Title: Make a button sprite in Python and Pygame
Pygame is mostly intended for drawing images that change frequently as in a video game. Unfortunately, it's pretty weak at making user interface elements. This program shows how you can provide a button in a Pygame application.
The example demonstrates a ButtonSprite class that act as a button that displays text inside a rounded rectangle.
One of the more important pieces of the example determines whether the mouse is over the button. That's more geometry-related than button-related, so I've put it in a separate point_over_rect function.
The following section describes that function. The rest of this post explains the ButtonSprite class.
point_over_rect
The point_over_rect function returns True if a point lies above a rectangle with uniformly rounded corners. Before you look at the code, take a look at the picture on the right, which you'll find useful for understanding the function. The black rounded rectangle shows the button's border. The green rectangle shows that rectangle with square corners and width reduced to exclude the rounded corners of the original rounded rectangle. The red rectangle has its height similarly reduced to exclude the corners. Finally, the blue rectangle in the middle has both width and height reduced.
Now here's the code.
def point_over_rect(point, rect, radius):
'''Return True if the point is over the uniformly rounded rect.'''
# If it's not in the rect, return False.
if not rect.collidepoint(point):
return False
# If we don't have rounded corners, it's on the rect.
if radius <= 0:
return True
# See if it's on the smaller rectangle with reduced width.
small_rect = rect.inflate(-2 * radius, 0)
if small_rect.collidepoint(point):
return True
# See if it's on the smaller rectangle with reduced height.
small_rect = rect.inflate(0, -2 * radius)
if small_rect.collidepoint(point):
return True
# It's in a corner area.
# Get a rectangle with reduced width and height.
small_rect = rect.inflate(-2 * radius, -2 * radius)
# See which small_rect corner is closet.
if point.x <= rect.centerx:
# Left side
if point.y <= small_rect.centery:
# Top left
corner = small_rect.topleft
else:
# Bottom left
corner = small_rect.bottomleft
else:
# Right side
if point.y <= small_rect.centery:
# Top right
corner = small_rect.topright
else:
# Bottom right
corner = small_rect.bottomright
# See how far we are from this corner.
return (point - corner).length() <= radius
The function first calls the rect object's collidepoint method to see if the point lies above the non-rounded rectangle. If it does not, then the point cannot lie over the smaller rounded rectangle so the function returns False.
Next, the code checks the rectangle's rounding radius. If the radius is 0 or smaller, then the corners are not rounded. Because the previous test found that the point lies above the non-rounded rectangle, it lies about the actual rectangle (which is also not rounded), so the function returns True.
The function then checks to see if the point lies above the green rectangle. If the point lies over the green rectangle, the function returns True.
Similarly, if the point lies above the red rectangle, the function returns True.
If the function hasn't returned yet, then the point lies near one of the rectangle's corners. If it is over the rounded rectangle, then it must be within one of the corner's circles, which are shown in yellow in the picture. (I told you the picture would be useful.)
The code sets small_rect to the picture's blue rectangle. It then uses that rectangle's centerx and centery values (the rectangle's X and Y midpoint coordinates) to see which corner is closest to the point and it sets corner equal to the blue rectangle's closest corner. It subtracts the target point from the corner point to get a vector between them. If that vector's length is less than the corner radius, then the point is within the yellow circle so the function returns True.
ButtonSprite
Now let's turn to the ButtonSprite class. Here's the declaration and constructor.
class ButtonSprite:
'''Sprite to display text inside a rectangle.'''
def __init__(self, command, text, font, position, alignment, antialias,
rect, fg_color, bg_color=None, border_width=0, radius=0):
self.command = command
self.text = text
self.font = font
self.position = position
self.alignment = alignment
self.rect = rect
self.fg_color = fg_color
self.bg_color = bg_color
self.border_width = border_width
self.radius = radius
# Draw the text onto its own surface.
self.surface = font.render(text, antialias, fg_color)
The constructor saves a bunch of values and then uses the font's render method to draw the button's text onto its own surface.
The class's update method simply uses a pass statement to not do anything. (The program needs an update method because it updates all sprites, most of which do something like move in their update methods.)
The following code shows the button's draw method.
def draw(self, surface):
'''Draw the button.'''
# Fill the rectangle
if self.bg_color is not None:
pygame.draw.rect(surface, self.bg_color, self.rect,
border_radius=self.radius)
# Outline the rectangle
if self.border_width > 0:
pygame.draw.rect(surface, self.fg_color, self.rect,
width=self.border_width,
border_radius=self.radius)
# Draw the text onto the surface.
draw_image(surface, self.surface, self.position, self.alignment)
This method fills the button's rectangle with its background color and then outlines the rectangle. It then calls draw_image to copy the saved text surface onto the main surface. (See the post Draw aligned text with Python and Pygame to see how draw_image works.)
The following code shows the button's is_at method.
def is_at(self, pos):
'''Return True if the position hits the button.'''
# Convert (x, y) into a pygame.Vector2 and call point_over_rect.
return point_over_rect(
pygame.Vector2(pos), self.rect, self.radius)
This method simply converts the mouse's position (which is a tuple holding the mouse's X and Y coordinates) into a Pygame Vector2 object and then passes that object to the point_over_rect function described earlier.
The last piece of the ButtonSprite class is the following is_clicked method. This method determines whether the mouse is over the button and, if it is, executes the method stored in the button's command value.
def is_clicked(self, event):
'''Take action and return True if we are clicked.'''
if self.is_at(event.pos):
self.command()
return True
else:
return False
This code calls self.is_at to see if the mouse is over the button. If it is, the method calls the method stored in self.command and returns True so the calling code knows the button fired. (That lets the program avoid invoking multiple buttons with one click, although if you placed multiple buttons in the same spot, you deserve what you get.)
If the mouse is not over the button, the method returns False.
Main Program
The following code shows the statement where the program creates a button.
sprite = ButtonSprite(
lambda x=text: print(f'{x} button clicked'),
text, font, rect.center, 'c', True, rect,
fg_color, bg_color, 1, radius)
All of the values are defined in code that isn't shown here. Download the example to see those details.
Notice that the button's command is set to lambda x=text: print(f'{x} button clicked'). That makes the button display its text followed by "button clicked."
The x=text is necessary to bind the current value of the text looping variable to the value x. If we don't do that, all of the buttons use the same value of text, which is the last value that it holds when its loop ends. (Change this to lambda: print(f'{text} button clicked') to see what happens.)
When the user presses the left mouse button, the program executes the following method.
def mouse_down(self, event):
'''See if we clicked a button.'''
for sprite in self.all_sprites:
if sprite.is_clicked(event):
break
This code loops through all of the program's button sprites and call's their is_clicked methods. If any of them executes its command and returns True, the program breaks out of this loop so only one button can execute at a time.
Conclusion
The ButtonSprite class is pretty easy to use. Just copy it, point_over_rect, and draw_text into your program and you'll be ready to use buttons in no time.
I've skipped a bunch of details so download the example if you want to see them. You may also want to download the program to experiment with it. For example, with some extra effort you could probably draw buttons that are beveled, raised, lowered, and so forth.
|