Tk — Toplevel

Toplevel widgets are non-root windows. In other words, Toplevel widgets are windows that appear outside of the application’s root window. We can use Toplevel widgets for items such as dialogs, color picker windows, or even dragging tabs into windows. Basically, anytime you need a window that isn’t part of the main application, you can use a Toplevel to create it.

Here is an example of a script that creates Toplevel windows everytime the Spawn button is clicked.

from tkinter import *

count = 0


def spawn_top_level(text):
    global count

    # Create a new window with a label
    win = Toplevel()
    Label(win, text=text, font=('Arial', 32, 'italic')).pack(expand=YES, fill=BOTH)

    count += 1


# This is the main application window
root = Tk()
Button(root,
       text='Spawn',
       command=(lambda: spawn_top_level('Top Level: {}'.format(count)))).pack()
Button(root, text='Quit', command=root.quit).pack()
root.mainloop()

The code creates Toplevel windows inside of the spawn_top_level() function. The window itself is created on line 10, and then we attach a Label to it on line 11. Notice how we pass win as the Label’s parent on line 11. This is how Tkinter knows where to attach the label. When the script is run, we get something like the screenshot below.

toplevel

Advertisements

Tk – GUI Composition with Classes

Since Python’s Tk widgets are classes, it is really easy to compose GUIs by using Pythons OOP capabilities. OOP works really well because we can break complex GUIs down into smaller components and then compose a larger GUI out of these components. Let’s begin with a text area control.

from tkinter import *


class TextArea(Text):
    def __init__(self, parent=None):
        Text.__init__(self, parent, width=40, height=10, border=2)
        self.pack(expand=YES, fill=BOTH, side=TOP)

if __name__ == '__main__':
    TextArea(Toplevel())
    mainloop()

This code subclasses the Text control and initializes it to what would be a reasonably sized text area control that grows and shrinks with the window. We can verify if our control is working properly by using the self-test code found in the script. Here is a screenshot of what it looks like.

textarea

It’s not much of a window, but we aren’t done composing our GUI yet either. Now let’s make some buttons that will let us load, save, and quit the application. The buttons will be arranged horizontally from left to right. This time, we are going to subclass the Frame class.

class ControlPanel(Frame):
    def __init__(self, parent=None, save=None, load=None, quit_command=exit):
        Frame.__init__(self, parent)
        Button(self, text='Save', command=save).pack(side=LEFT)
        Button(self, text='Load', command=load).pack(side=LEFT)
        Button(self, text='Exit', command=quit_command).pack(side=LEFT)
        self.pack(expand=YES, fill=BOTH, side=TOP)

if __name__ == '__main__':
    ControlPanel(Toplevel())
    mainloop()

In this example, we are using the self variable as a parent object to our 3 objects. The ControlPanel’s constructor accepts three references to functions that act as event handlers for the buttons. Inside of the constructor, we create three buttons and set their text and command attributes. Then we pack them to the left side of the layout. Finally, the frame itself is packed. Running the self-test code gives us the following window.

control_panel

The final task is to combine our controls into a single window. Once again, we are going to subclass Frame.

class TextPanel(Frame):
    def __init__(self, parent=None):
        Frame.__init__(self, parent)
        TextArea(self)
        ControlPanel(self)
        self.pack(expand=YES, fill=BOTH)

if __name__ == '__main__':
    TextPanel(Toplevel())
    mainloop()

Notice how the TextPanel class simply uses TextArea and ControlPanel. Once again, we are using composition to build up a complex GUI. The beauty of this pattern is that we can use both TextArea and ControlPanel in other GUIs. Futhermore, the TextPanel class can also get embedded into other GUIs as well.

Since all three classes have test code, we can easily see how our code is working as we develop. This is part of the reason why it’s so easy to build up GUI applications in Python using Tk or another widget toolkit. We can easily contruct GUIs using OOP and then test then instantly and independently of the application.

Here is the finished GUI followed by a complete script.

complete

from tkinter import *


class TextArea(Text):
    def __init__(self, parent=None):
        Text.__init__(self, parent, width=40, height=10, border=2)
        self.pack(expand=YES, fill=BOTH, side=TOP)


class ControlPanel(Frame):
    def __init__(self, parent=None, save=None, load=None, quit_command=exit):
        Frame.__init__(self, parent)
        Button(self, text='Save', command=save).pack(side=LEFT)
        Button(self, text='Load', command=load).pack(side=LEFT)
        Button(self, text='Exit', command=quit_command).pack(side=LEFT)
        self.pack(expand=YES, fill=BOTH, side=TOP)


class TextPanel(Frame):
    def __init__(self, parent=None):
        Frame.__init__(self, parent)
        TextArea(self)
        ControlPanel(self)
        self.pack(expand=YES, fill=BOTH)


if __name__ == '__main__':
    TextArea(Toplevel())
    ControlPanel(Toplevel())
    TextPanel(Toplevel())
    mainloop()

Tk – Themed Widgets

Since Python Tk widgets are classes, we can use inheritance to specialize widgets for our applications. A common use case is specifying themes for our widgets so that our GUI controls look consistent. In this tutorial, I’ll explain how to make themed Tk widgets.

themed_buttons.py

from tkinter import *


class ThemedFrame(Frame):
    def __init__(self, parent=None, **configs):
        Frame.__init__(self, parent, **configs)
        self.config(bg='Red', borderwidth=10)
        self.pack(expand=YES, fill=BOTH)


class ThemedButton(Button):
    def __init__(self, parent=None, **configs):
        Button.__init__(self, parent, **configs)
        self.config(font=('Arial', 32))
        self.pack()


if __name__ == '__main__':
    frame = ThemedFrame()
    ThemedButton(frame, text='Quit', command=(lambda: sys.exit()))
    frame.mainloop()

The above code makes the following window. The background is red and the button has its font set to Arial 32. All of the ThemedButtons and ThemedFrames in this application will adhere to a consistent styling.

themed_widgets

Making the ThemedFrame and ThemedButton are fairly straightforward. For ThemedFrame, we create a ThemedFrame class and have it extend Frame. Line 6 calls the Frame’s __init__ method and then we start our custom configuration on line 7. In this case, we set the frame’s background to red and give it a border that is 10 pixels thick. Then we pack the frame and set it’s expand and fill options so that the frame always resizes with the window.

ThemedButton follows the same pattern as ThemedFrame. The ThemedButton class extends Button. On line 12, we call Button’s __init__ method followed by configuration options on line 14. In this case, we set the button’s font to Arial 32. Then we call the pack() method.

The demonstration part is found on lines 18-21. We create a ThemedFrame object on line 19. It’s made the same way as a regular Frame. Line 20 makes a ThemedButton. The constructor is consistent with Button’s constructor, so we are free to pass attributes such as the text and callback handlers to the button. Finally, we call mainloop() on ThemedFrame. All of this works because ThemedButton and ThemedFrame are simply specialization of their parent classes.

Tk Event Handling

All GUI programs need a way to respond to user interactions. The Tk GUI toolkit provided in Python provides us with a number of different ways to respond to user interactions. Let’s look at a few different ways we can make buttons respond to user events.

Pass a Function

Since Python considers functions to be objects, we can just pass a function to the event handler.

def click():
    print('Clicked')

root = Tk()
Button(root, text='Click Me', command=click).pack()
root.mainloop()

Use a Lambda

Lamdas are another popular way to express event handling code.

root = Tk()
Button(root, text='Click Me', command=(lambda: print('Clicked'))).pack()
root.mainloop()

Use a Class

Many programs construct GUIs using Pythons OOP capabilities. As such, we can bind a class method to the event handler also.

class MyClass:
    def __init__(self, root):
        self.button = Button(root, text='Class', command=self.command).pack()

    def command(self):
        print('Class handler')

root = Tk()
MyClass(root)
root.mainloop()

Override the __call__ method

We can also construct classes that overload the __call__ operator. Doing so is useful when we need to pass complex information along to an event handler.

class MyCallable:
    def __init__(self):
        self.message = '__call__ handler'

    def __call__(self):
        print(self.message)

root=Tk()
Button(root, text='Callable', command=MyCallable()).pack()
root.mainloop()

Event Binding

We can also make direct calls to Tk

def print_me():
    print('binding')

root = Tk()

w = Button(root, text='Binding')
w.pack()
w.bind('<Button-1>, print_me)
root.mainloop()

Complete Program

Below is a complete program that demonstrates all of the above patterns.

import sys
from tkinter import *


def write():
    print('Function call')


class HelloClass:
    def __init__(self, root):
        self.button = Button(root, text='Class', command=self.command).pack(side=LEFT, fill=X)

    def command(self):
        print('Class handler')


class Callable:
    def __init__(self):
        self.message = '__call__ handler'

    def __call__(self):
        print(self.message)


def printMe(event):
    print('Double click to quit')


def quit(event):
    sys.exit()


if __name__ == '__main__':
    root = Tk()

    Button(root, text='Function', command=write).pack(side=LEFT, fill=X)
    Button(root, text='Lambda', command=(lambda: print('Labmda call'))).pack(side=LEFT, fill=X)
    HelloClass(root)
    Button(root, text='Callable', command=Callable()).pack(side=LEFT, fill=X)

    w = Button(root, text='Binding')
    w.pack(side=LEFT, fill=X)
    w.bind('<Button-1>', printMe)
    w.bind('<Double-1>', quit)

    root.mainloop()

Python – Getting Started With TK

Python has a variety of widget libraries, but TK is the one included in CPython. This post shows a very basic Python program that uses TK to create an application window with a label and a button. The application closes when the user clicks on the button.

from tkinter import *

root = Tk()

Label(root, text='Click to quit => ').pack(side=LEFT, expand=YES, fill=BOTH)
Button(root, text='Quit', command=sys.exit).pack(side=LEFT, expand=YES, fill=BOTH)

root.mainloop()

The following window appears when the application is executed.
tk

Explanation

The program starts by importing the tkinter module on line 1. This module contains the widgets (or controls) that we need to create our application window. On line 3, we create a root variable and assign it to a main (or root) window by calling the Tk() function. We are now ready to start creating our controls.

Line 5 creates a Label control. The first argument in the constructor is its parent window, so we pass in root. The text argument assigns text to the label. Next, we call the pack() method on the control. In our case, we use three optional arguments. Side is used to tell the layout manager which side the control should stick too. In our case, we want to left aling our controls so we use LEFT. The expand parameter tells the label to expand with the window, while the fill control tells the control which directions it should expand or shrink (horizontal, vertical, or both).

Line 6 creates a Button that we can click on. The root is still the main window while the text is the button’s text. The command is the action the button should execute when clicked. In our case, we are telling the application to exit because we are passing the sys.exit function to the command argument. The pack() method does the same as the Label on line 5.

Finally, we want to show the window and make the program wait for events. We do this by calling root.mainloop(). Once mainloop() executes, the script will only respond to code found in event handlers, which is command=sys.exit in our case.

Hangman—PyQt5 (Part 5)

This is the part in a serious of tutorials that describe how to make a Hangman game using Python and PyQt5. You can view the other parts of this series at the following links:

Putting it all together

So far, we have been making panels that inherit from QWidget. Although we tested each of those panels in isolation, the intended use of these panels is to group them together into a main application window. The other job we have to do is wire up our controls. Right now, none of our QPushButtons do anything when they are clicked. This section of the tutorial will demonstrate how to connect click events to functions that handle the event.

For reference, here is the module code

import sys

from PyQt5.QtCore import QCoreApplication
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import (QWidget, QPushButton, QGridLayout, QApplication, QLabel, QHBoxLayout, QVBoxLayout,
                             QMainWindow, QInputDialog)

from hangman import Hangman


class HangmanPanel(QWidget):
    def __init__(self, hangman, frames):
        super().__init__()
        self.hangman = hangman
        self.frame_index = 0
        self.frames = self.create_frames(frames)
        self.label = QLabel(self)
        self.layout = QHBoxLayout(self)
        self.layout.addWidget(self.label)
        self.advance_frame()

    def create_frames(self, frames):
        frm = []
        for f in frames:
            frm.append(QPixmap(f))
        return tuple(frm)

    def advance_frame(self):
        self.label.setPixmap(self.frames[self.frame_index])
        self.frame_index += 1

        if self.frame_index >= len(self.frames):
            self.frame_index = 0

    def reset_hangman(self):
        self.frame_index = 0
        self.advance_frame()


class LetterPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman
        self.create_buttons()
        self.grid = QGridLayout(self)
        self.position_buttons()

    def create_buttons(self):
        letters = ('A', 'B', 'C', 'D', 'E',
                   'F', 'G', 'H', 'I', 'J',
                   'K', 'L', 'M', 'N', 'O',
                   'P', 'Q', 'R', 'S', 'T',
                   'U', 'V', 'W', 'X', 'Y', 'Z')
        buttons = []
        for l in letters:
            buttons.append(QPushButton(l, self))
        self.buttons = tuple(buttons)

    def position_buttons(self):
        positions = [(i, j) for i in range(7) for j in range(4)]
        buttons = list(self.buttons)
        buttons.reverse()

        for pos in positions:
            if buttons:
                self.grid.addWidget(buttons.pop(), *pos)

    def activate_all(self):
        for button in self.buttons:
            button.setEnabled(True)


class WinsPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.label = QLabel(self)
        self.label.setText('Wins')

        self.win_label = QLabel(self)
        self.win_label.setText('0')

        self.layout = QHBoxLayout(self)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.win_label)

    def update_wins(self):
        self.win_label.setText(str(hangman.wins))


class DisplayPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.label = QLabel(self)
        self.word_label = QLabel(self)

        self.layout = QVBoxLayout(self)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.word_label)


class WordPanel(DisplayPanel):
    def __init__(self, hangman):
        super().__init__(hangman)

        self.label.setText('Current Word')
        self.update_word()

    def update_word(self):
        self.word_label.setText(' '.join(self.hangman.display_letters))


class GuessedLetterPanel(DisplayPanel):
    def __init__(self, hangman):
        super().__init__(hangman)

        self.label.setText("Letters Already Guessed")
        self.update_letters()

    def update_letters(self):
        self.word_label.setText(', '.join(self.hangman.guessed_letters))


class HangmanWindow(QMainWindow):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.wins_panel = WinsPanel(hangman)
        self.hangman_panel = HangmanPanel(hangman, ['hangman_0.png',
                                                    'hangman_1.png',
                                                    'hangman_2.png',
                                                    'hangman_3.png',
                                                    'hangman_4.png',
                                                    'hangman_5.png',
                                                    'hangman_6.png',
                                                    'hangman_7.png'])
        self.word_panel = WordPanel(hangman)
        self.guessed_letter_panel = GuessedLetterPanel(hangman)
        self.letter_panel = LetterPanel(hangman)

        central_widget = QWidget()
        central_layout = QHBoxLayout(central_widget)

        left_widget = QWidget()
        left_layout = QVBoxLayout(left_widget)
        left_layout.addWidget(self.wins_panel)
        left_layout.addWidget(self.hangman_panel)
        left_layout.addWidget(self.word_panel)
        left_layout.addWidget(self.guessed_letter_panel)

        right_widget = QWidget()
        right_layout = QHBoxLayout(right_widget)
        right_layout.addWidget(self.letter_panel)

        central_layout.addWidget(left_widget)
        central_layout.addWidget(right_widget)

        self.connect_listeners()
        self.setCentralWidget(central_widget)
        self.setWindowTitle('Hangman')
        self.show()

    def connect_listeners(self):
        for button in self.letter_panel.buttons:
            button.clicked.connect(self.on_letter_button_click)

    def on_letter_button_click(self):
        sender = self.sender()

        letter = sender.text()
        sender.setEnabled(False)

        current_guess = hangman.guesses

        self.hangman.guess_letter(letter)
        self.guessed_letter_panel.update_letters()
        self.word_panel.update_word()

        if current_guess < hangman.guesses:
            self.hangman_panel.advance_frame()

        if hangman.check_win():
            self.wins_panel.update_wins()
            self.show_win()
        elif hangman.check_lose():
            self.show_lose()

    def show_win(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Win! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def show_lose(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Lost! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def reset_game(self):
        hangman.start_game()
        self.wins_panel.update_wins()
        self.hangman_panel.reset_hangman()
        self.word_panel.update_word()
        self.guessed_letter_panel.update_letters()
        self.letter_panel.activate_all()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    hangman = Hangman('words.txt', 6)
    win = HangmanWindow(hangman)
    sys.exit(app.exec_())

HangmanWindow

HangmanWindow is our main application window. One difference you will notice right off of the bat is that this class inherits from QMainWindow instead of QWidget.

class HangmanWindow(QMainWindow):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.wins_panel = WinsPanel(hangman)
        self.hangman_panel = HangmanPanel(hangman, ['hangman_0.png',
                                                    'hangman_1.png',
                                                    'hangman_2.png',
                                                    'hangman_3.png',
                                                    'hangman_4.png',
                                                    'hangman_5.png',
                                                    'hangman_6.png',
                                                    'hangman_7.png'])
        self.word_panel = WordPanel(hangman)
        self.guessed_letter_panel = GuessedLetterPanel(hangman)
        self.letter_panel = LetterPanel(hangman)

        central_widget = QWidget()
        central_layout = QHBoxLayout(central_widget)

        left_widget = QWidget()
        left_layout = QVBoxLayout(left_widget)
        left_layout.addWidget(self.wins_panel)
        left_layout.addWidget(self.hangman_panel)
        left_layout.addWidget(self.word_panel)
        left_layout.addWidget(self.guessed_letter_panel)

        right_widget = QWidget()
        right_layout = QHBoxLayout(right_widget)
        right_layout.addWidget(self.letter_panel)

        central_layout.addWidget(left_widget)
        central_layout.addWidget(right_widget)

        self.connect_listeners()
        self.setCentralWidget(central_widget)
        self.setWindowTitle('Hangman')
        self.show()

    def connect_listeners(self):
        for button in self.letter_panel.buttons:
            button.clicked.connect(self.on_letter_button_click)

    def on_letter_button_click(self):
        sender = self.sender()

        letter = sender.text()
        sender.setEnabled(False)

        current_guess = hangman.guesses

        self.hangman.guess_letter(letter)
        self.guessed_letter_panel.update_letters()
        self.word_panel.update_word()

        if current_guess < hangman.guesses:
            self.hangman_panel.advance_frame()

        if hangman.check_win():
            self.wins_panel.update_wins()
            self.show_win()
        elif hangman.check_lose():
            self.show_lose()

    def show_win(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Win! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def show_lose(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Lost! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def reset_game(self):
        hangman.start_game()
        self.wins_panel.update_wins()
        self.hangman_panel.reset_hangman()
        self.word_panel.update_word()
        self.guessed_letter_panel.update_letters()
        self.letter_panel.activate_all()

This class is much larger than our other panel classes for the simple reason that it creates all of our panels and then connects the controls to event handler functions.

__init__

We begin with the __init__ method. There is a lot going on in this method so we are going to take it a little bit at a time.

def __init__(self, hangman):
    super().__init__()
    self.hangman = hangman

    self.wins_panel = WinsPanel(hangman)
    self.hangman_panel = HangmanPanel(hangman, ['hangman_0.png',
                                                'hangman_1.png',
                                                'hangman_2.png',
                                                'hangman_3.png',
                                                'hangman_4.png',
                                                'hangman_5.png',
                                                'hangman_6.png',
                                                'hangman_7.png'])
    self.word_panel = WordPanel(hangman)
    self.guessed_letter_panel = GuessedLetterPanel(hangman)
    self.letter_panel = LetterPanel(hangman)

    central_widget = QWidget()
    central_layout = QHBoxLayout(central_widget)

    left_widget = QWidget()
    left_layout = QVBoxLayout(left_widget)
    left_layout.addWidget(self.wins_panel)
    left_layout.addWidget(self.hangman_panel)
    left_layout.addWidget(self.word_panel)
    left_layout.addWidget(self.guessed_letter_panel)

    right_widget = QWidget()
    right_layout = QHBoxLayout(right_widget)
    right_layout.addWidget(self.letter_panel)

    central_layout.addWidget(left_widget)
    central_layout.addWidget(right_widget)

    self.connect_listeners()
    self.setCentralWidget(central_widget)
    self.setWindowTitle('Hangman')
    self.show()

As always, we start with calling super().__init__() to intialize the Qt window. Our next job is to assign the hangman variable to self.hangman. Doing this allows use to use the Hangman class to control the application’s logic.

Our next job is create our panels. We create a WinPanel (TODO: Link) (self.win_panel) and then we create a HangmanPanel (TODO: Link) (assign it to self.hangman_panel) and supply it with the file names of our frames. Then we create a WordPanel (TODO: Link) (assign it to self.word_panel), then a GuessedLetterPanel (self.guessed_letter_panel), and a LetterPanel (TODO: Link) (self.letter_panel).

Now, we can’t simply add these widgets to our window. QMainWindow uses a centeral widget that contains all of our widgets. We create a central_widget object and then on the following line assign a QHBoxLayout to this widget. Next we create two more nested QWidgets. The first widget is left_widget. This widget gets a QVBoxLayout and then we add self.win_panel (WinPanel), self.hangman_panel (HangmanPanel), self.word_panel (WordPanel), and self.guessed_letter_panel (GuessedLetterPanel) to this widget. That makes everything but the letter buttons appear on the left hand side of the screen.

Now we make another QWidget called right_widget. This widget gets a QHBoxLayout and then we added self.letter_panel (LetterPanel) to this widget. Now that we have our left_widget and right_widget, we can add them both to central_widget. The two widgets will stack up left to right.

Now that we have layed our screen, we call self.connect_listeners() to wire up the buttons to their event handling code. We then call setCentralWidget and pass central_widget to it. This places all of the controls on the QMainWindow. The next line of code sets the title of the window to “Hangman”. Finally, we call self.show() to actually show the window.

connect_listeners

Right now we have 26 QPushButtons on our window that don’t do anything. We are going to address that issue in this method.

def connect_listeners(self):
    for button in self.letter_panel.buttons:
        button.clicked.connect(self.on_letter_button_click)

If you remember from LetterPanel (TODO: Link), LetterPanel has a tuple of 26 buttons. QPushButton maintains a clicked object that has a connect method. The clicked is the name of the event (which fires when the user clicks on the button). The connect is a method that takes a Callable, (functions for the most part, but can be lambdas or classes that implement Callable). In our case, we are going to connect a click to our class’s on_letter_button_click method.

on_letter_button_click

This function gets called anytime we we click on a button. We connected this function to QPushButton’s clicked event in the connect_listeners function.

def on_letter_button_click(self):
    sender = self.sender()

    letter = sender.text()
    sender.setEnabled(False)

    current_guess = hangman.guesses

    self.hangman.guess_letter(letter)
    self.guessed_letter_panel.update_letters()
    self.word_panel.update_word()

    if current_guess < hangman.guesses:
        self.hangman_panel.advance_frame()

    if hangman.check_win():
        self.wins_panel.update_wins()
        self.show_win()
    elif hangman.check_lose():
        self.show_lose()

The first thing we need to do is get the source of the click event. We can get this using QMainWindow's sender() method. The next thing to do is get the letter. Remember that we set the label of each QPushButton to a letter in the alphabit. Therefore, we can get the guessed letter but using the text() method on QPushButton. Next we call sender.setEnabled to false so that they can't guess the same letter twice.

Now it's time for our HangmanObject to do some work. We start by grabbing the current number of wrong guesses but accessing hangman.guesses. On the next line, we pass the guessed letter to hangman.guess_letter. It's going to do the work of making a determination if the letter was a correct guess or not.

After guessing a letter, we call self.guessed_letter_panel.update_letters() to update the guessed letter portion of the UI. Then we call self.word_panel.update_word() to update the current word portion of the UI.

Next we need to see if we need to show the man in hangman getting hanged. We compare the current_guess against hangman.guesses. If they guessed wrong, then hangman.guessess will be larger than current_guess. Otherwise the are equal. If they guessed wrong, we update the HangmanPanel of the UI by calling self.hangman_panel.advance_frame()

Finally we need to check if the user has won or lost the game. We use hangman.check_win to see if they have won. If they have won, we need to update the WinsPanel by calling self.wins_panel.update_wins() and then call self.show_win() to notify the user that they have won.

Alternatively, the user may have lost the game. If that's the case, we call self.show_lose()

show_win

If the user wins, we want to show a dialog window that tells them they have won. It looks like this screen shot
wins copy.png
Here is the code for this function

def show_win(self):
    items = ('Yes', 'No')
    item, ok = QInputDialog.getItem(self, 'You Win! Play again?', 'Choice:', items, 0, False)
    if ok and item == 'Yes':
        self.reset_game()
    else:
        QCoreApplication.instance().quit()

This function starts out by creating a tuple that contains the two choices we are presenting to the user. Then we call QInputDialog.getItem to get the users choice. This creates a dialog box that is populated with the choices ‘Yes’ or ‘No’. It returns a tuple that we unpack into the variables item and ok. Item is the users choice and ok if if they clicked the Ok button rather than cancel.

If they clicked ok and selected Yes, we call self.reset_game() to start a new game. Otherwise we use QCoreApplication.instance().quit() to exit the application.

show_lose

This code does the exact same thing as show_win but it shows a different error message. Here is the code, but there isn’t anything new to explain.

def show_lose(self):
    items = ('Yes', 'No')
    item, ok = QInputDialog.getItem(self, 'You Lost! Play again?', 'Choice:', items, 0, False)
    if ok and item == 'Yes':
        self.reset_game()
    else:
        QCoreApplication.instance().quit()

reset_game

The final function in this class is the reset_game function. It does the job of reseting the UI and Hangman to their default states so that we can play a new game.

def reset_game(self):
    hangman.start_game()
    self.wins_panel.update_wins()
    self.hangman_panel.reset_hangman()
    self.word_panel.update_word()
    self.guessed_letter_panel.update_letters()
    self.letter_panel.activate_all()

We call hangman.start_game to reset Hangman. We also update the WinsPanel portion of the UI. Next we need to update the HangmanPanel to it’s first frame by calling reset_hangman().

Since Hangman has picked a new word at this point, we need to call WordPanel.update_word() to replace the word with dashes again. We also need to clear out the guessed letters so we call GuessedLetterPanel.update_letters() to erase all of the guessed letters. Finally we have to enable all of the QPushButtons so use LetterPanel.activate_all() to re-enable all of our QPushButtons.

Run the program

We run the program like this

if __name__ == '__main__':
    app = QApplication(sys.argv)
    hangman = Hangman('words.txt', 6)
    win = HangmanWindow(hangman)
    sys.exit(app.exec_())

We begin by initializing Qt by creating a QApplication object and passing the command line arguments to it. Next we create a Hangman object and pass the files with our word bank and the number of allowed guesses to it. The next line creates our HangmanWindow. Finally we call sys.exit and pass the QApplication.exec_() function to it so that the application exits properly.

Conclusion

We came a long way in this tutorial. We had a lot of practice with object orientated programming (OOP) and we got a tour of PyQt5. This program read a file line by line, joined strings, and even handled GUI event programming. Here is alink to the complete project! Enjoy playing Hangman!

Hangman—PyQt5 (Part 4)

This is part 4 in a tutorial that describes how to make a Hangman game using Python and PyQt5. You can view the other parts of this series at the following links:

We have 4 small classes that we are going to cover in this potion of the tutorial. They are WinsPanel, DisplayPanel, WordPanel, and GuessedLetterPanel. In this portion, we will see QVBoxLayout, join a string together, create mock objects for unit testing, and us interhitence to promote code reuse.

Here is the complete module code for reference followed by a discussion of the individual classes for this portion of the tutorial.


from PyQt5.QtCore import QCoreApplication
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import (QWidget, QPushButton, QGridLayout, QApplication, QLabel, QHBoxLayout, QVBoxLayout,
                             QMainWindow, QInputDialog)

from hangman import Hangman


class HangmanPanel(QWidget):
    def __init__(self, hangman, frames):
        super().__init__()
        self.hangman = hangman
        self.frame_index = 0
        self.frames = self.create_frames(frames)
        self.label = QLabel(self)
        self.layout = QHBoxLayout(self)
        self.layout.addWidget(self.label)
        self.advance_frame()

    def create_frames(self, frames):
        frm = []
        for f in frames:
            frm.append(QPixmap(f))
        return tuple(frm)

    def advance_frame(self):
        self.label.setPixmap(self.frames[self.frame_index])
        self.frame_index += 1

        if self.frame_index >= len(self.frames):
            self.frame_index = 0

    def reset_hangman(self):
        self.frame_index = 0
        self.advance_frame()


class LetterPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman
        self.create_buttons()
        self.grid = QGridLayout(self)
        self.position_buttons()

    def create_buttons(self):
        letters = ('A', 'B', 'C', 'D', 'E',
                   'F', 'G', 'H', 'I', 'J',
                   'K', 'L', 'M', 'N', 'O',
                   'P', 'Q', 'R', 'S', 'T',
                   'U', 'V', 'W', 'X', 'Y', 'Z')
        buttons = []
        for l in letters:
            buttons.append(QPushButton(l, self))
        self.buttons = tuple(buttons)

    def position_buttons(self):
        positions = [(i, j) for i in range(7) for j in range(4)]
        buttons = list(self.buttons)
        buttons.reverse()

        for pos in positions:
            if buttons:
                self.grid.addWidget(buttons.pop(), *pos)

    def activate_all(self):
        for button in self.buttons:
            button.setEnabled(True)


class WinsPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.label = QLabel(self)
        self.label.setText('Wins')

        self.win_label = QLabel(self)
        self.win_label.setText('0')

        self.layout = QHBoxLayout(self)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.win_label)

    def update_wins(self):
        self.win_label.setText(str(hangman.wins))

class DisplayPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.label = QLabel(self)
        self.word_label = QLabel(self)

        self.layout = QVBoxLayout(self)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.word_label)


class WordPanel(DisplayPanel):
    def __init__(self, hangman):
        super().__init__(hangman)

        self.label.setText('Current Word')
        self.update_word()

    def update_word(self):
        self.word_label.setText(' '.join(self.hangman.display_letters))


class GuessedLetterPanel(DisplayPanel):
    def __init__(self, hangman):
        super().__init__(hangman)

        self.label.setText("Letters Already Guessed")
        self.update_letters()

    def update_letters(self):
        self.word_label.setText(', '.join(self.hangman.guessed_letters))


class HangmanWindow(QMainWindow):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.wins_panel = WinsPanel(hangman)
        self.hangman_panel = HangmanPanel(hangman, ['hangman_0.png',
                                                    'hangman_1.png',
                                                    'hangman_2.png',
                                                    'hangman_3.png',
                                                    'hangman_4.png',
                                                    'hangman_5.png',
                                                    'hangman_6.png',
                                                    'hangman_7.png'])
        self.word_panel = WordPanel(hangman)
        self.guessed_letter_panel = GuessedLetterPanel(hangman)
        self.letter_panel = LetterPanel(hangman)

        central_widget = QWidget()
        central_layout = QHBoxLayout(central_widget)

        left_widget = QWidget()
        left_layout = QVBoxLayout(left_widget)
        left_layout.addWidget(self.wins_panel)
        left_layout.addWidget(self.hangman_panel)
        left_layout.addWidget(self.word_panel)
        left_layout.addWidget(self.guessed_letter_panel)

        right_widget = QWidget()
        right_layout = QHBoxLayout(right_widget)
        right_layout.addWidget(self.letter_panel)

        central_layout.addWidget(left_widget)
        central_layout.addWidget(right_widget)

        self.connect_listeners()
        self.setCentralWidget(central_widget)
        self.setWindowTitle('Hangman')
        self.show()

    def connect_listeners(self):
        for button in self.letter_panel.buttons:
            button.clicked.connect(self.on_letter_button_click)

    def on_letter_button_click(self):
        sender = self.sender()

        letter = sender.text()
        sender.setEnabled(False)

        current_guess = hangman.guesses

        self.hangman.guess_letter(letter)
        self.guessed_letter_panel.update_letters()
        self.word_panel.update_word()

        if current_guess < hangman.guesses:
            self.hangman_panel.advance_frame()

        if hangman.check_win():
            self.wins_panel.update_wins()
            self.show_win()
        elif hangman.check_lose():
            self.show_lose()

    def show_win(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Win! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def show_lose(self):
        items = ('Yes', 'No')
        item, ok = QInputDialog.getItem(self, 'You Lost! Play again?', 'Choice:', items, 0, False)
        if ok and item == 'Yes':
            self.reset_game()
        else:
            QCoreApplication.instance().quit()

    def reset_game(self):
        hangman.start_game()
        self.wins_panel.update_wins()
        self.hangman_panel.reset_hangman()
        self.word_panel.update_word()
        self.guessed_letter_panel.update_letters()
        self.letter_panel.activate_all()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    hangman = Hangman('words.txt', 6)
    win = HangmanWindow(hangman)
    sys.exit(app.exec_())

WinsPanel

Our application supports tracking the number of times the user wins the game. We can display this information with a panel that creates two QLabels and displays them horizontally.

class WinsPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.label = QLabel(self)
        self.label.setText('Wins')

        self.win_label = QLabel(self)
        self.win_label.setText('0')

        self.layout = QHBoxLayout(self)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.win_label)

    def update_wins(self):
        self.win_label.setText(str(hangman.wins))

As always, we can test this panel in isolation by using the following code snippet.

if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = WinsPanel(None)
    win.show()
    sys.exit(app.exec_())

When run, the panel looks like the following screen shot
WinsPanel copy

Let’s walk through the code

__init__

The WinPanel’s __init__ method does the job of creating two QLabel objects and a QHBoxLayout. Here is the code for __init__ followed by an explanation.

def __init__(self, hangman):
    super().__init__()
    self.hangman = hangman

    self.label = QLabel(self)
    self.label.setText('Wins')

    self.win_label = QLabel(self)
    self.win_label.setText('0')

    self.layout = QHBoxLayout(self)
    self.layout.addWidget(self.label)
    self.layout.addWidget(self.win_label)

There really isn’t anything here that we haven’t seen before. We begin by calling QWidget’s __init__ method so that we can add widgets to this panel. This panel also takes a Hangman object. The next two lines create a QLabel with the text ‘Wins’.

The following the creation of the first QLabel, we create another QLabel and assign it to self.win_label with a text of ‘0’. This label will get updated in the update_wins function below. It will display the number of games of Hangman the user won.

The final three lines create a QHBoxLayout to arrange controls from left to right and then we use addWidget to add the two QLabel objects that we created.

update_wins

The update_wins function get’s called by the application to update the win_label’s text.

def update_wins(self):
     self.win_label.setText(str(hangman.wins))

The Hangman object has a wins property. Remember that our application’s logic is seperate from the presentation (the UI), which is why this class is grabbing the value from Hangman rather than keeping it’s own win count. All we are doing is using QLabel’s setText method to update the text in the label. Since hangman.wins is an integer, we need to convert it to a string using the str function to avoid a type error.

DisplayPanel

WordPanel and GuessedLetterPanel are incredibly similar and even use the same controls and layouts. Whenever we have patterns in our code, we should use the “Do Not Repeat” principle. In this case, we are going to define a class that is only used as a base class for WordPanel and GuessedLetterPanel. Here is the code followed by an explanation.

class DisplayPanel(QWidget):
    def __init__(self, hangman):
        super().__init__()
        self.hangman = hangman

        self.label = QLabel(self)
        self.word_label = QLabel(self)

        self.layout = QVBoxLayout(self)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.word_label)

__init__

The DisplayPanel class only has one method __init__. This class does the job of assigning a hagnman object for later use and creating two QLabel objects. You will notice that the QLabel objects do not have their text set.

We also meet a new layout manager here. The QVBoxLayout is used to stack widgets from top to bottom. We create the QVBoxLayout and then add both of our labels to the layout manager.

WordPanel

WordPanel does the job of displaying the user’s correct guesses. It works with Hangman’s display_letters list object to display the letters the user has guessed correctly or show’s dashes (‘-‘) for letters that are still unknown to the user. Here is the code for this class.

class WordPanel(DisplayPanel):
    def __init__(self, hangman):
        super().__init__(hangman)

        self.label.setText('Current Word')
        self.update_word()

    def update_word(self):
        self.word_label.setText(' '.join(self.hangman.display_letters))

Using this code, we can run this panel in isolation

if __name__ == '__main__':
    class MockHangman:
        def __init__(self):
            self.display_letters = ['-', '-', 'A', '-', '-']

    app = QApplication(sys.argv)
    win = WordPanel(MockHangman())
    win.show()
    sys.exit(app.exec_())

You will notice in this case we need a special MockHangman class to test WordPanel. Python is a dynamically typed language. As long as objects satisfy an interface, Python could care less about the kind of object. This feature (often called duck typing) is a major advantage of Python.

WordPanel needs a Hangman object that has a display_letters variable. Since our MackHangman has this variable, it can satisfy WordPanel’s Hangman dependency. Defining classes on the fly like we did above makes it really easy to test Python objects in isolation.

Our panel looks like this when it’s run
WordPanel copy

Now let’s discuss the code

__init__

What is special about WordPanel’s __init__ is that it is so short. Here is the code for the __init__ method for this class.

def __init__(self, hangman):
    super().__init__(hangman)

    self.label.setText('Current Word')
    self.update_word()

You will notice that we call DisplayPanel’s __init__ and pass the hangman object to the parent class’s constructor. We set the text of the label also, but we never create the QLabel object here. That’s already been done for us in DisplayPanel’s __init__. We have direct access to the QLabel right away. We don’t even worry about creating a layout manager here since that also was taken care of for us already.

update_word

We are going to see how to combine a list into a string.

def update_word(self):
    self.word_label.setText(' '.join(self.hangman.display_letters))

The ' '.join(self.hangman.display_letters) is the code of interest here. Whenever we want to combine elements of an iterable (lists are iterables) into a string, we start with a string that is used as a seperator. In our case, we are using a blank space ‘ ‘ that appears between every letter. If you don’t want a space, just use an empty string ”. Now we use the join method on string, which accepts any iterable. Our iterable is self.hangman.display_letters. So each element in self.hangman.display_letters is joined into a single string.

At this point, we just use QLabels.setText method to update the text in the string.

GuessedLetterPanel

GuessedLetterPanel’s job is to display the letters the user has guessed so far. Here is the code for this class.

class GuessedLetterPanel(DisplayPanel):
    def __init__(self, hangman):
        super().__init__(hangman)

        self.label.setText("Letters Already Guessed")
        self.update_letters()

    def update_letters(self):
        self.word_label.setText(', '.join(self.hangman.guessed_letters))

We can test this class as a stand alone component. However, to do so, we need a Mock Hangman object. Here is the code to run this panel in isolation.

if __name__ == '__main__':
    class MockHangman:
        def __init__(self):
            self.guessed_letters = ['A', 'E', 'I', 'O', 'U']

    app = QApplication(sys.argv)
    win = GuessedLetterPanel(MockHangman())
    win.show()
    sys.exit(app.exec_())

This is what our panel looks like when it’s ran in isolation.
GuessedLettersPanel copy

__init__

Once again, GuessedLetterPanel is a child class of DisplayPanel. For this reason, we don’t need to worry about things like creating our QLabel objects or our layout. We just set the variables to the values we need them at and let the parent class do the work for us.

def __init__(self, hangman):
    super().__init__(hangman)

    self.label.setText("Letters Already Guessed")
    self.update_letters()
[/code

<h3>update_letters<h3>
This method is another example of how to join an iterable into a string.

def update_letters(self):
    self.word_label.setText(', '.join(self.hangman.guessed_letters))

In this case, we use ', ' as our letter seperator rather than a blank space.

Conclusion

We convered quite a bit of ground in this section. The code in these classes showed us how to use QVBoxLayout. We learned how to join strings together into a single string using String's join method. Furthermore, we saw how to create mock objects that help us with unit testing our classes. Finally, we saw how to use inheritance to group similar code together.