Coverage for topdownengine/ui.py: 84%

91 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-13 14:20 +0000

1from __future__ import annotations 

2import pygame as pg 

3from collections.abc import Callable 

4from .font import Font 

5from .visual_utils import VisualUtils 

6 

7class UIContainer: 

8 """Class to store a collection of UI elements. 

9  

10 Attributes: 

11 elements (set[BaseUIElement]): The set of all elements in this container. This is a managed property. 

12 """ 

13 

14 def __init__(self): 

15 "Initialize the UIContainer." 

16 self._elements = set() 

17 

18 @property 

19 def elements(self) -> set[BaseUIElement]: 

20 "The set of all elements in this container. This is a managed property." 

21 return self._elements 

22 

23 def add_ui_element(self, element: BaseUIElement) -> None: 

24 """Add a UI element to this container. 

25  

26 Args: 

27 element (BaseUIElement): The element to add. 

28 

29 Raises: 

30 TypeError: If the element is not an instance of a subclass of BaseUIElement. 

31 """ 

32 if not isinstance(element, BaseUIElement): 

33 raise TypeError("Elements must be subclasses of the BaseUIElement class.") 

34 

35 self._elements.add(element) 

36 element._containers.add(self) 

37 

38 def remove_ui_element(self, element: BaseUIElement) -> None: 

39 """Remove a UI element from this container. 

40  

41 Args: 

42 element (BaseUIElement): The element to remove. 

43 """ 

44 self._elements.remove(element) 

45 element._containers.remove(self) 

46 

47 def remove_all_ui_elements(self) -> None: 

48 "Remove all UI elements from this container." 

49 for element in self.elements.copy(): 

50 self.remove_ui_element(element) 

51 

52 def handle_event(self, event: pg.Event) -> None: 

53 """Handle a single event for all elements in this container. 

54  

55 Args: 

56 event (pygame.Event): The event to handle. 

57 """ 

58 for e in self.elements.copy(): 

59 e.handle_event(event) 

60 

61 def update(self, dt: float) -> None: 

62 """Update all elements in this container. 

63  

64 Args: 

65 dt (float): The deltatime. 

66 """ 

67 for e in self.elements.copy(): 

68 e.update(dt) 

69 

70 def render(self, surface: pg.Surface) -> None: 

71 """Render all elements to a given surface. 

72  

73 Args: 

74 surface (pygame.Surface): The surface to render to. 

75 """ 

76 for e in self.elements: 

77 surface.blit(e.image, e.rect) 

78 

79class BaseUIElement: 

80 """Base class for UI elements. 

81  

82 Attributes: 

83 containers (set[UIContainer]): The set of all containers that contain this element. This is a managed property. 

84 image (pygame.Surface): The surface of the element. This is a managed property. 

85 """ 

86 

87 def __init__(self, position: pg.typing.Point, align: str="center", image: pg.Surface=None): 

88 """Handle a single event for all elements in this container. 

89  

90 Args: 

91 position (pygame.typing.Point): The position of the element. 

92 align (str): The alignment of the element. 

93 image (pygame.Surface, optional): The image to use for the element. 

94 """ 

95 self._containers = set() 

96 self._image = image 

97 if self._image is None: 

98 self._image = pg.Surface((1,1), pg.SRCALPHA) 

99 self.rect = self._image.get_rect(**{align: position}) 

100 self.align = align 

101 

102 @property 

103 def containers(self) -> set[UIContainer]: 

104 "The set of all containers that contain this element. This is a managed property." 

105 return self._containers 

106 

107 @property 

108 def image(self) -> pg.Surface: 

109 "The surface of the element. This is a managed property." 

110 return self._image 

111 

112 @image.setter 

113 def image(self, new_image: pg.Surface): 

114 self._image = new_image 

115 self.rect = new_image.get_rect(**{self.align: getattr(self.rect, self.align)}) 

116 

117 def add_container(self, container: UIContainer) -> None: 

118 """Add this UI element to a container. 

119  

120 Args: 

121 container (UIContainer): The container to add. 

122 

123 Raises: 

124 TypeError: If the container is not an instance of UIContainer. 

125 """ 

126 

127 if not isinstance(container, UIContainer): 

128 raise TypeError("Containers must be instances of UIContainer.") 

129 

130 self._containers.add(container) 

131 container._elements.add(self) 

132 

133 def remove_container(self, container: UIContainer) -> None: 

134 """Remove this UI element from a container. 

135  

136 Args: 

137 container (UIContainer): The container to remove from. 

138 """ 

139 self._containers.remove(container) 

140 container._elements.remove(self) 

141 

142 def remove_from_all_containers(self) -> None: 

143 "Remove this UI element from all containers." 

144 for container in self.containers.copy(): 

145 self.remove_container(container) 

146 

147 def handle_event(self, event: pg.Event) -> None: # pragma: no cover 

148 """Handle a single event. 

149  

150 Args: 

151 event (pygame.Event): The event to handle. 

152 """ 

153 pass 

154 

155 def update(self, dt: float) -> None: # pragma: no cover 

156 """Update this element. 

157  

158 Args: 

159 dt (float): The deltatime. 

160 """ 

161 pass 

162 

163class Button(BaseUIElement): 

164 def __init__(self, position: pg.typing.Point, align: str="center", image: pg.Surface=None, on_click: Callable[[], None]=None, hover_highlight_strength: int=100): 

165 """Create a button. 

166  

167 Args: 

168 position (pygame.typing.Point): The position of the button. 

169 align (str, optional): The alignment of the button in relation to the position. Defaults to "center". 

170 image (pygame.Surface, optional): The Surface that is drawn to represent the button. Defaults to None, 

171 on_click (Callable, optional): The on click callback function. Defaults to None. 

172 hover_highlight_strength (int, optional): The hover highlight strength. Defaults to 100. 

173 """ 

174 super().__init__(position, align, image) 

175 self.on_click = on_click 

176 self.hover_highlight_strength = hover_highlight_strength 

177 self._enable_hover = False 

178 

179 def _get_image(self) -> pg.Surface: 

180 if self._enable_hover and self.is_mouse_over(): 

181 return VisualUtils.make_img_white(self._image, self.hover_highlight_strength) 

182 

183 return self._image 

184 

185 image = property(fget=_get_image, fset=BaseUIElement.image.fset) 

186 

187 def is_mouse_over(self) -> bool: 

188 "Is the mouse over this button?" 

189 return self.rect.collidepoint(pg.mouse.get_pos()) 

190 

191 def handle_event(self, event: pg.Event) -> None: 

192 if event.type == pg.MOUSEBUTTONUP and self.is_mouse_over() and self.on_click is not None: 

193 self.on_click() 

194 

195 def update(self, dt: float) -> None: 

196 self._enable_hover = True 

197 

198 @classmethod 

199 def create_styled( 

200 cls, 

201 text: str, 

202 font: Font, 

203 position: pg.typing.Point, 

204 width: int, 

205 height: int, 

206 background_color: pg.typing.ColorLike, 

207 foreground_color: pg.typing.ColorLike, 

208 on_click: Callable[[], None]=None, 

209 hover_highlight_strength: int=100, 

210 border_radius: int=-1 

211 ): 

212 """Create a pre-styled button with a given position, text, size, styles, and on click callback function. 

213  

214 Args: 

215 text (str): The text to render on the button. 

216 font (Font): The font to use to render the text. 

217 position (pygame.typing.Point): The position of the button. 

218 width (int): The width of the button. 

219 height (int): The height of the button. 

220 background_color (pygame.typing.ColorLike): The background color. 

221 foreground_color (pygame.typing.ColorLike): The text color. 

222 on_click (Callable, optional): The on click callback function. Defaults to None. 

223 hover_highlight_strength (int, optional): The hover highlight strength. Defaults to 100. 

224 border_radius (int, optional): The border radius of the button. Defaults to -1 (no border radius). 

225  

226 Returns: 

227 Button: The resulting Button object. 

228 """ 

229 image = pg.Surface((width, height), pg.SRCALPHA) 

230 rect = pg.Rect(0, 0, width, height) 

231 padded_rect = pg.Rect(0, 0, width - (border_radius/2 if border_radius > 0 else 0), height) 

232 padded_rect.center = (width/2, height/2) 

233 pg.draw.rect( 

234 image, 

235 background_color, 

236 rect, 

237 border_radius=border_radius 

238 ) 

239 

240 font.draw_text(text, width / 2, height / 2, font.get_max_size_for_text_in_rect(text, padded_rect), image, foreground_color) 

241 return cls(position, "center", image, on_click, hover_highlight_strength) 

242 

243class Text(BaseUIElement): 

244 def __init__(self, position: pg.typing.Point, font: Font, size: int, text: str, color: pg.typing.ColorLike, align: str="center"): 

245 image = font._render(size, text, color) 

246 super().__init__(position, align, image)