diff --git a/CHANGELOG.md b/CHANGELOG.md index cebc332..7d26541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,13 @@ - Every `handle_event` returns `True` when it consumed the event (button presses and clicks, slider drags, clicks and typing in a focused text input, open-dropdown interactions). `Group.handle_event` stops at the first widget that consumes an event and returns whether one did. - `Group` now sets the mouse cursor after each `MOUSEMOTION`: the `cursor` of the hovered widget (hand for buttons, sliders and dropdowns; I-beam for text inputs), otherwise the arrow. Widgets no longer change the cursor themselves. - A focused text input sees mouse clicks first, so clicking another widget still unfocuses it. -- Export `Group`, `Widget` and all widget classes from the top-level package (`from PygameUIKit import ButtonText, Slider`). Submodules remain importable. +- Export `Group`, `Widget` and all widget classes from the top-level package (`from PygameUIKit import Button, Slider`). Submodules remain importable. - Remove `Group.draw`, which drew every widget at the same position. +- Replace the eight button classes with four: `Button` (rounded text button), `ImageButton`, `IconButton` and `IconToggle`. All click on a left press and release inside the button, cancel on window focus loss, and expose `pressed`. +- `Button` sizes itself to its text (or a fixed `width` with `align`), derives hover and pressed shades from `color`, picks black or white text for contrast unless `text_color` is given, and defaults to `border_radius=6` and `padding=(16, 8)`. `outline_color` draws a 2 px outline. `button.text` is a property that resizes the button. +- `Label(text, color="white", *, font=None, align="left")` draws exactly at (x, y); `draw(surface, x, y, width=None)` aligns the text within `width`. Setting `text` or `color` re-renders only when the value changes. +- `IconButton`'s clickable area and hover backdrop are the icon plus `padding` on each side, starting at (x, y). +- Add `utils.shade(color, amount)`; `utils.best_contrast_color` uses perceived brightness and returns a `pygame.Color`. - Remove the duplicate root `src/demo.py` and `src/assets/`; the packaged `pygameuikit-demo` is the demo. ### Upgrading from 0.2 @@ -24,6 +29,16 @@ - Button `on_hover()`/`on_unhover()` hooks and Slider `on_hover()`/`on_unhover()` are removed; read `hovered` instead. - Widgets no longer call `pygame.mouse.set_cursor`; route `MOUSEMOTION` events through a `Group` to get cursor changes. - Overlapping widgets in a `Group` no longer all react to the same click; only the first one that consumes it does. +- Buttons take `on_click` (second positional argument or keyword) instead of `onclick_f`; reassign `button.on_click` instead of calling `connect()`. `clicked` → `pressed`. +- `ButtonText(text, onclick_f, rect_color=..., font_color=..., fixed_width=..., text_align=...)` → `Button(text, on_click, color=..., text_color=..., width=..., align=...)`. `change_text(text)` → `button.text = text`. +- `ButtonRect(w, h, color, onclick_f)` → `Button(text, on_click, color=..., width=w)` (the height follows the font and `padding`), or an `ImageButton` showing a filled `pygame.Surface((w, h))`. +- `ButtonImage(image, onclick_f, hover_image)` → `ImageButton(image, on_click, hover_image=...)`. +- `ButtonImageText(image, onclick_f, text, text_color, font, image_hover, text_offset)` → `ImageButton(image, on_click, hover_image=..., text=..., text_color=..., font=...)`; text is centered and `text_offset` is removed. +- `ButtonPngIcon(image, onclick_f, hover_color, opacity, inflate)` → `IconButton(image, on_click, hover_color=..., hover_alpha=int(opacity * 255), padding=inflate // 2)`. The icon is now drawn at (x + padding, y + padding). +- `ButtonTwoStates(image_default, image_click, onclick_f)` → `IconToggle(image_off, image_on, on_change)`; `on_change` receives the new `value` (bool) and `state` (1/2) → `value` (False/True). +- `ButtonThreadImage` and `ButtonThreadText` are removed. Set `button.text` (or `image_button.image`) from your own state each frame instead, e.g. `button.text = "Searching..." if thread.is_alive() else "Find match"`. +- `Label(text, font_color, font, text_align, bounding_rect)` → `Label(text, color, font=..., align=...)`. Pass the bounding width to `draw(surface, x, y, width)` instead of `bounding_rect`; the `TextAlignment` enum is removed (use `"left"`, `"center"`, `"right"`). Labels no longer add 10 px of padding: draw at `(x + 10, y + 10)` for the old placement. `label.render_text()` and `label.text_surface` → `label.surface` (re-rendered automatically). +- `utils.make_surface_darker` is removed; use `utils.shade` for colors. ## 0.2.0 diff --git a/README.md b/README.md index 9ce9103..5921e82 100644 --- a/README.md +++ b/README.md @@ -28,19 +28,13 @@ uv installs Python and dependencies automatically. The demo is also included in ```python import pygame -from PygameUIKit import Group, button +from PygameUIKit import Button, Group pygame.init() screen = pygame.display.set_mode((640, 360)) clock = pygame.time.Clock() ui = Group() -hello = button.ButtonText( - "Say hello", - lambda: print("Hello!"), - rect_color=(85, 145, 92), - border_radius=8, - ui_group=ui, -) +hello = Button("Say hello", lambda: print("Hello!"), color=(85, 145, 92), ui_group=ui) running = True while running: @@ -62,11 +56,14 @@ pygame.quit() Add widgets with `ui_group=ui`, forward each event once, and call `ui.update(dt)` with seconds. `ui.handle_event(event)` returns `True` when a widget consumed the event (for example a button click or typing into a focused text input), so you can skip your own handling for it. The group also sets the mouse cursor for the hovered widget. Draw each widget at your chosen position. Sliders accept `draw(screen, x, y, width, height)`; charts use the same layout arguments. Draw dropdowns last so their menus appear above other widgets. -- `button`: text, image, icon, toggle, and thread-status buttons. +- `Button("Play", on_click, color="seagreen")`: rounded text button sized to its text; set `text` to relabel it. Options: `width` and `align`, `outline_color`, `border_radius`, `padding`, `text_color` (black or white for contrast by default). +- `ImageButton(image, on_click, hover_image=None, text="")`: an image, optionally with centered text. +- `IconButton(icon, on_click)`: an icon with a translucent backdrop on hover. +- `IconToggle(icon_off, icon_on, on_change)`: switches icons on click and calls `on_change(value)`; read or set `value`. - `slider.Slider(min, max, step)`: `get_value()` and `connect(callback)`. - `combobox.ComboBox(["One", "Two"])`: `get_value()` and `add_action(index, callback)`. - `text_input.TextInput(placeholder="Name")`: `get_text()` and `set_text(text)`. -- `label.Label("Hello", "white")`: update the `text` property. +- `Label("Hello", "white")`: draws at exactly (x, y); `draw(screen, x, y, width)` aligns it within `width` using `align="left" | "center" | "right"`. Set `text` to change it. - `barchart.BarChart([10, 20, 30])`: `change_value(index, value)`; accepts non-negative values. Text inputs use Pygame's `TEXTINPUT` events for Unicode text and `KEYDOWN` for editing. Forward both through `Group`; don't also forward the same events separately to the input. Pass a custom `pygame.font.Font` if you need a particular font or character set. diff --git a/src/PygameUIKit/__init__.py b/src/PygameUIKit/__init__.py index 2fbb092..406cdae 100644 --- a/src/PygameUIKit/__init__.py +++ b/src/PygameUIKit/__init__.py @@ -2,16 +2,7 @@ from . import barchart, button, combobox, label, slider, text_input, utils, widget from .barchart import BarChart -from .button import ( - ButtonImage, - ButtonImageText, - ButtonPngIcon, - ButtonRect, - ButtonText, - ButtonThreadImage, - ButtonThreadText, - ButtonTwoStates, -) +from .button import Button, IconButton, IconToggle, ImageButton from .combobox import ComboBox from .label import Label from .slider import Slider @@ -20,16 +11,12 @@ __all__ = [ "BarChart", - "ButtonImage", - "ButtonImageText", - "ButtonPngIcon", - "ButtonRect", - "ButtonText", - "ButtonThreadImage", - "ButtonThreadText", - "ButtonTwoStates", + "Button", "ComboBox", "Group", + "IconButton", + "IconToggle", + "ImageButton", "Label", "Slider", "TextInput", diff --git a/src/PygameUIKit/button.py b/src/PygameUIKit/button.py index 893c4f4..5383a56 100644 --- a/src/PygameUIKit/button.py +++ b/src/PygameUIKit/button.py @@ -1,400 +1,242 @@ -import threading +from collections.abc import Callable import pygame as pg -from pygame import Rect -from pygame.color import Color +from pygame.typing import ColorLike from . import utils -from .label import Label, TextAlignment -from .utils import get_font +from .label import Label from .widget import Group, Widget -class EasyButton(Widget): - """Base for clickable widgets: calls ``onclick_f`` on a left press and release inside.""" +class _Clickable(Widget): + """Calls ``on_click()`` when the left button is pressed and released inside ``rect``.""" cursor = pg.SYSTEM_CURSOR_HAND - def __init__(self, onclick_f, ui_group: Group | None = None): + def __init__(self, on_click: Callable[[], None] | None, *, ui_group: Group | None): super().__init__(ui_group=ui_group) - self.clicked = False - self.onclick_f = onclick_f + self.on_click = on_click + self.pressed = False - def is_mouse_on_button(self, pos): - return self.rect.collidepoint(pos) - - def handle_event(self, event): + def handle_event(self, event: pg.event.Event) -> bool: if event.type == pg.WINDOWFOCUSLOST: - self.clicked = False - self.hovered = False + self.pressed = self.hovered = False elif event.type == pg.MOUSEMOTION: - self.hovered = self.is_mouse_on_button(event.pos) + self.hovered = self.rect.collidepoint(event.pos) elif event.type == pg.MOUSEBUTTONDOWN and event.button == 1: - self.clicked = self.is_mouse_on_button(event.pos) - return self.clicked - elif event.type == pg.MOUSEBUTTONUP and event.button == 1: - activate = self.clicked and self.is_mouse_on_button(event.pos) - self.clicked = False - if activate: - self._on_click() - return activate + self.pressed = self.rect.collidepoint(event.pos) + return self.pressed + elif event.type == pg.MOUSEBUTTONUP and event.button == 1 and self.pressed: + self.pressed = False + if self.rect.collidepoint(event.pos): + self._activate() + return True return False - def _on_click(self): - if self.onclick_f is not None: - self.onclick_f() + def _activate(self) -> None: + if self.on_click is not None: + self.on_click() - def connect(self, func, when="on_click"): - if when == "on_click": - self.onclick_f = func +class Button(_Clickable): + """A rounded button with a text label, sized to fit unless ``width`` is given. -class ButtonRect(EasyButton): - def __init__(self, w, h, color, onclick_f, outline_color=None, border_radius=0, ui_group=None): - super().__init__(onclick_f, ui_group) - self.bg_color = color - self.border_radius = border_radius - self.rect = pg.Rect(0, 0, w, h) - self.surface = None - self.surface_hover = None - self.surface_clicked = None - self.outline_color = outline_color - self.render() - - def render(self): - self.surface = pg.surface.Surface((self.rect.w, self.rect.h), pg.SRCALPHA) - pg.draw.rect( - self.surface, - self.bg_color, - self.surface.get_rect(), - border_radius=self.border_radius, - width=0, - ) - if self.outline_color: - pg.draw.rect( - self.surface, - self.outline_color, - self.surface.get_rect(), - border_radius=self.border_radius, - width=2, - ) - self.render_hover() - self.render_clicked() + ``play = Button("Play", start_game, color="seagreen", ui_group=ui)``; + ``play.draw(screen, x, y)`` every frame. Hover and press shades are derived from ``color`` + and the text is black or white for contrast unless ``text_color`` is set. + """ - def draw(self, screen, x, y): + def __init__( + self, + text: str, + on_click: Callable[[], None] | None = None, + *, + color: ColorLike = (59, 130, 246), + text_color: ColorLike | None = None, + border_radius: int = 6, + outline_color: ColorLike | None = None, + padding: tuple[int, int] = (16, 8), + font: pg.font.Font | None = None, + width: int | None = None, + align: str = "center", + ui_group: Group | None = None, + ): + if border_radius < 0: + raise ValueError("border_radius must not be negative") + if len(padding) != 2 or min(padding) < 0: + raise ValueError("padding must be two non-negative numbers (x, y)") + if width is not None and width <= 0: + raise ValueError("width must be positive") + self.color = pg.Color(color) + self.text_color = None if text_color is None else pg.Color(text_color) + self.outline_color = None if outline_color is None else pg.Color(outline_color) + self.border_radius = border_radius + self.padding = padding + self.width = width + self.label = Label(text, self._text_color(), font=font, align=align) + super().__init__(on_click, ui_group=ui_group) + self._resize() + + @property + def text(self) -> str: + return self.label.text + + @text.setter + def text(self, value: str) -> None: + self.label.text = value + self._resize() + + def _text_color(self) -> pg.Color: + if self.text_color is not None: + return self.text_color + return utils.best_contrast_color(self.color) + + def _resize(self) -> None: + text_width, text_height = self.label.surface.get_size() + pad_x, pad_y = self.padding + self.rect.size = (self.width or text_width + 2 * pad_x, text_height + 2 * pad_y) + + def draw(self, surface: pg.Surface, x: int, y: int) -> None: + self._resize() self.rect.topleft = (x, y) - if self.clicked: - screen.blit(self.surface_clicked, self.rect) + if self.pressed: + fill = utils.shade(self.color, 0.25) elif self.hovered: - screen.blit(self.surface_hover, self.rect) + fill = utils.shade(self.color, 0.12) else: - screen.blit(self.surface, self.rect) - - def render_hover(self): - self.surface_hover = utils.make_surface_darker(self.surface, 30) - - def render_clicked(self): - self.surface_clicked = utils.make_surface_darker(self.surface, 50) - + fill = self.color + pg.draw.rect(surface, fill, self.rect, border_radius=self.border_radius) + if self.outline_color is not None: + pg.draw.rect( + surface, self.outline_color, self.rect, 2, border_radius=self.border_radius + ) + self.label.color = self._text_color() + pad_x = self.padding[0] + label_y = y + (self.rect.h - self.label.surface.get_height()) // 2 + self.label.draw(surface, x + pad_x, label_y, self.rect.w - 2 * pad_x) -class ButtonImage(EasyButton): - def __init__(self, image, onclick_f, hover_image=None, ui_group=None): - super().__init__(onclick_f, ui_group=ui_group) - self.image = image - self.hover_image = hover_image if hover_image is not None else image - self.rect: Rect = self.image.get_rect() - def draw(self, screen, x, y): - self.rect.topleft = (x, y) - if self.hovered: - screen.blit(self.hover_image, self.rect) - else: - screen.blit(self.image, self.rect) +class ImageButton(_Clickable): + """A clickable image, optionally swapped for ``hover_image`` and with centered ``text``. + ``ImageButton(pg.image.load("play.png"), start_game, ui_group=ui).draw(screen, x, y)`` + """ -class ButtonImageText(ButtonImage): def __init__( self, - image, - onclick_f, - text, - text_color=(255, 255, 255), - font=None, - image_hover=None, - text_offset=(0, 0), - ui_group=None, + image: pg.Surface, + on_click: Callable[[], None] | None = None, + *, + hover_image: pg.Surface | None = None, + text: str = "", + text_color: ColorLike = "white", + font: pg.font.Font | None = None, + ui_group: Group | None = None, ): - super().__init__(image, onclick_f, hover_image=image_hover, ui_group=ui_group) - self.text = text - self.text_color = text_color - self.text_offset = text_offset - self.font = font if font is not None else get_font(15) - - def draw(self, screen, x, y): - super().draw(screen, x, y) - text = self.font.render(self.text, True, self.text_color) - text_rect = text.get_rect() - text_rect.center = ( - self.rect.centerx + self.text_offset[0], - self.rect.centery + self.text_offset[1], - ) - screen.blit(text, text_rect) - + self.image = image + self.hover_image = hover_image + self.label = Label(text, text_color, font=font, align="center") + super().__init__(on_click, ui_group=ui_group) + self.rect = image.get_rect() + + @property + def text(self) -> str: + return self.label.text + + @text.setter + def text(self, value: str) -> None: + self.label.text = value + + def draw(self, surface: pg.Surface, x: int, y: int) -> None: + image = self.image + if self.hover_image is not None and (self.hovered or self.pressed): + image = self.hover_image + self.rect = image.get_rect(topleft=(x, y)) + surface.blit(image, self.rect) + label_y = self.rect.centery - self.label.surface.get_height() // 2 + self.label.draw(surface, x, label_y, self.rect.w) + + +class IconButton(_Clickable): + """An icon with a translucent rounded backdrop while hovered or pressed. + + ``IconButton(icon, open_settings, ui_group=ui).draw(screen, x, y)``; the clickable area is + the icon plus ``padding`` on every side, starting at (x, y). + """ -class ButtonPngIcon(ButtonImage): def __init__( - self, image, onclick_f, hover_color=(128, 128, 128), opacity=0.5, inflate=10, ui_group=None + self, + image: pg.Surface, + on_click: Callable[[], None] | None = None, + *, + hover_color: ColorLike = (128, 128, 128), + hover_alpha: int = 128, + padding: int = 6, + border_radius: int = 6, + ui_group: Group | None = None, ): - super().__init__(image, onclick_f, ui_group=ui_group) - self.hover_color = hover_color - self.opacity = int(opacity * 255) - self.inflate = inflate - - def draw(self, screen, x, y): - self.rect.topleft = (x, y) - - # if hover then blit a transparent rect behind the image - if self.hovered or self.clicked: + if not 0 <= hover_alpha <= 255: + raise ValueError("hover_alpha must be between 0 and 255") + if padding < 0 or border_radius < 0: + raise ValueError("padding and border_radius must not be negative") + self.image = image + self.hover_color = pg.Color(hover_color) + self.hover_alpha = hover_alpha + self.padding = padding + self.border_radius = border_radius + super().__init__(on_click, ui_group=ui_group) + width, height = image.get_size() + self.rect = pg.Rect(0, 0, width + 2 * padding, height + 2 * padding) + + def draw(self, surface: pg.Surface, x: int, y: int) -> None: + width, height = self.image.get_size() + self.rect = pg.Rect(x, y, width + 2 * self.padding, height + 2 * self.padding) + if self.hovered or self.pressed: + alpha = min(255, self.hover_alpha * 3 // 2) if self.pressed else self.hover_alpha utils.draw_transparent_rect_with_border_radius( - screen, - self.hover_color, - self.rect.inflate(self.inflate, self.inflate), - 10, - self.opacity, + surface, self.hover_color, self.rect, self.border_radius, alpha ) + surface.blit(self.image, (x + self.padding, y + self.padding)) - screen.blit(self.image, self.rect) +class IconToggle(IconButton): + """An icon button that switches between two images; ``on_change(value)`` gets the new state. -class ButtonThreadImage(ButtonImage): - """ - Really specific button type, useful for button that need to have a state depending on a thread - e.g a button that start matchmaking + ``pause = IconToggle(play_icon, pause_icon, lambda playing: print(playing), ui_group=ui)``. + Read or set ``pause.value``; setting it does not call ``on_change``. """ - def __init__( - self, image_idle, image_working, image_hover, img_success, onclick_f, ui_group=None - ): - super().__init__(image_idle, onclick_f, hover_image=image_hover, ui_group=ui_group) - self.image_idle = image_idle - self.image_working = image_working - self.image_hover = self.hover_image - self.img_success = img_success - - self.isWorking = False - self.isSuccess = False - - def draw(self, screen, x, y): - self.rect.topleft = (x, y) - - if self.isSuccess: - image = self.img_success - elif self.isWorking: - image = self.image_working - elif self.hovered: - image = self.image_hover - - else: - image = self.image_idle - - screen.blit(image, self.rect) - - def check_thread(self, thread, cond): - if not thread: - self.idle() - return - if thread.is_alive(): - self.working() - elif thread.ident is not None and cond: - self.success() - else: - self.idle() - - def working(self): - self.isWorking = True - self.isSuccess = False - - def idle(self): - self.isWorking = False - self.isSuccess = False - - def success(self): - self.isSuccess = True - self.isWorking = False - - -class ButtonText(ButtonRect): def __init__( self, - text="", - onclick_f=None, - rect_color=(0, 0, 0), - border_radius=0, - font=None, - font_color=None, - outline_color=None, - fixed_width=None, - text_align=None, - ui_group=None, + image_off: pg.Surface, + image_on: pg.Surface, + on_change: Callable[[bool], None] | None = None, + *, + value: bool = False, + hover_color: ColorLike = (128, 128, 128), + hover_alpha: int = 128, + padding: int = 6, + border_radius: int = 6, + ui_group: Group | None = None, ): - - if font_color is None: - font_color = utils.best_contrast_color(rect_color) - self.fixed_width = fixed_width - - if ( - text_align in ("center", "right", TextAlignment.CENTER, TextAlignment.RIGHT) - and fixed_width is None - ): - raise ValueError("Center and right alignment require fixed_width") - - rect = Rect(0, 0, fixed_width, 0) if fixed_width else None - self.label = Label(text, font_color, font=font, text_align=text_align, bounding_rect=rect) - + self.image_off = image_off + self.image_on = image_on + self.on_change = on_change + self.value = bool(value) super().__init__( - 0, - 0, - rect_color, - onclick_f, + image_on if self.value else image_off, + hover_color=hover_color, + hover_alpha=hover_alpha, + padding=padding, border_radius=border_radius, ui_group=ui_group, - outline_color=outline_color, ) - def render(self): - self.label.render_text() - if self.fixed_width: - self.rect.w = self.fixed_width - else: - self.rect.w = self.label.rect.w + 20 - self.rect.h = self.label.rect.h + 20 - super().render() - - def change_text(self, new_text): - if new_text == self.label.text: - return - self.label.text = new_text - self.render() - - def draw(self, screen, x, y): - super().draw(screen, x, y) - self.label.draw(screen, x, y) - - -class ButtonThreadText(ButtonRect): - def __init__( - self, - *, - rect_color=(0, 0, 0), - onclick_f=None, - thread=None, - text_before="", - text_during="", - text_after="", - border_radius=0, - font=None, - ui_group=None, - text_color=(255, 255, 255), - ): - - self.thread = thread - self.idle_color = rect_color - - self.text_before = text_before - self.text_during = text_during - self.text_after = text_after - self.font = font if font is not None else get_font(15) - self.font_color = text_color - - self.text_surface_idle = self.font.render(self.text_before, True, self.font_color) - self.text_surface_working = None - self.text_surface_success = None - self.text_surface_hover = None - self.render_text_surfaces() - - w = self.text_surface_idle.get_width() + 20 - h = self.text_surface_idle.get_height() + 20 - super().__init__( - w, h, rect_color, onclick_f, border_radius=border_radius, ui_group=ui_group - ) - - self.isWorking = False - self.isSuccess = False - - def render_text_surfaces(self): - self.text_surface_idle = self.font.render(self.text_before, True, self.font_color) - self.text_surface_working = self.font.render(self.text_during, True, self.font_color) - self.text_surface_success = self.font.render(self.text_after, True, self.font_color) - - def draw(self, screen, x, y): - super().draw(screen, x, y) - if self.isSuccess: - screen.blit(self.text_surface_success, (x + 10, y + 10)) - elif self.isWorking: - screen.blit(self.text_surface_working, (x + 10, y + 10)) - else: - screen.blit(self.text_surface_idle, (x + 10, y + 10)) - - def check_thread(self, thread: threading.Thread, success_condition): - if not thread: - self.idle() - return - # check if thread did not start or is running or is done - if thread.is_alive(): - self.working() - else: - if thread.ident is None: - self.idle() - else: - if success_condition: - self.success() - else: - self.idle() - - def working(self): - self.isWorking = True - self.isSuccess = False - - self.bg_color = self.idle_color - self.rect.w = self.text_surface_working.get_width() + 20 - super().render() - - def idle(self): - self.isWorking = False - self.isSuccess = False - self.bg_color = self.idle_color - self.rect.w = self.text_surface_idle.get_width() + 20 - super().render() - - def success(self): - self.isSuccess = True - self.isWorking = False - self.rect.w = self.text_surface_success.get_width() + 20 - self.bg_color = Color((87, 147, 94)) - super().render() - - -class ButtonTwoStates(ButtonPngIcon): - def __init__( - self, - image_default, - image_click, - onclick_f, - hover_color=(128, 128, 128), - opacity=0.5, - inflate=10, - ui_group=None, - ): - super().__init__(image_default, onclick_f, hover_color, opacity, inflate, ui_group) - self.images = {1: image_default, 2: image_click} - - self.state = 1 - - def draw(self, screen, x, y): - self.image = self.images[self.state] - self.rect.size = self.image.get_size() - super().draw(screen, x, y) + def _activate(self) -> None: + self.value = not self.value + if self.on_change is not None: + self.on_change(self.value) - def _on_click(self): - self.state = 2 if self.state == 1 else 1 - super()._on_click() + def draw(self, surface: pg.Surface, x: int, y: int) -> None: + self.image = self.image_on if self.value else self.image_off + super().draw(surface, x, y) diff --git a/src/PygameUIKit/combobox.py b/src/PygameUIKit/combobox.py index ce870a5..0853001 100644 --- a/src/PygameUIKit/combobox.py +++ b/src/PygameUIKit/combobox.py @@ -4,7 +4,7 @@ import pygame as pg from pygame import Color -from .button import ButtonText +from .button import Button from .label import Label from .utils import draw_transparent_rect_with_border_radius, load_image @@ -13,22 +13,22 @@ cwd = os.path.dirname(__file__) -class ComboBox(ButtonText): +class ComboBox(Button): def __init__(self, elements: list[str], ui_group=None, font=None, font_color=(0, 0, 0)): if not elements: raise ValueError("ComboBox requires at least one element") super().__init__( - ui_group=ui_group, - text=elements[0], - onclick_f=self.toggle_open, + elements[0], + self.toggle_open, + color="white", + text_color=font_color, border_radius=5, - fixed_width=200, + padding=(PADDING, PADDING), font=font, - font_color=font_color, - rect_color=(255, 255, 255), + align="left", + ui_group=ui_group, ) self.font = self.label.font - self.text = elements[0] self.elements = elements self.selected_index = 0 self.hovered_index = self.selected_index @@ -43,20 +43,20 @@ def __init__(self, elements: list[str], ui_group=None, font=None, font_color=(0, self._pressed_index = None self.img_arrow_up = pg.transform.rotate(self.img_arrow_down.copy(), 180) - self.fixed_width = max(200, max(self.font.size(text)[0] for text in elements) + 50) - self.label.bounds.w = self.fixed_width - self.render() + self.width = max(200, max(self.font.size(text)[0] for text in elements) + 50) + self._resize() self._render_elements() def _render_elements(self): self._labels.clear() for element in self.elements: - self._labels.append(Label(element, font=self.font, font_color=Color("white"))) + self._labels.append(Label(element, "white", font=self.font)) def draw(self, screen, x, y): super().draw(screen, x, y) img = self.img_arrow_up if self.is_open else self.img_arrow_down - screen.blit(img, (x + self.rect.w - self.img_arrow_down.get_width() - PADDING, y + 10)) + arrow_y = y + (self.rect.h - img.get_height()) // 2 + screen.blit(img, (x + self.rect.w - img.get_width() - PADDING, arrow_y)) if self.is_open: height = self.rect.h * len(self.elements) @@ -74,7 +74,8 @@ def draw(self, screen, x, y): border_radius=5, alpha=100, ) - self._labels[i].draw(screen, x, y + self.rect.h * (i + 1)) + label_y = y + self.rect.h * (i + 1) + PADDING + self._labels[i].draw(screen, x + PADDING, label_y) @property def event_priority(self): @@ -92,7 +93,7 @@ def handle_event(self, event): ): self.is_open = False self._pressed_index = None - self.clicked = False + self.pressed = False if self.is_open and event.type in (pg.MOUSEBUTTONDOWN, pg.MOUSEBUTTONUP, pg.MOUSEMOTION): index = self._option_at(event.pos) if event.type == pg.MOUSEMOTION: @@ -125,7 +126,6 @@ def change_selected(self, index): raise IndexError("ComboBox index out of range") self.selected_index = index self.hovered_index = index - self.change_text(self.elements[self.selected_index]) self.text = self.elements[self.selected_index] self._render_elements() if index in self.actions: diff --git a/src/PygameUIKit/demo.py b/src/PygameUIKit/demo.py index ee5d7b3..bf638d4 100644 --- a/src/PygameUIKit/demo.py +++ b/src/PygameUIKit/demo.py @@ -38,15 +38,14 @@ def __init__(self): ui_group=self.easy_objects, font=def_font, ) - self.btn_pause = button.ButtonTwoStates( - img_play, img_stop, do_nothing, ui_group=self.easy_objects + self.btn_pause = button.IconToggle( + img_play, img_stop, self.set_dancing, ui_group=self.easy_objects ) - self.btn_png = button.ButtonPngIcon( - img_play, self.change_values, inflate=10, ui_group=self.easy_objects + self.btn_png = button.IconButton(img_play, self.change_values, ui_group=self.easy_objects) + self.btn_shuffle = button.Button( + "Shuffle", self.change_values, color=BTN_BLUE, font=def_font, ui_group=self.easy_objects ) - self.btn_pause.connect(self.toggle_dance) - self.slider = slider.Slider( 0, 100, 1, show_value=True, ui_group=self.easy_objects, font=def_font ) @@ -96,6 +95,7 @@ def draw(self, win): self.text_input.draw(win, W // 2 - self.text_input.rect.w // 2, H - 200) self.btn_pause.draw(win, 100, 200) self.btn_png.draw(win, 100, 300) + self.btn_shuffle.draw(win, 100, 430) self.slider.draw(win, 100, 400, 300, 5) self.chart.draw(win, 159, 164, 300, 100) self.dropdown.draw(win, 100, 100) @@ -106,12 +106,8 @@ def change_values(self): new_value = random.randint(0, 100) self.chart.change_value(i, new_value) - def toggle_dance(self): - self.dancing = not self.dancing - - -def do_nothing(): - pass + def set_dancing(self, dancing): + self.dancing = dancing def main(): diff --git a/src/PygameUIKit/label.py b/src/PygameUIKit/label.py index e55592c..69aab93 100644 --- a/src/PygameUIKit/label.py +++ b/src/PygameUIKit/label.py @@ -1,64 +1,66 @@ -from enum import Enum - import pygame as pg +from pygame.typing import ColorLike from .utils import get_font -from .widget import Widget +from .widget import Group, Widget + +ALIGNMENTS = ("left", "center", "right") -class TextAlignment(Enum): - LEFT = "left" - RIGHT = "right" - CENTER = "center" +def _check_align(align: str) -> str: + if align not in ALIGNMENTS: + raise ValueError(f"align must be one of {ALIGNMENTS}, not {align!r}") + return align class Label(Widget): - """Static text: ``Label("Hello", "white").draw(surface, x, y)``; set ``text`` to change it.""" + """A line of text drawn with its top-left corner at (x, y). + + ``score = Label("Score: 0")``; ``score.draw(screen, 10, 10)`` every frame. Set ``score.text`` + to change it. With ``draw(..., width=w)`` the text is aligned within ``w`` pixels. + """ def __init__( self, - text, - font_color, - font=None, - text_align=TextAlignment.LEFT, - bounding_rect=None, - ui_group=None, + text: str, + color: ColorLike = "white", + *, + font: pg.font.Font | None = None, + align: str = "left", + ui_group: Group | None = None, ): - super().__init__(ui_group=ui_group) - self.bounds = bounding_rect # Used only when centering the text - self._text = text - self.font_color = font_color + self.align = _check_align(align) self.font = font if font is not None else get_font() - self.text_surface: pg.Surface | None = None - self.text_align = TextAlignment(text_align or TextAlignment.LEFT) - if self.text_align != TextAlignment.LEFT and self.bounds is None: - raise ValueError("Center and right alignment require a bounding_rect") - self.render_text() + self._text = text + self._color = pg.Color(color) + self.surface = self.font.render(text, True, self._color) + super().__init__(ui_group=ui_group) + self.rect = self.surface.get_rect() @property - def text(self): + def text(self) -> str: return self._text @text.setter - def text(self, value): - if self._text != value: + def text(self, value: str) -> None: + if value != self._text: + self.surface = self.font.render(value, True, self._color) self._text = value - self.render_text() - def draw(self, screen, x, y): - if self.text_align == TextAlignment.LEFT or self.text_align == TextAlignment.LEFT.value: - xx, yy = x + 10, y + 10 - elif self.text_align == TextAlignment.RIGHT or self.text_align == TextAlignment.RIGHT.value: - xx, yy = x + self.bounds.w - self.text_surface.get_width() - 10, y + 10 - elif ( - self.text_align == TextAlignment.CENTER or self.text_align == TextAlignment.CENTER.value - ): - xx, yy = x + self.bounds.w // 2 - self.text_surface.get_width() // 2, y + 10 - else: - raise ValueError(f"Unknown text alignment {self.text_align}") - self.rect.topleft = (xx, yy) - screen.blit(self.text_surface, self.rect) + @property + def color(self) -> pg.Color: + return self._color + + @color.setter + def color(self, value: ColorLike) -> None: + value = pg.Color(value) + if value != self._color: + self._color = value + self.surface = self.font.render(self._text, True, value) - def render_text(self): - self.text_surface = self.font.render(self.text, True, self.font_color) - self.rect = self.text_surface.get_rect(topleft=self.rect.topleft) + def draw(self, surface: pg.Surface, x: int, y: int, width: int | None = None) -> None: + text_width, height = self.surface.get_size() + width = text_width if width is None else width + self.rect = pg.Rect(x, y, width, height) + offset = {"left": 0, "center": (width - text_width) // 2, "right": width - text_width} + surface.blit(self.surface, (x + offset[self.align], y)) diff --git a/src/PygameUIKit/utils.py b/src/PygameUIKit/utils.py index b0a4350..1086e78 100644 --- a/src/PygameUIKit/utils.py +++ b/src/PygameUIKit/utils.py @@ -1,20 +1,6 @@ import pygame as pg from pygame import Color - - -def make_surface_darker(surface: pg.Surface, amount: int = 50) -> pg.Surface: - """ - Make the given surface darker by the given amount. - You can make the surface lighter by passing a negative amount. - :param surface: The surface to make darker. - :param amount: The amount to make the surface darker by. - :return: The darker surface. - """ - new_surface = surface.copy() - amount = max(-255, min(255, amount)) - blend = pg.BLEND_RGB_SUB if amount >= 0 else pg.BLEND_RGB_ADD - new_surface.fill((abs(amount),) * 3, special_flags=blend) - return new_surface +from pygame.typing import ColorLike def draw_transparent_rect_with_border_radius(screen, color, rect, border_radius, alpha): @@ -24,14 +10,21 @@ def draw_transparent_rect_with_border_radius(screen, color, rect, border_radius, screen.blit(surf, rect) -def best_contrast_color(rgb_color: Color | tuple): - if isinstance(rgb_color, Color): - rgb_color = rgb_color.r, rgb_color.g, rgb_color.b - r, g, b = rgb_color[:3] - if r + g + b < 500: - return 255, 255, 255 - else: - return 0, 0, 0 +def _luma(color: pg.Color) -> float: + return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b + + +def best_contrast_color(color: ColorLike) -> Color: + """Black or white, whichever reads better on ``color``.""" + return Color("black") if _luma(Color(color)) >= 150 else Color("white") + + +def shade(color: ColorLike, amount: float) -> Color: + """Mix ``color`` toward black (toward white if it is very dark) by ``amount`` in 0..1.""" + color = Color(color) + shaded = color.lerp("white" if _luma(color) < 60 else "black", amount) + shaded.a = color.a + return shaded def load_image(path, size=None) -> pg.Surface: diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 713b392..228103b 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -7,8 +7,8 @@ from PygameUIKit import Group, Widget, button, slider, text_input, utils from PygameUIKit.barchart import BarChart from PygameUIKit.combobox import ComboBox -from PygameUIKit.label import Label, TextAlignment -from PygameUIKit.utils import lighter_color, load_image, make_surface_darker +from PygameUIKit.label import Label +from PygameUIKit.utils import best_contrast_color, lighter_color, load_image, shade def mouse(kind, pos=(20, 20), button_number=1): @@ -30,10 +30,9 @@ def test_import_does_not_initialize_pygame(): @pytest.mark.parametrize( "factory", [ - lambda: button.ButtonText("Hello"), - lambda: button.ButtonImageText(pg.Surface((20, 20)), None, "Hello"), - lambda: button.ButtonThreadText(text_before="Start"), - lambda: Label("Hello", "white"), + lambda: button.Button("Hello"), + lambda: button.ImageButton(pg.Surface((20, 20)), text="Hello"), + lambda: Label("Hello"), lambda: slider.Slider(0, 10, 1), lambda: text_input.TextInput(), lambda: ComboBox(["Hello", "World"]), @@ -50,7 +49,7 @@ def test_default_fonts_survive_pygame_restart(factory, display): def test_button_only_activates_for_matching_left_click(display): calls = [] - widget = button.ButtonText("Click", lambda: calls.append(True)) + widget = button.Button("Click", lambda: calls.append(True)) widget.draw(display, 10, 10) for kind in (pg.MOUSEBUTTONDOWN, pg.MOUSEBUTTONUP): widget.handle_event(mouse(kind, button_number=3)) @@ -61,11 +60,11 @@ def test_button_only_activates_for_matching_left_click(display): widget.handle_event(mouse(pg.MOUSEBUTTONDOWN)) widget.handle_event(mouse(pg.MOUSEBUTTONUP)) assert calls == [True] - assert not widget.clicked + assert not widget.pressed def test_button_without_callback_and_image_without_hover(display): - widget = button.ButtonImage(pg.Surface((30, 30)), None) + widget = button.ImageButton(pg.Surface((30, 30))) widget.draw(display, 10, 10) widget.handle_event(mouse(pg.MOUSEMOTION)) widget.draw(display, 10, 10) @@ -145,7 +144,7 @@ def test_combobox_selection_and_callback_observe_current_value(display): group = Group() widget = ComboBox(["One", "A much longer second option"], ui_group=group) widget.draw(display, 10, 10) - assert widget.surface.get_width() == widget.rect.width + assert widget.rect.width >= widget.font.size(widget.elements[1])[0] + 40 values = [] widget.add_action(1, lambda: values.append((widget.get_value(), widget.text))) group.handle_event(mouse(pg.MOUSEBUTTONDOWN)) @@ -186,13 +185,12 @@ def test_chart_validation(kwargs): BarChart(**kwargs) -def test_color_adjustment_clamps_and_preserves_alpha(): - surface = pg.Surface((1, 1), pg.SRCALPHA) - surface.fill((250, 10, 100, 42)) - assert make_surface_darker(surface, -50).get_at((0, 0)) == (255, 60, 150, 42) - assert make_surface_darker(surface, 50).get_at((0, 0)) == (200, 0, 50, 42) - assert surface.get_at((0, 0)) == (250, 10, 100, 42) +def test_color_helpers(): assert lighter_color(pg.Color(0, 0, 0, 10), 50).a == 0 + assert best_contrast_color("white") == pg.Color("black") + assert best_contrast_color((40, 60, 160)) == pg.Color("white") + assert shade((200, 100, 50, 42), 0.5) == pg.Color(100, 50, 25, 42) + assert shade("black", 0.5) == pg.Color(128, 128, 128) def test_image_loading_without_display_mode(tmp_path): @@ -202,19 +200,11 @@ def test_image_loading_without_display_mode(tmp_path): assert load_image(path, (10, 10)).get_size() == (10, 10) -@pytest.mark.parametrize("alignment", [TextAlignment.CENTER, "right"]) -def test_alignment_requires_bounds(alignment): - with pytest.raises(ValueError): - button.ButtonText("Hello", text_align=alignment) - with pytest.raises(ValueError): - Label("Hello", "white", text_align=alignment) - - def test_group_stops_at_first_consumer_and_survives_removal(display): group = Group() calls = [] - first = button.ButtonText("First", lambda: group.remove(first), ui_group=group) - second = button.ButtonText("Second", lambda: calls.append(True), ui_group=group) + first = button.Button("First", lambda: group.remove(first), ui_group=group) + second = button.Button("Second", lambda: calls.append(True), ui_group=group) first.draw(display, 10, 10) second.draw(display, 10, 10) assert group.handle_event(mouse(pg.MOUSEBUTTONDOWN)) @@ -229,7 +219,7 @@ def test_group_stops_at_first_consumer_and_survives_removal(display): def test_handle_event_reports_consumption(display): - btn = button.ButtonText("Click") + btn = button.Button("Click") btn.draw(display, 10, 10) assert not btn.handle_event(mouse(pg.MOUSEMOTION)) assert not btn.handle_event(mouse(pg.MOUSEBUTTONDOWN, (500, 500))) @@ -253,7 +243,7 @@ def test_handle_event_reports_consumption(display): assert field.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_LEFT)) assert not field.handle_event(mouse(pg.MOUSEBUTTONDOWN, (500, 500))) - for widget in (Label("Hi", "white"), BarChart([1])): + for widget in (Label("Hi"), BarChart([1])): widget.draw(display, 10, 10) assert not widget.handle_event(mouse(pg.MOUSEBUTTONDOWN)) @@ -261,7 +251,7 @@ def test_handle_event_reports_consumption(display): def test_clicking_a_button_unfocuses_text_input(display): group = Group() calls = [] - btn = button.ButtonText("Click", lambda: calls.append(True), ui_group=group) + btn = button.Button("Click", lambda: calls.append(True), ui_group=group) field = text_input.TextInput(fixed_width=100, ui_group=group) btn.draw(display, 10, 10) field.draw(display, 10, 100) @@ -277,9 +267,9 @@ def test_group_sets_cursor_of_hovered_widget(display, monkeypatch): cursors = [] monkeypatch.setattr(utils, "set_system_cursor", cursors.append) group = Group() - btn = button.ButtonText("Click", ui_group=group) + btn = button.Button("Click", ui_group=group) field = text_input.TextInput(fixed_width=100, ui_group=group) - Label("Label", "white", ui_group=group).draw(display, 10, 200) + Label("Label", ui_group=group).draw(display, 10, 200) btn.draw(display, 10, 10) field.draw(display, 10, 100) @@ -307,7 +297,7 @@ def test_widget_base_defaults(): def test_dropdown_consumes_clicks_over_other_widgets(display): group = Group() calls = [] - underneath = button.ButtonText("Under menu", lambda: calls.append(True), ui_group=group) + underneath = button.Button("Under menu", lambda: calls.append(True), ui_group=group) menu = ComboBox(["First", "Second"], ui_group=group) menu.draw(display, 10, 10) underneath.draw(display, 10, 10 + menu.rect.height) @@ -329,7 +319,7 @@ def test_dropdown_does_not_select_on_unmatched_release(display): def test_button_focus_loss_cancels_click(display): calls = [] - widget = button.ButtonText("Click", lambda: calls.append(True)) + widget = button.Button("Click", lambda: calls.append(True)) widget.draw(display, 10, 10) widget.handle_event(mouse(pg.MOUSEBUTTONDOWN)) widget.handle_event(pg.event.Event(pg.WINDOWFOCUSLOST)) @@ -337,32 +327,157 @@ def test_button_focus_loss_cancels_click(display): assert calls == [] -def test_toggle_callback_sees_new_state(display): - states = [] - widget = button.ButtonTwoStates(pg.Surface((30, 30)), pg.Surface((40, 40)), None) - widget.connect(lambda: states.append(widget.state)) +def click(widget, pos=(20, 20)): + return [ + widget.handle_event(mouse(kind, pos)) for kind in (pg.MOUSEBUTTONDOWN, pg.MOUSEBUTTONUP) + ] + + +def test_click_needs_press_and_release_inside(display): + calls = [] + btn = button.Button("Click", lambda: calls.append(True)) + btn.draw(display, 10, 10) + assert btn.handle_event(mouse(pg.MOUSEBUTTONDOWN, (500, 500))) is False + assert btn.handle_event(mouse(pg.MOUSEBUTTONUP)) is False + assert btn.handle_event(mouse(pg.MOUSEBUTTONDOWN)) + assert btn.pressed + assert not btn.handle_event(mouse(pg.MOUSEBUTTONUP, (500, 500))) + assert not btn.pressed and calls == [] + assert click(btn) == [True, True] + assert calls == [True] + btn.on_click = lambda: calls.append("new") + click(btn) + assert calls == [True, "new"] + + +@pytest.mark.parametrize( + "factory", + [ + lambda: button.Button("Hi"), + lambda: button.ImageButton(pg.Surface((30, 30))), + lambda: button.IconButton(pg.Surface((30, 30))), + lambda: button.IconToggle(pg.Surface((30, 30)), pg.Surface((30, 30))), + ], +) +def test_clickables_share_hover_cursor_and_focus_loss(factory, display): + widget = factory() widget.draw(display, 10, 10) + assert widget.cursor == pg.SYSTEM_CURSOR_HAND + widget.handle_event(mouse(pg.MOUSEMOTION)) + assert widget.hovered widget.handle_event(mouse(pg.MOUSEBUTTONDOWN)) - widget.handle_event(mouse(pg.MOUSEBUTTONUP)) widget.draw(display, 10, 10) - assert states == [2] - assert widget.rect.size == (40, 40) - - -def test_thread_buttons_complete_and_reset(): - import threading - - thread = threading.Thread(target=lambda: None) - thread.start() - thread.join() - surface = pg.Surface((20, 20)) - image = button.ButtonThreadImage(surface, surface, None, surface, None) - text = button.ButtonThreadText(rect_color=(10, 20, 30), text_before="Go", text_after="Done") - for widget in (image, text): - widget.check_thread(thread, True) - assert widget.isSuccess and not widget.isWorking - widget.working() - assert widget.isWorking and not widget.isSuccess - widget.idle() - assert not widget.isWorking and not widget.isSuccess - assert text.bg_color == (10, 20, 30) + widget.handle_event(pg.event.Event(pg.WINDOWFOCUSLOST)) + assert not widget.pressed and not widget.hovered + assert not widget.handle_event(mouse(pg.MOUSEBUTTONUP)) + + +def test_button_auto_size_padding_and_text_property(): + font = pg.font.Font(None, 20) + btn = button.Button("Hi", font=font, padding=(10, 4)) + text_width, text_height = font.render("Hi", True, "white").get_size() + assert btn.rect.size == (text_width + 20, text_height + 8) + btn.text = "A longer label" + assert btn.text == "A longer label" + assert btn.rect.w == font.render("A longer label", True, "white").get_width() + 20 + fixed = button.Button("Hi", font=font, width=150) + fixed.text = "Something else" + assert fixed.rect.w == 150 + + +def test_button_text_color_and_states(display): + assert button.Button("x", color="white").label.color == pg.Color("black") + assert button.Button("x", color="navy").label.color == pg.Color("white") + assert button.Button("x", color="navy", text_color="gold").label.color == pg.Color("gold") + btn = button.Button("x", color=(200, 200, 200), outline_color="black", border_radius=0) + btn.draw(display, 0, 0) + assert display.get_at((btn.rect.centerx, 0)) == pg.Color("black") + normal = display.get_at((5, 5)) + btn.handle_event(mouse(pg.MOUSEMOTION, (5, 5))) + btn.draw(display, 0, 0) + hovered = display.get_at((5, 5)) + btn.handle_event(mouse(pg.MOUSEBUTTONDOWN, (5, 5))) + btn.draw(display, 0, 0) + pressed = display.get_at((5, 5)) + assert normal.r > hovered.r > pressed.r + + +@pytest.mark.parametrize( + "kwargs", + [dict(align="middle"), dict(width=0), dict(padding=(-1, 0)), dict(border_radius=-1)], +) +def test_button_validation_happens_before_joining_group(kwargs): + group = Group() + with pytest.raises(ValueError): + button.Button("x", ui_group=group, **kwargs) + with pytest.raises(ValueError): + button.IconButton(pg.Surface((5, 5)), hover_alpha=300, ui_group=group) + assert group.widgets == [] + + +def test_image_button_hover_image_and_text(display): + image, hover = pg.Surface((40, 20)), pg.Surface((60, 30)) + widget = button.ImageButton(image, hover_image=hover, text="Go") + widget.draw(display, 10, 10) + assert widget.rect == pg.Rect(10, 10, 40, 20) + widget.handle_event(mouse(pg.MOUSEMOTION)) + widget.draw(display, 10, 10) + assert widget.rect.size == (60, 30) + assert widget.label.rect.centerx == widget.rect.centerx + widget.text = "Stop" + assert widget.label.text == "Stop" + + +def test_icon_button_area_includes_padding(display): + calls = [] + widget = button.IconButton(pg.Surface((20, 20)), lambda: calls.append(1), padding=5) + assert widget.rect.size == (30, 30) + widget.draw(display, 10, 10) + assert widget.rect == pg.Rect(10, 10, 30, 30) + click(widget, (12, 12)) + assert calls == [1] + + +def test_icon_toggle_reports_new_value(display): + values = [] + widget = button.IconToggle(pg.Surface((30, 30)), pg.Surface((40, 40)), values.append) + widget.draw(display, 10, 10) + click(widget) + widget.draw(display, 10, 10) + assert values == [True] and widget.value + assert widget.rect.size == (52, 52) + click(widget) + assert values == [True, False] and not widget.value + widget.value = True + assert values == [True, False] + assert button.IconToggle(pg.Surface((1, 1)), pg.Surface((1, 1)), value=True).value + + +@pytest.mark.parametrize("align,offset", [("left", 0), ("center", 25), ("right", 50)]) +def test_label_draws_exactly_at_position_and_aligns(align, offset): + font = pg.font.Font(None, 20) + label = Label("Hi", font=font, align=align) + text_width, text_height = font.render("Hi", True, "white").get_size() + target = pg.Surface((300, 100), pg.SRCALPHA) + label.draw(target, 10, 20, width=text_width + 50) + assert label.rect == pg.Rect(10, 20, text_width + 50, text_height) + bounds = target.get_bounding_rect() + assert bounds.x - 10 - offset in range(0, 3) + assert bounds.y >= 20 + label.draw(target, 5, 6) + assert label.rect == pg.Rect(5, 6, text_width, text_height) + + +def test_label_rerenders_only_on_change(): + label = Label("Hi", "red") + surface = label.surface + label.text = "Hi" + label.color = (255, 0, 0) + assert label.surface is surface + label.text = "Bye" + assert label.surface is not surface and label.text == "Bye" + surface = label.surface + label.color = "blue" + assert label.surface is not surface and label.color == pg.Color("blue") + with pytest.raises(ValueError): + Label("Hi", align="justify")