Python Forum
Thread Rating:
  • 0 Vote(s) - 0 Average
  • 1
  • 2
  • 3
  • 4
  • 5
[PyGame] User Interface
#1
Pygame does not come with widget toolkits such as buttons, sliders, input boxes, etc. You have to create them from scratch. Writing a single button for example is easy, but creating a complete UI framework and maintaining it is a massive task. There are a few frameworks that people have created over time (sgc, pgu, GooeyPy, Albow, ocempgui, PygameGUILib, and even more). I think almost all have been abandoned after some point in time, if not all of them. The newer ones dont have much and your not sure if they will be maintained and added to.

So most of the time, pygamers run their own code. If you need a button then create one, if you need a label create one, etc. If you dont need to embed an image, dont bother adding the code to handle it, etc.

Button
A basic button can be done with just a rectangle that gets when the mouse button clicks. Fancy stuff can be added if needed later. We are making this a class because we want to make a button that can be used in a variety of circumstances, not this one time.

The following just creates a red rectangle that identifies a left mouse click and prints it and thats it. But it will give you a basic idea of what we need.

import pygame as pg

class Button:
    def __init__(self, rect, command):
        self.rect = pg.Rect(rect)
        self.image = pg.Surface(self.rect.size).convert()
        self.image.fill((255,0,0))
        self.function = command

    def get_event(self, event):
        if event.type == pg.MOUSEBUTTONDOWN and event.button == 1:
            self.on_click(event)

    def on_click(self, event):
        if self.rect.collidepoint(event.pos):
            self.function()

    def draw(self, surf):
        surf.blit(self.image, self.rect)
        
def button_was_pressed():
    print('button_was_pressed')

screen = pg.display.set_mode((800,600))
done = False
btn = Button(rect=(50,50,105,25), command=button_was_pressed)

while not done:
    for event in pg.event.get():
        if event.type == pg.QUIT:
            done = True
        btn.get_event(event)
    btn.draw(screen)
    pg.display.update()
In the dunder init method we take the rect coordinates; left and top position length and height, create a surface for that rect, color it via fill, and pass the command to an attribute.

The get_event method identifies the mouse button and the correct button.

The on_click method handles what happens if the mouse was clicked. First it checks if the mouse position was inside the buttons rect, if so executes the command. You could also instead of passing the event through, do pygame.mouse.get_pos()instead of event.pos.

Currently our button does what we want. But it doesnt look good at all. To do this we need to add some things. Buttons can have a LOT of effects. This means we need to change the code up a bit to account for all these things. We are going to add a method called process_kwargs that 1) stores default settings and 2) allows us to change them on the fly for each button created. That is the point in classes after all.

import pygame as pg
pg.init()

class Button:
    def __init__(self, rect, command, **kwargs):
        self.process_kwargs(kwargs)
        self.rect = pg.Rect(rect)
        self.image = pg.Surface(self.rect.size).convert()
        self.function = command
        self.text = self.font.render(self.text,True,self.font_color)
        self.text_rect = self.text.get_rect(center=self.rect.center)
        
    def process_kwargs(self, kwargs):
        settings = {
            'color'         :pg.Color('red'),
            'text'          :'default',
            'font'          :pg.font.SysFont('Arial', 16),
            'hover_color'   :(200,0,0),
            'font_color'    :pg.Color('white'),
        }
        for kwarg in kwargs:
            if kwarg in settings:
                settings[kwarg] = kwargs[kwarg]
            else:
                raise AttributeError("{} has no keyword: {}".format(self.__class__.__name__, kwarg))
        self.__dict__.update(settings)

    def get_event(self):
        if event.type == pg.MOUSEBUTTONDOWN and event.button == 1:
            self.on_click()

    def on_click(self):
        if self.is_hovering():
            self.function()
            
    def is_hovering(self):
        if self.rect.collidepoint(pg.mouse.get_pos()):
            return True

    def draw(self, surf):
        if self.is_hovering():
            self.image.fill(self.hover_color)
        else:
            self.image.fill(self.color)
        surf.blit(self.image, self.rect)
        surf.blit(self.text, self.text_rect)
        
def button_was_pressed():
    print('button_was_pressed')

screen = pg.display.set_mode((800,600))
done = False
btn = Button((50,50,105,25), button_was_pressed)

while not done:
    for event in pg.event.get():
        if event.type == pg.QUIT:
            done = True
        btn.get_event()
    btn.draw(screen)
    pg.display.update()
process_kwargs() has a settings dictionary that is flexible and stores the defaults. More importantly it shows what arguments you can give the button to customize it on a per button basis. All of these keys in this dictionary get implemented into the class as attributes of the class via this line self.__dict__.update(settings). After this point there is a self.color and its value is pg.Color('red'). And the same with every other key:entry pair. By having this method you can change any one of these with an argument or pass a dictionary of many to change some or all them. This is very useful as buttons often have different effects from one another.

EXAMPLE: When we create our button we are using all the defaults. btn = Button((50,50,105,25), button_was_pressed). However we can easily change the text by adding a single argument now. btn = Button((50,50,105,25), button_was_pressed, text='Press Me'). This simply changes the text of the button. If i wanted to change many settings, i can keep a dictionary of button customizations somewhere, and pass that in instead.
btn_settings = {
    'text':'Press Me',
    'color':pg.Color('grey'),
    'font_color':pg.Color('red'),
}
btn = Button((50,50,105,25), button_was_pressed, **btn_settings)
This will update the settings changed, and keep the defaults of those not changed. If you try to give an argument that is not in the dictionary (or other attribute) then you are going to get a custom AttributeError. btn = Button((50,50,105,25), button_was_pressed, click_sound='button.mp3' will output the error
Error:
Traceback (most recent call last): File "test3.py", line 53, in <module> btn = Button((50,50,105,25), button_was_pressed, click_sound='button.mp3') File "test3.py", line 6, in __init__ self.process_kwargs(kwargs) File "test3.py", line 25, in process_kwargs raise AttributeError("{} has no keyword: {}".format(self.__class__.__name__, kwarg)) AttributeError: Button has no keyword: click_sound
And now your slowly on your way to creating your own UI Toolkit.

The rest of the changes were to draw method for changing the color of the button if the mouse is colliding with the rect (AKA hovering) and drawing the text. The dunder init method adds a couple lines to create the button text. It creates the font object with the proper settings and aligns the text to the center. Here i am assuming you want it in the center. But you could just make a new entry in the settings dictionary for font position if you wanted...or for anything. And your button class customization will expand and expand.

The following is a more fully fledged button class with an example to be runnable
import pygame as pg

class Button(object):
    def __init__(self,rect,command,**kwargs):
        self.rect = pg.Rect(rect)
        self.command = command
        self.clicked = False
        self.hovered = False
        self.hover_text = None
        self.clicked_text = None
        self.process_kwargs(kwargs)
        self.render_text()

    def process_kwargs(self,kwargs):
        settings = {
            "color"             : pg.Color('red'),
            "text"              : None,
            "font"              : None, #pg.font.Font(None,16),
            "call_on_release"   : True,
            "hover_color"       : None,
            "clicked_color"     : None,
            "font_color"        : pg.Color("white"),
            "hover_font_color"  : None,
            "clicked_font_color": None,
            "click_sound"       : None,
            "hover_sound"       : None,
            'border_color'      : pg.Color('black'),
            'border_hover_color': pg.Color('yellow'),
            'disabled'          : False,
            'disabled_color'     : pg.Color('grey'),
            'radius'            : 3,
        }
        for kwarg in kwargs:
            if kwarg in settings:
                settings[kwarg] = kwargs[kwarg]
            else:
                raise AttributeError("{} has no keyword: {}".format(self.__class__.__name__, kwarg))
        self.__dict__.update(settings)

    def render_text(self):
        if self.text:
            if self.hover_font_color:
                color = self.hover_font_color
                self.hover_text = self.font.render(self.text,True,color)
            if self.clicked_font_color:
                color = self.clicked_font_color
                self.clicked_text = self.font.render(self.text,True,color)
            self.text = self.font.render(self.text,True,self.font_color)

    def get_event(self,event):
        if event.type == pg.MOUSEBUTTONDOWN and event.button == 1:
            self.on_click(event)
        elif event.type == pg.MOUSEBUTTONUP and event.button == 1:
            self.on_release(event)

    def on_click(self,event):
        if self.rect.collidepoint(event.pos):
            self.clicked = True
            if not self.call_on_release:
                self.function()

    def on_release(self,event):
        if self.clicked and self.call_on_release:
            #if user is still within button rect upon mouse release
            if self.rect.collidepoint(pg.mouse.get_pos()):
                self.command()
        self.clicked = False

    def check_hover(self):
        if self.rect.collidepoint(pg.mouse.get_pos()):
            if not self.hovered:
                self.hovered = True
                if self.hover_sound:
                    self.hover_sound.play()
        else:
            self.hovered = False

    def draw(self,surface):
        color = self.color
        text = self.text
        border = self.border_color
        self.check_hover()
        if not self.disabled:
            if self.clicked and self.clicked_color:
                color = self.clicked_color
                if self.clicked_font_color:
                    text = self.clicked_text
            elif self.hovered and self.hover_color:
                color = self.hover_color
                if self.hover_font_color:
                    text = self.hover_text
            if self.hovered and not self.clicked:
                border = self.border_hover_color
        else:
            color = self.disabled_color
        
        #if not self.rounded:
        #    surface.fill(border,self.rect)
        #    surface.fill(color,self.rect.inflate(-4,-4))
        #else:
        if self.radius:
            rad = self.radius
        else:
            rad = 0
        self.round_rect(surface, self.rect , border, rad, 1, color)
        if self.text:
            text_rect = text.get_rect(center=self.rect.center)
            surface.blit(text,text_rect)
            
            
    def round_rect(self, surface, rect, color, rad=20, border=0, inside=(0,0,0,0)):
        rect = pg.Rect(rect)
        zeroed_rect = rect.copy()
        zeroed_rect.topleft = 0,0
        image = pg.Surface(rect.size).convert_alpha()
        image.fill((0,0,0,0))
        self._render_region(image, zeroed_rect, color, rad)
        if border:
            zeroed_rect.inflate_ip(-2*border, -2*border)
            self._render_region(image, zeroed_rect, inside, rad)
        surface.blit(image, rect)


    def _render_region(self, image, rect, color, rad):
        corners = rect.inflate(-2*rad, -2*rad)
        for attribute in ("topleft", "topright", "bottomleft", "bottomright"):
            pg.draw.circle(image, color, getattr(corners,attribute), rad)
        image.fill(color, rect.inflate(-2*rad,0))
        image.fill(color, rect.inflate(0,-2*rad))
        
    def update(self):
        #for completeness
        pass
        
if __name__ == '__main__':
    pg.init()
    screen = pg.display.set_mode((600,400))
    screen_rect = screen.get_rect()
    done = False

    def print_on_press():
        print('button pressed')
        
    settings = {
        "clicked_font_color" : (0,0,0),
        "hover_font_color"   : (205,195, 100),
        'font'               : pg.font.Font(None,16),
        'font_color'         : (255,255,255),
        'border_color'       : (0,0,0),
    }

    btn = Button(rect=(10,10,105,25), command=print_on_press, text='Press Me', **settings)

    while not done:
        mouse = pg.mouse.get_pos()
        for event in pg.event.get():
            if event.type == pg.QUIT:
                done = True
            btn.get_event(event)
        btn.draw(screen)
        pg.display.update()
   

You can extend this beyond one button quite easily. The following uses the previous code snippets button class with a modified main code after __name__ == '__main__': to create a button for each letter of the alphabet programmatically. You do not need to change the button class at all. That is why you create a button class. This code creates a button and sets the y axis (top) of each button. enumerate allows you to increment a number for each button which can be used as a multiplier for the y axis. lambda l=letter:print_on_press(l) passes the letter to the callback function so you can do something with each button uniquely. It requires lambda because you are passing something to the callback function. In addition it also requires letter to be passed into the lambda function as a local variable l. If you only did lambda:print_on_press(letter) then z would be the only thing that shows up because that is the last letter in the loop. letter would then be changed to what the next loop letter value is, instead of retaining it for each iteration.

if __name__ == '__main__':
    #code pertaining to the main program not in the button module
    import string
    pg.init()
    screen = pg.display.set_mode((600,400))
    screen_rect = screen.get_rect()
    done = False
 
    def print_on_press(letter):
        print('{} pressed'.format(letter))
         
    settings = {
        "clicked_font_color" : (0,0,0),
        "hover_font_color"   : (205,195, 100),
        'font'               : pg.font.Font(None,16),
        'font_color'         : (255,255,255),
        'border_color'       : (0,0,0),
    }
    btns = []
    for position, letter in enumerate(string.ascii_lowercase):
        btn_height = 15
        spacer = 5
        top = position*btn_height + spacer
        b = Button(rect=(10,top,105,btn_height), command=lambda l=letter:print_on_press(l), text=letter, **settings)
        btns.append(b)
 
    while not done:
        mouse = pg.mouse.get_pos()
        for event in pg.event.get():
            if event.type == pg.QUIT:
                done = True
            for btn in btns:
                btn.get_event(event)
        for btn in btns:
            btn.draw(screen)
        pg.display.update()
Recommended Tutorials:
Reply


Messages In This Thread
User Interface - by metulburr - Nov-13-2017, 01:05 PM
RE: User Interface - by metulburr - Nov-03-2018, 04:08 PM
RE: User Interface - by metulburr - Nov-03-2018, 04:30 PM

Forum Jump:

User Panel Messages

Announcements
Announcement #1 8/1/2020
Announcement #2 8/2/2020
Announcement #3 8/6/2020