Coverage for topdownengine/ui.py: 84%
91 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 14:20 +0000
« 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
7class UIContainer:
8 """Class to store a collection of UI elements.
10 Attributes:
11 elements (set[BaseUIElement]): The set of all elements in this container. This is a managed property.
12 """
14 def __init__(self):
15 "Initialize the UIContainer."
16 self._elements = set()
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
23 def add_ui_element(self, element: BaseUIElement) -> None:
24 """Add a UI element to this container.
26 Args:
27 element (BaseUIElement): The element to add.
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.")
35 self._elements.add(element)
36 element._containers.add(self)
38 def remove_ui_element(self, element: BaseUIElement) -> None:
39 """Remove a UI element from this container.
41 Args:
42 element (BaseUIElement): The element to remove.
43 """
44 self._elements.remove(element)
45 element._containers.remove(self)
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)
52 def handle_event(self, event: pg.Event) -> None:
53 """Handle a single event for all elements in this container.
55 Args:
56 event (pygame.Event): The event to handle.
57 """
58 for e in self.elements.copy():
59 e.handle_event(event)
61 def update(self, dt: float) -> None:
62 """Update all elements in this container.
64 Args:
65 dt (float): The deltatime.
66 """
67 for e in self.elements.copy():
68 e.update(dt)
70 def render(self, surface: pg.Surface) -> None:
71 """Render all elements to a given surface.
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)
79class BaseUIElement:
80 """Base class for UI elements.
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 """
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.
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
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
107 @property
108 def image(self) -> pg.Surface:
109 "The surface of the element. This is a managed property."
110 return self._image
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)})
117 def add_container(self, container: UIContainer) -> None:
118 """Add this UI element to a container.
120 Args:
121 container (UIContainer): The container to add.
123 Raises:
124 TypeError: If the container is not an instance of UIContainer.
125 """
127 if not isinstance(container, UIContainer):
128 raise TypeError("Containers must be instances of UIContainer.")
130 self._containers.add(container)
131 container._elements.add(self)
133 def remove_container(self, container: UIContainer) -> None:
134 """Remove this UI element from a container.
136 Args:
137 container (UIContainer): The container to remove from.
138 """
139 self._containers.remove(container)
140 container._elements.remove(self)
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)
147 def handle_event(self, event: pg.Event) -> None: # pragma: no cover
148 """Handle a single event.
150 Args:
151 event (pygame.Event): The event to handle.
152 """
153 pass
155 def update(self, dt: float) -> None: # pragma: no cover
156 """Update this element.
158 Args:
159 dt (float): The deltatime.
160 """
161 pass
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.
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
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)
183 return self._image
185 image = property(fget=_get_image, fset=BaseUIElement.image.fset)
187 def is_mouse_over(self) -> bool:
188 "Is the mouse over this button?"
189 return self.rect.collidepoint(pg.mouse.get_pos())
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()
195 def update(self, dt: float) -> None:
196 self._enable_hover = True
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.
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).
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 )
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)
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)