I developed two custom UI components with Pygame: a Button widget and a Frame widget. After placing the Button inside the Frame, I encountered an issue with the Button’s collision detection.
Requirements pygame-ce==2.5.6 python >=3.11
Problem When the Button was nested inside the Frame, its collision area was still being calculated relative to the main screen, not relative to the Frame’s coordinate space. This caused inaccurate interaction behavior when the cursor hovered or clicked on the Button.
My Solution To resolve the issue, I attempted to convert the cursor position by adjusting event.pos inside the BTNBase class. I defined a method named convert_pos to translate the global cursor coordinates into the Frame’s local coordinate system.
Request for Feedback I would like to know whether my current implementation of the Frame widget is structurally correct. Will the current design cause issues in future updates?
import pygame # pygame-ce==2.5.6 from abc import ABC, abstractmethod CLR_SPACE_GREY = (17, 0, 34) # main #110022 CLR_VOID = (5, 13, 37) CLR_NEON_GREEN = (80, 255, 120) # Active #50ff78 CLR_NEON_GREEN_LIGHT = (150, 255, 180) # Hover #96FFB4 CLR_NEON_GREEN_DARK = (20, 120, 50) # Press #147832 BTN_WIDTH = 200 BTN_HEIGHT = 50 BTN_SIZE = (BTN_WIDTH,BTN_HEIGHT) BTN_CLR_NORMAL = CLR_NEON_GREEN BTN_CLR_HOVER = CLR_NEON_GREEN_LIGHT BTN_CLR_PRESS = CLR_NEON_GREEN_DARK class Color: def __init__(self, r:int, g:int, b:int): self.r = r self.g = g self.b = b class Point: def __init__(self,x:int,y:int): self.x = x self.y = y class BTNBase(pygame.sprite.Sprite, ABC): def __init__(self,command:function=None): super().__init__() self.on_click = command self.state = "NORMAL" self.pressed = False @staticmethod def convert_pos(pos:tuple[int,int], offsets:tuple[int,int]): """ to fix frame positioning actually buttons collision is counted relative to main screen surface but to fix it i convert cursor position for when widget is inside frame :param pos: cursor position by `pygame.event` :param offsets: the difference between main pos and frame pos :return: exact position where cursor can find widget """ return (pos[0]-offsets[0] ,pos[1]-offsets[1]) def events(self, event:pygame.event.Event, frame_offsets:tuple[int,int] = (0,0)): if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: cursor_pos = self.convert_pos((event.pos[0],event.pos[1]), frame_offsets) if self.rect.collidepoint(cursor_pos): self.pressed = True self.state = "PRESS" elif event.type == pygame.MOUSEBUTTONUP and event.button == 1: cursor_pos = self.convert_pos((event.pos[0],event.pos[1]), frame_offsets) if self.rect.collidepoint(cursor_pos): self.curr_time = pygame.time.get_ticks() if self.on_click and self.pressed: self.on_click() self.pressed = False if self.rect.collidepoint(cursor_pos): self.state = "HOVER" else: self.state = "NORMAL" if event.type == pygame.MOUSEMOTION: cursor_pos = self.convert_pos((event.pos[0],event.pos[1]), frame_offsets) if self.rect.collidepoint(cursor_pos): if not self.pressed: self.state = "HOVER" else: self.state = "NORMAL" @abstractmethod def blits(self): pass class Button(BTNBase): def __init__(self,surface:pygame.Surface, width:int=10, height:int=10, x:int=10, y:int=10, text:str="" ,font_size:int=18, font_bold:bool=False, command:function=None): super().__init__(command) self.surface = surface self.background = pygame.Surface((width,height), pygame.SRCALPHA) self.image = self.background self.rect = self.image.get_rect(topleft = (x, y)) self.font = pygame.font.Font(None, font_size) if font_bold: self.font.set_bold(True) self.text = self.font.render(text,True,CLR_SPACE_GREY) self.text_rect = self.text.get_rect() # pos relates to self.image pos self.text_rect.topleft = ((width//2)-(self.text_rect.w//2),(height//2)-(self.text_rect.h//2)) def blits(self): self.image = self.background if self.state == "NORMAL": self.image.fill(BTN_CLR_NORMAL) elif self.state == "HOVER": self.image.fill(BTN_CLR_HOVER) elif self.state == "PRESS": self.image.fill(BTN_CLR_PRESS) self.image.blit(self.text, self.text_rect) self.surface.blit(self.image, self.rect) class Frame(pygame.Surface): def __init__(self, surface:pygame.Surface, size:Point=(10,10), x:int = 10, y:int = 10, bg:Color=(pygame.Color("Black"))): super().__init__(size,pygame.SRCALPHA) self.bg = bg self.fill(self.bg) self.surface = surface self.rect = pygame.rect.Rect((x,y),size) self.widgets = dict() self.widgets_count = 0 def add_widget(self,widget:pygame.sprite.Sprite): # TODO add feature to add multiple widgets at once self.widgets_count += 1 self.widgets[self.widgets_count] = widget def events(self,event): self.fill(self.bg) for key,widget in self.widgets.items(): widget.events(event, frame_offsets=(self.rect.x, self.rect.y)) widget.blits() def blits(self): self.surface.blit(self, self.rect) if __name__ == "__main__": pygame.init() demo_screen = pygame.display.set_mode((400,400)) frame = Frame(demo_screen,(200, 300), x=40, y=40) # Button(surface, width, height, x-pos, y-pos, text, ...) btn1 = Button(frame, 100 , 50 , 40 , 40, "BTN1",font_size=22, command=lambda : print("BTN1")) btn2 = Button(frame, 100 , 50 , 40 , 100, "BTN2",font_size=22, command=lambda : print("BTN2")) btn3 = Button(frame, 100 , 50 , 40 , 160, "BTN3",font_size=22, command=lambda : print("BTN3")) frame.add_widget(btn1) frame.add_widget(btn2) frame.add_widget(btn3) while True: for event in pygame.event.get(): if event.type == pygame.QUIT: break; demo_screen.fill(CLR_SPACE_GREY) frame.events(event) frame.blits() pygame.display.update() ```