The Invent with Python Blog

Writings from the author of Automate the Boring Stuff.

Tue 30 October 2012

Designing a Button UI Module for Pygame

Posted by Al Sweigart in pygame   

Pygame is a 2D graphics and gaming library for Python. It's pretty nifty because it essentially gives you a blank window that you can draw any shapes or lines or images you want on it. But it doesn't come with any UI elements like buttons, scrollbars, or check boxes. This post will go through not only creating a button class for Pygame, but also the reasoning behind why I've set up the code as it is. This is more of a "how to create a module other people can use" tutorial than a UI or Pygame tutorial.

This tutorial assumes you know the basics of Pygame and Python programming. If you don't, it's probably easy enough to follow anyway.

A button is a common user interface (UI) control that is used in many software applications. It seems simple enough: there's a button on the window and you click on it and something happens. But there's a lot of details we should plan out ahead of time. Remember, we want to make a generic button class so that other programmers can use this in their games and programs. Once you've read through the process here, you'll be familiar with how to make your own modules for UI elements.

Designing a UI button class is good a good programming practice exercise.

Download the PygButton module from PyPI by running "pip install pygbutton". You can also look at just the pygbutton.py file itself.

Or download it from the git repository on GitHub.

The Feature List (and the Non-Feature List)

First, let's create a list of design details for the buttons:

  1. Can have any width or height.
  2. The buttons can have text on them. The font and size can be customized.
  3. The background color of the button can be changed to any RGB value, as can the foreground (text) color.
  4. The button's properties (bgcolor, bgcolor, text, font, size, etc.) can be dynamically changed.
  5. The button has three states: normal, down (when the mouse cursor has pressed down on the button), and highlight (when the mouse cursor is over the button but not pressing down).
  6. Pygame's mouse events are passed to the button's handleEvent() method, which update's the button's state and calls any event-handling code.
  7. The button recognizes 6 different types of events: mouse enter, mouse exit, mouse down, mouse up, mouse click, and mouse move. (These are explained later.)
  8. Instead of text, the user will be able to specify images for the three different states. We'll call these image-based buttons.
  9. The button's visibility can be toggled on and off.

And it's always a good idea to come up with a list of things we specifically won't implement (to avoid feature creep each time we think, "Hey, it'd be cool if we could..."). These features could always be implemented later.

  1. Must be rectangular (i.e. can't be oval).
  2. No transparency.
  3. No more than the three states.
  4. No hotkeys attached to them, or keyboard focus.
  5. No special "double click" event (it'll just be two click events).
  6. For now, the highlight state looks identical to the normal state for text-based buttons.
  7. A button is either text-based or image-based, there's no hybrid.
  8. No "disabled" state.
  9. Only one font & color at a time for text-based buttons.
  10. The text caption will always be centered, not left- or right-aligned.

(But you can add these features to your own code if you want.)

Design Details

Whenever you're designing something, always do a prior art search first. Looking at how buttons on a web page work is a good case to examine, for example.

The buttons have three states and can have a different appearance for each state.

  • The "normal" state is what the button looks like when it has not been clicked and the mouse is not over it.
  • The "highlight" state is what the button looks like when the mouse is hovering over it, but not clicking it. We can use this to add some kind of highlighting behavior when the mouse glides over the button. For normal text-based buttons, this state will look identical to the normal state.
  • The "down" state is what the button looks like when it is being clicked down.

There are also six different "button events" that the buttons can produce based on the Pygame mouse events that are passed to them:

  • Enter - When a MOUSEMOTION event has told the button that the mouse is over the button when previously it wasn't.
  • Exit - When a MOUSEMOTION event has told the button that the mouse is no longer over the button when previously it was.
  • Move - When the button has received a MOUSEMOTION event.
  • Down - When the mouse is pressed down on the button.
  • Up - When the mouse is released on the button.
  • Click - When the mouse was pressed down on the button and released over the button. (Releasing the mouse off of the button does not trigger the click event.)

(Note: The buttons won't produce Pygame USEREVENTS. I didn't see a significant need for them.)

As to how the mouse button looks, I'll be using the Windows look-and-feel of buttons. Here's what they look like zoomed in:

Notice that the 3D appearance is caused by drawing these black, gray, and white outlines. These lines don't change if the background color of the button changes.

What the API will Look Like

Before diving into coding, we need a concrete plan for how other programmers will use this module. It doesn't matter how sophisticated your library is, if it is opaque, difficult to learn, and inconsistent no one will want to learn it and it will not be used. It's important to get these details right the first time, because making changes (like changing a function name or getting rid of a class) later on could break other people's code that uses your library. This means they won't adopt newer versions and new features (since the newer version breaks their code), which further limits the popularity of your module.

The button's API will have three main parts: the constructor function that creates it, the function that draws the button to a pygame.Surface object (to display it on the screen), and a handleEvent() method that we can pass pygame.Event objects to so it knows what is happening in the program. The code will roughly look like this:

myButton = pygbutton.PygButton(rectObj, 'Caption text')

...

for event in pygame.event.get(): # event handling loop

    myButton.handleEvent(event)

...

myButton.draw(displaySurface)

Before we start coding, we should write out the method names and parameters for the PygButton class first. This will help cement what we want to code before we start coding:

  • def __init__(self, rect=None, caption='', bgcolor=LIGHTGRAY, fgcolor=BLACK, font=None, normal=None, down=None, highlight=None) - The constructor. Note that pretty much everything has a default argument. If the user just wants asimple button, we shouldn't have to make her write out tons of boilerplate code. Let's just supply default values.
  • def handleEvent(self, eventObj) - Changes the button's state if the Pygame event passed is relevant.
  • def draw(self, surfaceObj) - Draws the button (in its current state) to the surfaceObj surface.
  • def mouseClick(self, event) - Called when the button has a click event. (These methods don't do anything in the PygButton class, but you can override this class to implement code in these methods.)
  • def mouseEnter(self, event) - Called when the button has a "mouse enter" event.
  • def mouseExit(self, event) - Called when the button has a "mouse exit" event.
  • def mouseMove(self, event) - Called when the button has a "mouse move" event.
  • def mouseDown(self, event) - Called when the button has a mouse button down event.
  • def mouseUp(self, event) - Called when the button has a mouse button up event.
  • def setSurfaces(self, normalSurface, downSurface=None, highlightSurface=None) - Let's the user specify either image filenames or pygame.Surface objects to use for each of the states. (This sets the button to be an image-based button.)

And here are some properties that we'd like to set for the PygButton class. Whenever you think you'll need a get and set method for something (i.e. getCaption() and setCaption() instead of just a caption property), this is a strong indication that a property would be better instead.

  • caption - The string for the text caption in the center of the button.
  • rect - A pygame.Rect object which gives the position and size of the button.
  • visible - A boolean that sets the button to visible (True) or invisible (False).
  • fgcolor - An RGB tuple or pygame.Color object for the text (foreground) color.
  • bgcolor - An RGB tuple or pygame.Color object for the background color.
  • font - A pygame.font.Font object for the font (and size) to use for the text caption.

Setting any of these properties (other than rect) will result in the button becoming a text-based button if it was previously an image-based button. Setting the rect property of an image-based button simply resizes the images.

Note that we don't have properties for setting the normal, down, and highlight Surfaces. This is because when we switch from a normal text-based button (which uses the caption, fgcolor, bgcolor, and font properties) to an image-based button, we want to set the images for all three Surfaces at the same time (even though we have defaults for the down and highlight surfaces.)

The Preamble Code

Here's the code that goes at the top of the pygbutton.py file. It imports Pygame and calls the init() function for the fonts and creates a few constants that we'll use in the module.

import pygame

from pygame.locals import *



pygame.font.init()

PYGBUTTON_FONT = pygame.font.Font('freesansbold.ttf', 14)



BLACK     = (  0,   0,   0)

WHITE     = (255, 255, 255)

DARKGRAY  = ( 64,  64,  64)

GRAY      = (128, 128, 128)

LIGHTGRAY = (212, 208, 200)

The Constructor Function

The constructor function is fairly straight forward. There are many different attributes that we can customize for a button, but we can always just create a standard default button.

class PygButton(object):

    def __init__(self, rect=None, caption='', bgcolor=LIGHTGRAY, fgcolor=BLACK, font=None, normal=None, down=None, highlight=None):

        if rect is None:

            self._rect = pygame.Rect(0, 0, 30, 60)

        else:

            self._rect = pygame.Rect(rect)



        self._caption = caption

        self._bgcolor = bgcolor

        self._fgcolor = fgcolor



        if font is None:

            self._font = PYGBUTTON_FONT

        else:

            self._font = font



        # tracks the state of the button

        self.buttonDown = False # is the button currently pushed down?

        self.mouseOverButton = False # is the mouse currently hovering over the button?

        self.lastMouseDownOverButton = False # was the last mouse down event over the mouse button? (Used to track clicks.)

        self._visible = True # is the button visible

        self.customSurfaces = False # button starts as a text button instead of having custom images for each surface



        if normal is None:

            # create the surfaces for a text button

            self.surfaceNormal = pygame.Surface(self._rect.size)

            self.surfaceDown = pygame.Surface(self._rect.size)

            self.surfaceHighlight = pygame.Surface(self._rect.size)

            self._update() # draw the initial button images

        else:

            # create the surfaces for a custom image button

            self.setSurfaces(normal, down, highlight)

For image-based buttons, the setSurfaces() method is called, which handles the default images for the Down and Highlight state if they are unspecified. It also checks that the images are the same size. Note that the user can specify either pygame.Surface objects or string filename values.

    def setSurfaces(self, normalSurface, downSurface=None, highlightSurface=None):

        """Switch the button to a custom image type of button (rather than a

        text button). You can specify either a pygame.Surface object or a

        string of a filename to load for each of the three button appearance

        states."""

        if downSurface is None:

            downSurface = normalSurface

        if highlightSurface is None:

            highlightSurface = normalSurface



        if type(normalSurface) == str:

            self.origSurfaceNormal = pygame.image.load(normalSurface)

        if type(downSurface) == str:

            self.origSurfaceDown = pygame.image.load(downSurface)

        if type(highlightSurface) == str:

            self.origSurfaceHighlight = pygame.image.load(highlightSurface)



        if self.origSurfaceNormal.get_size() != self.origSurfaceDown.get_size() != self.origSurfaceHighlight.get_size():

            raise Exception('foo')



        self.surfaceNormal = self.origSurfaceNormal

        self.surfaceDown = self.origSurfaceDown

        self.surfaceHighlight = self.origSurfaceHighlight

        self.customSurfaces = True

        self._rect = pygame.Rect((self._rect.left, self._rect.top, self.surfaceNormal.get_width(), self.surfaceNormal.get_height()))

Note that the PygButton class also stores the original images in the origSurfaceNormal, origSurfaceDown, and origSurfaceHighlight member variables. This is so that when the code does a resize, we are resizing the original images. The button could be resized multiple times, and this would result in poor quality if we tried to resize and previously resized image. (The same way a photocopy of a photocopy of a photocopy reduces the image quality.)

The draw() Method

The draw() method is straightforward since it only copies the surfaceNormal, surfaceDown, and surfaceHighlight properties to the passed pygame.Surface object. The draw() method is called whenever the button's current state needs to be drawn to a Surface object. Drawing the buttons themselves will be handled by the _update() method.

def draw(self, surfaceObj):

    """Blit the current button's appearance to the surface object."""

    if self._visible:

        if self.buttonDown:

            surfaceObj.blit(self.surfaceDown, self._rect)

        elif self.mouseOverButton:

            surfaceObj.blit(self.surfaceHighlight, self._rect)

        else:

            surfaceObj.blit(self.surfaceNormal, self._rect)

The _update() method will be called whenever the appearance of the buttons has been modified. This happens when the text, background color, size, etc. of the button has changed. This is why the name of _update() begins with an underscore; it's only called by the class's code itself. It shouldn't be called by the user.

The _update() method is mostly drawing code for text-based buttons (or resizing the images for image-based buttons).

    def _update(self):

        """Redraw the button's Surface object. Call this method when the button has changed appearance."""

        if self.customSurfaces:

            self.surfaceNormal    = pygame.transform.smoothscale(self.origSurfaceNormal, self._rect.size)

            self.surfaceDown      = pygame.transform.smoothscale(self.origSurfaceDown, self._rect.size)

            self.surfaceHighlight = pygame.transform.smoothscale(self.origSurfaceHighlight, self._rect.size)

            return



        w = self._rect.width # syntactic sugar

        h = self._rect.height # syntactic sugar



        # fill background color for all buttons

        self.surfaceNormal.fill(self.bgcolor)

        self.surfaceDown.fill(self.bgcolor)

        self.surfaceHighlight.fill(self.bgcolor)



        # draw caption text for all buttons

        captionSurf = self._font.render(self._caption, True, self.fgcolor, self.bgcolor)

        captionRect = captionSurf.get_rect()

        captionRect.center = int(w / 2), int(h / 2)

        self.surfaceNormal.blit(captionSurf, captionRect)

        self.surfaceDown.blit(captionSurf, captionRect)



        # draw border for normal button

        pygame.draw.rect(self.surfaceNormal, BLACK, pygame.Rect((0, 0, w, h)), 1) # black border around everything

        pygame.draw.line(self.surfaceNormal, WHITE, (1, 1), (w - 2, 1))

        pygame.draw.line(self.surfaceNormal, WHITE, (1, 1), (1, h - 2))

        pygame.draw.line(self.surfaceNormal, DARKGRAY, (1, h - 1), (w - 1, h - 1))

        pygame.draw.line(self.surfaceNormal, DARKGRAY, (w - 1, 1), (w - 1, h - 1))

        pygame.draw.line(self.surfaceNormal, GRAY, (2, h - 2), (w - 2, h - 2))

        pygame.draw.line(self.surfaceNormal, GRAY, (w - 2, 2), (w - 2, h - 2))



        # draw border for down button

        pygame.draw.rect(self.surfaceDown, BLACK, pygame.Rect((0, 0, w, h)), 1) # black border around everything

        pygame.draw.line(self.surfaceDown, WHITE, (1, 1), (w - 2, 1))

        pygame.draw.line(self.surfaceDown, WHITE, (1, 1), (1, h - 2))

        pygame.draw.line(self.surfaceDown, DARKGRAY, (1, h - 2), (1, 1))

        pygame.draw.line(self.surfaceDown, DARKGRAY, (1, 1), (w - 2, 1))

        pygame.draw.line(self.surfaceDown, GRAY, (2, h - 3), (2, 2))

        pygame.draw.line(self.surfaceDown, GRAY, (2, 2), (w - 3, 2))



        # draw border for highlight button

        self.surfaceHighlight = self.surfaceNormal

The Event Callback Methods

There are two ways that we can execute code in response to button-related events. The first is to have a method in the PygButton class (and its subclasses) get called that contains the code we want to run.

We'll just put stub functions for these methods. Any subclasses that inherit from PygButton can override these methods and use any code they want. But for now, they do nothing:

    def mouseClick(self, event):

        pass # This class is meant to be overridden.

    def mouseEnter(self, event):

        pass # This class is meant to be overridden.

    def mouseMove(self, event):

        pass # This class is meant to be overridden.

    def mouseExit(self, event):

        pass # This class is meant to be overridden.

    def mouseDown(self, event):

        pass # This class is meant to be overridden.

    def mouseUp(self, event):

        pass # This class is meant to be overridden.

The handleEvent() Method

Whenever our program calls pygame.event.get_events() to retrieve all the events generated (for keyboard, mouse, etc. events) we should pass them to handleEvent() so the buttons can update their state. The second way to execute code in response to button events is with the return value of handleEvent().

The handleEvent() method has been set up so that it returns a list of all button events that have happened due to the normal Pygame events passed to handleEvent(). So if a mouse move Pygame event has happened over the button (when previously the mouse cursor wasn't over the button), the handleEvent() method will return the list ['enter', 'move'].

The caller of handleEvent() can perform any actions in response to these events.

Here's the code for handleEvent():

def handleEvent(self, eventObj):

    if eventObj.type not in (MOUSEMOTION, MOUSEBUTTONUP, MOUSEBUTTONDOWN) or not self._visible:

        # The button only cares bout mouse-related events (or no events, if it is invisible)

        return []



    retVal = []



    hasExited = False

    if not self.mouseOverButton and self._rect.collidepoint(eventObj.pos):

        # if mouse has entered the button:

        self.mouseOverButton = True

        self.mouseEnter(eventObj)

        retVal.append('enter')

    elif self.mouseOverButton and not self._rect.collidepoint(eventObj.pos):

        # if mouse has exited the button:

        self.mouseOverButton = False

        hasExited = True # call mouseExit() later, since we want mouseMove() to be handled before mouseExit()



    if self._rect.collidepoint(eventObj.pos):

        # if mouse event happened over the button:

        if eventObj.type == MOUSEMOTION:

            self.mouseMove(eventObj)

            retVal.append('move')

        elif eventObj.type == MOUSEBUTTONDOWN:

            self.buttonDown = True

            self.lastMouseDownOverButton = True

            self.mouseDown(eventObj)

            retVal.append('down')

    else:

        if eventObj.type in (MOUSEBUTTONUP, MOUSEBUTTONDOWN):

            # if an up/down happens off the button, then the next up won't cause mouseClick()

            self.lastMouseDownOverButton = False



    # mouse up is handled whether or not it was over the button

    doMouseClick = False

    if eventObj.type == MOUSEBUTTONUP:

        if self.lastMouseDownOverButton:

            doMouseClick = True

        self.lastMouseDownOverButton = False



        if self.buttonDown:

            self.buttonDown = False

            self.mouseUp(eventObj)

            retVal.append('up')



        if doMouseClick:

            self.buttonDown = False

            self.mouseClick(eventObj)

            retVal.append('click')



    if hasExited:

        self.mouseExit(eventObj)

        retVal.append('exit')



    return retVal

PygButton Properties

Instead of having simple member variables for the caption, rect, visible, fgcolor, bgcolor, and font, we can use Python properties instead. This is better, because each time these values get updated we need to run some code that updates the Surface objects that hold the button's look. In other languages, this would require the use of bulky get and set methods. Python's property() function lets us assign methods to be called whenever the member variables need to be get or set.

    def _propGetCaption(self):

        return self._caption



    def _propSetCaption(self, captionText):

        self.customSurfaces = False

        self._caption = captionText

        self._update()



    def _propGetRect(self):

        return self._rect



    def _propSetRect(self, newRect):

        # Note that changing the attributes of the Rect won't update the button. You have to re-assign the rect member.

        self._update()

        self._rect = newRect



    def _propGetVisible(self):

        return self._visible



    def _propSetVisible(self, setting):

        self._visible = setting



    def _propGetFgColor(self):

        return self._fgcolor



    def _propSetFgColor(self, setting):

        self.customSurfaces = False

        self._fgcolor = setting

        self._update()



    def _propGetBgColor(self):

        return self._bgcolor



    def _propSetBgColor(self, setting):

        self.customSurfaces = False

        self._bgcolor = setting

        self._update()



    def _propGetFont(self):

        return self._font



    def _propSetFont(self, setting):

        self.customSurfaces = False

        self._font = setting

        self._update()



    caption = property(_propGetCaption, _propSetCaption)

    rect = property(_propGetRect, _propSetRect)

    visible = property(_propGetVisible, _propSetVisible)

    fgcolor = property(_propGetFgColor, _propSetFgColor)

    bgcolor = property(_propGetBgColor, _propSetBgColor)

    font = property(_propGetFont, _propSetFont)

Example Programs

This is a very important step. We need to accompany the module with some example programs that show how simple it is to actually use the module in a working program. I would even say that including example programs is more important than having documentation (for smaller libraries, at least.)

Download the PygButton module and example programs here.

 

Learn to program with my books for beginners, free under a Creative Commons license:

Take my Automate the Boring Stuff with Python online Udemy course. Use this link to apply a 60% discount.