Tk Standard Dialogs

Applications generally need to show system dialogs to alert the user to events. In this post, we will cover the yes or no dialog, a warning dialog, information dialog, and an error dialog. Tk uses system calls to show dialogs that are native to the underlying platform. Therefore, dialogs on Windows will look like they should on Windows while Mac OS X dialogs will appear correct for that platform.

askyesno

The askyesno is a dialog that is used to present a user with a yes or no choice. It returns a boolean to the caller.

result = askyesno('Yes No Demo', 'Click on either yes or no')

yesno

showwarning

You use showwarning when you want to warn the user about something.

showwarning('Warning Demo', 'You have been warned')

warning

showinfo

This dialog is used to supply the user with information.

showinfo('Info Demo', 'This is some information')

info

showerror

You should use showerror when you need to report an error to the user.

showerror('Error Demo', 'This is an error')

error

Putting it Together

Standard dialog calls are a useful way to notify the user about something important. Since they block the program’s execution, the user is forced to interact with the dialog. This makes the dialogs ideal for forcing the user to read a message or make a choice. Below is a complete program that demonstrates all of the dialogs.

from tkinter import *
from tkinter.messagebox import *


def ask_yes_no_demo():
    result = askyesno('Yes No Demo', 'Click on either yes or no')
    if result:
        showinfo('Result', 'You clicked on Yes')
    else:
        showinfo('Result', 'You clicked on No')


def warning_demo():
    showwarning('Warning Demo', 'You have been warned')


def info_demo():
    showinfo('Info Demo', 'This is some information')


def error_demo():
    showerror('Error Demo', 'This is an error')


root = Tk()
Button(text='Ask Yes No', command=ask_yes_no_demo).pack(fill=X)
Button(text='Warning', command=warning_demo).pack(fill=X)
Button(text='Info', command=info_demo).pack(fill=X)
Button(text='Error', command=error_demo).pack(fill=X)
Button(text='Quit', command=(lambda: sys.exit(0))).pack(fill=X)
mainloop()
Advertisements

Hangman—PyQt5 (Part 3)

This is part 3 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:

The next step…

In Part 2, we created a panel that shows our man getting hanged as the user inputs incorrect guesses. We are now going to continue by discussing the LetterPanel, which is a panel that holds 26 QPushButtons. Each QPushButton will correspond to a letter of the alphabit.

This is the code for the entire module for reference.

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_())

We are going to focus on the LetterPanel of this code. The LetterPanel explores some other features of PyQt5 which include GridLayout and QPushButton.

Letter Panel

This is the code for the letter panel

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)

Just like HangmanPanel, this panel inherits QWidget so that it can be used a GUI component. This allows us to nest this panel into an application window or run it as a stand alone window.

We can run it in a standalone window using the following code

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

This is what the panel looks like when run in isolation.
LetterPanel copy

Now let’s discuss the code in this class.

__init__

By now you should be familiar with Python’s __init__ method. Here is the code for LetterPanel’s __init__ followed by an explanation.

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

We start by calling QWidget’s __init__ function so that we can add controls to this panel. We also supply a Hangman object to this class also.

The next line does the work of creating 26 buttons that get placed on this panel. We are going to do this in a seperate create_buttons() function. Next we need to layout the buttons into a grid pattern. QGridLayout is a powerful class that does the job of laying out components in a grid like fashion.

At this point, we have 26 buttons and a grid, but we need to actually add the buttons to the grid. Once again, we are going to use a seperate function position_buttons() and delegate this work to that function.

create_buttons

We met the create_buttons function in __init__. Here is the code followed by an explanation.

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)

We begin by creating a tuple of upper case letters, followed by an empty list of buttons. Since all of the buttons are going to do a similar job (which is sending a guessed letter to Hangman), it is much easier and much more maintainable to use a list to hold our QPushButton objects rather than using 26 or more variables for each button.

The next thing to do is iterate through each letter an create a QPushButton. The first argument of QPushButton’s __init__ method takes a string that will eventually contains the button’s text. So when we do QPushButton(l, self) we are going to set the label of the button to the current letter. Then we add the newly created QPushButton to the buttons list.

We don’t plan on adding, changing, or shuffling our buttons after the GUI is created. To write protect our data, we convert buttons to a tuple and then assign it to self.buttons.

position_buttons

We met position_buttons in the __init__ method. This function does the job of adding the buttons created in create_button to the screen.

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)

QGridLayout uses an x,y position to add controls to the grid. So our first line is using Python’s generator statement to create a 2D list of x,y positions. We will end up with a list that is 7 rows deep and 4 columns wide.

Now, this is the one case where we need to manipulate the order of our buttons. We create a buttons list out of self.buttons (remember that self.buttons is a tuple) so that we can reverse the order of the buttons. If we forget to do this, we would have the letter ‘Z’ appear first in our grid rather than last in grid. This is becuase we are going to use the pop() method to remove the last element in a list. Therefore, we need ‘Z’ to be at the first position in the list and ‘A’ at the last position.

Now we are going to add the buttons to the grid. We are going to iterate through the positions list object. The variable pos is a tuple that contains the x,y pair of coordinates.

Now we need to check that buttons isn’t empty, so we use if buttons first to make sure we aren’t calling pop() on an empty list. As long as we still have buttons, we can add them to self.grid.addWidget. The *pos is a shortcut way of adding the x,y pair into the addWidget’s method without having to create two variables.

activate_all

This last method is used later on when we want to reset the game. It simply re-enables all disabled buttons (which get disabled after the user clicks on them).

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

Conclusion

We covered some new ground in this portion of the tutorial. QPushButton is a widget that creates a regular push button on the street. QGridLayout is a layout manager that positions controls on the screen in a grid like fashion. When you add widgets to the screen using grid layout, you need to supply an x,y pair so that the grid layout knows where to position each control.

Hangman—PyQt5 (Part 2)

This is the second part of a tutorial on a Hangman game written in Python using PyQt5.

In the first part of this tutorial, we created a Hangman class that handles the application logic for creating a Hangman game in Python. In this part, we will write a GUI client that uses the Hangman class.

PyQt

Qt is a cross platform application framework that includes a GUI toolkit. Python ships with tkinter as it’s standard GUI API, but I am actually more comfortable using Qt. Qt is a an object orietated framework that provides us with classes that represent controls on the screen (known as widgets).

Using Python’s OOP features, we can develop a GUI in small increments and then put them together into a single application window. That’s what we are going to do in this tutorial.

Here is the complete GUI code for hangman.

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_())

When run, this code will produce a window that looks like this screen shot.
PyQt Hangman copy

HangmanPanel

The first thing we need is a panel that shows the man getting hanged in the window as the user plays the games. We begin by using 8 images for each of the frames.

Now let’s look at the code.

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()

This class extends the QWidget class. QWidget is the super class for objects that are displayed by Qt. By inheriting from QWidget, we can uses instances of this class as a GUI component as either a nested component in a window, or as a standalone window.

The ability to display QWidgets in their own window is a huge feature of PyQt. For example, we can test that this window is working properly by adding the following lines of code to our script.

if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = HangmanPanel(None, ['hangman_0.png'])
    win.show()
    sys.exit(app.exec_())

When run, we get our window.
HangmanPanel copy

By using the technique described above, we can test each component of our GUI in isolation to make sure it’s working properly before we continue. Now let’s move onto a description of this class.

__init__

Python uses an __init__ function to create an object.

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()

The first line is critical. We have to call QWidget’s __init__ method before we do anything else with this component. If we forget to do so, PyQt will throw an exception and let you know about the error.

The next line of code is our Hangman object. This is an instance of the Hangman class that we made in part 1 of the tutorial. The next variable is to track what frame we are currently on. Every time the user guesses wrong, we are going to increment frame_index by one. The frame_index variable is used to show which frame of the hangman game we are going to show.

The self.frames variable is a list of all of the possible frames that we are going to show. It uses the self.create_frames function to read each file supplied in the frames variable. That function will return QPixmap objects that represent each picture that we supplied to the program.

self.label is a QLabel control. The QLabel is a widget that is capable of displaying a picture (among other things). We are going to pass QPixmap objects to this QLabel object and display our hanging man as the game progresses.

Next we need a layout manager. This is an object that is used by Qt to position controls on the screen. QHBoxLayout is used to display components from left to right in a horizontal row. The next line self.layout.addWidget(self.label) adds the QLabel object to this screen. Finally, we want to show our starting hangman from by calling self.advance_frame() described below.

create_frames

The create_frames function is used to load our picture files from the file system and wrap them into QPixmap objects. Here is the code for this function.

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

This begins by creating an empty frm list object. Then we iterate through each item in frames. We create a new QPixmap object, which accepts the location of the frame file on the file system. The QPixmap object is added the frm list. Since we do not want to change the frames in any way after loading them, we convert frm into a tuple (think of it as a read-only list) and return it to the caller.

advance_frame

The advance_frame function does the job of moving the program to the next frame of hangman.

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

We created a QLabel object in the __init__ function and stored it in self.label. We also created our frames as a tuple of QPixmaps in create_frames. We can change the frame that is displayed by using QLabel.setPixmap() and passing a QPixmap to it.

So what this code is doing on line one is looking up a QPixmap located at the current frame_index. On the second line, we increment self.frame_index by one. Our next section of code does bounds checking on the self.frames tuple. If we at the end of the frames, we just reset self.frame_index to zero so that we don’t try and go past the number of frames that we have.

reset_hangman

The final method of the HangmanPanel class gets used to reset the game.

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

You will notice that this code repeats some of what is done in the constructor. It resets the frame_index to 0 and then calls advance_frame to display the first frame in the sequence.

Conclusion

This part of the tutorial discusses the first GUI component of our Hangman GUI. PyQt5 applications are easy to develop because we can work on each component in isolation and make sure things are working properly before continuing. In this post, we worked on the portion of the GUI that displays our man getting hanged as the user guesses wrong.

Hangman—PyQt5 (Part 1)

The is the first part of a 5 part series. You can view the other parts

Hangman games are a popular programming game that many of my students are asked to implement. I decided to do one in Python using the PyQ5 library.

Before working on this tutorial, you may need to install pyqt5 on your system.

pip3 install pyqt5

Before we begin…

We need to create a file that holds our list of words. Here is one that can help you get started.

Kitties
Python
Foliage
Assorted
Soda
Stream
Democracy
Blog
Selection
Insertion

This file will be called words.txt throughout this tutorial. It needs to get placed in the same folder as the rest of our scripts.

Application Logic

It is always advisable to seperate the logic of your application from the display. This let’s us reuse our application code but plug it into different displayes. For example, this tutorial uses PyQt5, but in theory, we may wish to have a Tkinter (the standard Python GUI toolkit) version of this program also. When we seperate application logic from display, we can write different clients for the same program.

Let’s begin with the code first and then walk through it. Here is the core application logic for our hangman game.

from random import randint

class Hangman:
    def __init__(self, word_list_file, allowed_guesses=15):
        self.words = self.read_word_file(word_list_file)
        self.allowed_guesses = allowed_guesses
        self.start_game()
        self.wins = 0

    def start_game(self):
        self.secret_word = self.pick_secret_word()
        self.display_letters = self.create_display_letters()
        self.guessed_letters = []
        self.guesses = 0

    @staticmethod
    def read_word_file(word_list_file):
        word_list = []
        with open(word_list_file, 'r') as f:
            for line in f:
                word_list.append(line.rstrip())
        return word_list

    def pick_secret_word(self):
        index = randint(0, len(self.words) - 1)
        return self.words[index].upper()

    def create_display_letters(self):
        letters = []
        for _ in self.secret_word:
            letters.append('-')
        return letters

    def guess_letter(self, letter):
        if letter not in self.guessed_letters:
            guess_wrong = True
            self.guessed_letters.append(letter)
            for i in range(len(self.secret_word)):
                if letter == self.secret_word[i]:
                    guess_wrong = False
                    self.display_letters[i] = letter
            if guess_wrong:
                self.guesses += 1

    def check_win(self):
        word = ''.join(self.display_letters)
        if word == self.secret_word and self.guesses  self.allowed_guesses

Our hangman program needs to hold data and have functions that operate on the data. This suggests that we should use Object Orientated Programming (OOP). Let’s begin by pointing out the data Hangman needs to keep track of first.

  • wins
  • secret word
  • display letters
  • guessed_letters
  • guesses
  • allowed guesses

Here is a list of behaviors that Hangman needs to perform to work on the data.

  • __init__
  • start_game
  • read_word_file
  • pick_secret_word
  • create_display_letters
  • guess_letter
  • check_win
  • check_lose

Let’s take each function one at a time…

__init__

__init__ is a function that all Python classes have that is used to initalize an object. Programmers from other OOP languages such as Java or C++ may refer to this as a constructor method.

Here is our __init__ code

def __init__(self, word_list_file, allowed_guesses=15):
    self.words = self.read_word_file(word_list_file)
    self.allowed_guesses = allowed_guesses
    self.start_game()
    self.wins = 0

You will notice that our __init__ function requires a word_list_file which is the path to a file that contains our list of words. The first thing this function does is reads the words_list_file using the read_word_file function. That function returns all of the words in the file as a list. One the next line, we set the self.allowed_guesses variable to the supplied allowed_guesses parameter. Then we call self.start_game() to initialize the game.

start_game

start_game initializes our hangman game. The reason this function is seperate from __init__ is so that clients can start a new game of hangman by calling this method, rather than recreating a new Hangman object. Here is the code for the start_game fucntion.

def start_game(self):
    self.secret_word = self.pick_secret_word()
    self.display_letters = self.create_display_letters()
    self.guessed_letters = []
    self.guesses = 0

Mostly speaking, we are just initializing our class variables in this function. The first thing we do is pick a secret word. We use self.pick_secret_word() which randomly picks a word from our word list and returns it. Next we need to create a list that holds the user’s correct guesses. At first, we want it to contain only dash (‘-‘) characters, so we use self.create_display_letters to build that list and return it to this function.

Our next line creates an empty list to hold the user’s guesses followed by a line to set their incorrect guesses to zero.

read_word_file

We met read_word_file in __init__. Now let’s talk about how it works. Here is the code.

@staticmethod
def read_word_file(word_list_file):
    word_list = []
    with open(word_list_file, 'r') as f:
        for line in f:
            word_list.append(line.rstrip())
    return word_list

This method is decorated with @staticmethod which indicate the method belongs to the class Hangman rather than individual Hangman objects. The first thing we do is create an empty word_list object that holds our words. The next thing we do is open word_list_file in read mode using the statement with open (word_list_file, 'r') as f. That statement opens the file and assigns it the variable f. It will also handle closing the file when we are done.

Now we need to read the file line by line. We do that with the for loop. As we go through each line in the file, we need to strip off any trailing ‘\n’ characters, which what line.rstrip() does. At this point, we can add our word to the word_list. When we are done reading the file, we can return the word_list to the calling method.

pick_secret_word

We met pick_secret_word in the start_game function. Here is the code for this method followed by an explanation.

def pick_secret_word(self):
    index = randint(0, len(self.words) - 1)
    return self.words[index].upper()

(note: see Random Int—Python for a post dedicated to picking random items from a list)

In this code, we need to pick a random number between 0 and the number of words – 1. We need to use -1 because lists are zero based. Once we have a number, we return a secret word from the words list and convert it to an upper cased string for easy comparisons later on in the program.

create_display_letters

Now that we have picked our secret word, we can use create_display_letters to assign dash (‘-‘) characters for each unknown letter in the game. Latter on, we will replace the dashes with correct guesses. Here is the code for create_display_letters.

def create_display_letters(self):
    letters = []
    for _ in self.secret_word:
        letters.append('-')
    return letters

In this function, we create a letters list object that is empty. Then we go through each letter in self.secret_word. We use the underscore (‘_’) to indicate that we aren’t actually interested in each letter so there is no need to assign to a variable. At each iteration of the loop, we just append a dash to the letters list. When this method returns, we have a list that is the same length as secret word but it is filled with dashes.

guess_letter

This function is the main entry point for clients that use this class. The idea here is that the client get’s the user’s guess from some sort of input (note that this class isn’t concerned with how that input is gathered from the user) and the guess is passed to this guess_letter function. Here is the code followed by an explanation.

def guess_letter(self, letter):
    if letter not in self.guessed_letters:
        guess_wrong = True
        self.guessed_letters.append(letter)
        for i in range(len(self.secret_word)):
            if letter == self.secret_word[i]:
                guess_wrong = False
                self.display_letters[i] = letter
        if guess_wrong:
            self.guesses += 1

Again, this begins with a letter variable that is supplied by the caller. We don’t care at this point how the program got the letter from the user. Now, first we need to check that that the letter wasn’t already guessed. If it was, we just ignore the guess.

Now, we are creating a boolean variable that is set to True to assume that they guessed wrong. The next line adds the guess to the guessed_letters list. Next, we are going to go through self.secret_word one letter at a time using indexes.

Inside of the loop, we check if the guessed letter matches the current letter in self.secret_word at index ‘i’. If we have a match, we set gress_wrong to False, and we replace the dash character in self.display_letters at index to the letter.

Finally, we check if they have guessed right or wrong. If guess_wrong is True, we update the self.guesses by 1 to indicate that they have guessed wrong.

check_win

The client of this class needs a way to check if the user won the game. We use the check_win function as a way to report back to the client if the user has won the game. Here’s the code followed by an explanation.

def check_win(self):
    word = ''.join(self.display_letters)
    if word == self.secret_word and self.guesses <= self.allowed_guesses:
        self.wins += 1
        return True
    else:
        return False

What we are doing here is assembling the user's guesses into a single string using ''.join(self.display_letters. The string will end up being something like ‘K-tt-es’ if they haven’t guessed all of the letters in the word yet, but if they have guessed everything right, it would be ‘Kitties’.

The next thing to do is see if word is equal to self.secret_word. As long as they match and the user hasn’t exceeded the number of allowed guesses, they win the game. We increment self.wins by 1 and return True. Alternatively, we return False to indicate they have not one the game.

check_lose

Our final function in Hangman is check_lose. This just checks if they have lost the game by exceeding the number of allowed incorrect guesses.

def check_lose(self):
    return self.guesses > self.allowed_guesses

Conclusion

The Hangman class demonstrates many important application concepts in Python. It opens files, picks numbers randomly, and tracks the progress of a game. An extremely important concept is that this class is not tied to any user interface. Instead, it contains the application logic used to play a game of hangman, but allows client modules to import it and use it as needed. The next part of this tutorial will focus on a GUI client that uses this class.