diff --git a/CHANGELOG.md b/CHANGELOG.md index 19279ab..cebc332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## Unreleased (0.3.0) + +0.3.0 is a deliberate breaking cleanup; there are no compatibility aliases. + +- Add `Widget`, the base class of every widget, with `rect`, `hovered`, `cursor`, `handle_event(event) -> bool`, `update(dt)` and `draw(surface, x, y, ...)`. `Widget` and `Group` live in `PygameUIKit.widget`. +- 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. +- Remove `Group.draw`, which drew every widget at the same position. +- Remove the duplicate root `src/demo.py` and `src/assets/`; the packaged `pygameuikit-demo` is the demo. + +### Upgrading from 0.2 + +- `PygameUIKit.super_object` → `PygameUIKit.widget`; `EasyObject` → `Widget`. `ObjectWithText` and `EasyObject.update_pos` are removed. +- `PygameUIKit.utilis` → `PygameUIKit.utils`; `ligther_color` → `lighter_color`; `get_font` moved from `super_object` to `utils`. +- `label.DEFAULT_FONT` is removed; pass `font=None` (the default) for the default font. +- `Group.objects` → `Group.widgets`. `Group.draw(screen, x, y)` is removed; draw each widget yourself. +- `handle_events(events)` on buttons and text inputs is removed; call `handle_event(event)` per event (or use `Group`). +- Button `is_hover` and text input `hover` → `hovered`. +- `TextInput.cursor` (caret index) → `TextInput.caret`; `cursor` is now the widget's system cursor. +- 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. + ## 0.2.0 - Support Python 3.11–3.15 with pygame-ce, installed automatically as a dependency. diff --git a/README.md b/README.md index 37e21e8..9ce9103 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ while running: pygame.quit() ``` -Add widgets with `ui_group=ui`, forward each event once, and call `ui.update(dt)` with seconds. 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. +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. - `slider.Slider(min, max, step)`: `get_value()` and `connect(callback)`. diff --git a/pyproject.toml b/pyproject.toml index d571017..daadc1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dev = ["pytest>=9,<10", "ruff>=0.14,<1"] [tool.uv.build-backend] module-name = "PygameUIKit" -source-include = ["tests/**", "src/demo.py"] +source-include = ["tests/**"] [tool.ruff] target-version = "py311" diff --git a/src/PygameUIKit/__init__.py b/src/PygameUIKit/__init__.py index 0b96a4a..2fbb092 100644 --- a/src/PygameUIKit/__init__.py +++ b/src/PygameUIKit/__init__.py @@ -1,6 +1,45 @@ """Composable Pygame UI widgets with explicit drawing and event dispatch.""" -from . import barchart, button, combobox, label, slider, text_input -from .super_object import Group +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 .combobox import ComboBox +from .label import Label +from .slider import Slider +from .text_input import TextInput +from .widget import Group, Widget -__all__ = ["Group", "barchart", "button", "combobox", "label", "slider", "text_input"] +__all__ = [ + "BarChart", + "ButtonImage", + "ButtonImageText", + "ButtonPngIcon", + "ButtonRect", + "ButtonText", + "ButtonThreadImage", + "ButtonThreadText", + "ButtonTwoStates", + "ComboBox", + "Group", + "Label", + "Slider", + "TextInput", + "Widget", + "barchart", + "button", + "combobox", + "label", + "slider", + "text_input", + "utils", + "widget", +] diff --git a/src/PygameUIKit/barchart.py b/src/PygameUIKit/barchart.py index 48dbe74..98adf58 100644 --- a/src/PygameUIKit/barchart.py +++ b/src/PygameUIKit/barchart.py @@ -4,8 +4,8 @@ import pygame as pg from pygame import Color, Rect -from .super_object import EasyObject, get_font -from .utilis import draw_transparent_rect_with_border_radius +from .utils import draw_transparent_rect_with_border_radius, get_font +from .widget import Widget CHANGE_SPEED = 200 SPACE_BETWEEN_BARS = 2 @@ -18,7 +18,9 @@ def color_from_float(colors): return new_colors -class BarChart(EasyObject): +class BarChart(Widget): + """Animated bars: ``BarChart([10, 20]).draw(surface, x, y, width, height)``.""" + def __init__( self, values: list[int], diff --git a/src/PygameUIKit/button.py b/src/PygameUIKit/button.py index 9b5626f..893c4f4 100644 --- a/src/PygameUIKit/button.py +++ b/src/PygameUIKit/button.py @@ -4,64 +4,43 @@ from pygame import Rect from pygame.color import Color -from . import utilis -from .label import DEFAULT_FONT, Label, TextAlignment -from .super_object import EasyObject, Group, get_font +from . import utils +from .label import Label, TextAlignment +from .utils import get_font +from .widget import Group, Widget -class EasyButton(EasyObject): - def __init__(self, onclick_f, ui_group: Group = None): +class EasyButton(Widget): + """Base for clickable widgets: calls ``onclick_f`` on a left press and release inside.""" + + cursor = pg.SYSTEM_CURSOR_HAND + + def __init__(self, onclick_f, ui_group: Group | None = None): super().__init__(ui_group=ui_group) - self.is_hover = False self.clicked = False self.onclick_f = onclick_f - self.ui_group = ui_group def is_mouse_on_button(self, pos): return self.rect.collidepoint(pos) - def _on_hover(self): - self.on_hover() - utilis.set_system_cursor(pg.SYSTEM_CURSOR_HAND) - - def on_hover(self): - pass - - def _on_unhover(self): - self.on_unhover() - utilis.set_system_cursor(pg.SYSTEM_CURSOR_ARROW) - - def on_unhover(self): - pass - - def handle_events(self, events): - for event in events: - self.handle_event(event) - def handle_event(self, event): if event.type == pg.WINDOWFOCUSLOST: self.clicked = False - self.is_hover = False - if event.type == pg.MOUSEBUTTONDOWN and event.button == 1: - if self.is_mouse_on_button(event.pos): - self.clicked = True - - if event.type == pg.MOUSEBUTTONUP and event.button == 1: + self.hovered = False + elif event.type == pg.MOUSEMOTION: + self.hovered = self.is_mouse_on_button(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() - - if event.type == pg.MOUSEMOTION: - was_hover = self.is_hover - self.is_hover = self.is_mouse_on_button(event.pos) - if self.is_hover and not was_hover: - self._on_hover() - elif not self.is_hover and was_hover: - self._on_unhover() + return activate + return False def _on_click(self): - utilis.set_system_cursor(pg.SYSTEM_CURSOR_ARROW) if self.onclick_f is not None: self.onclick_f() @@ -106,16 +85,16 @@ def draw(self, screen, x, y): self.rect.topleft = (x, y) if self.clicked: screen.blit(self.surface_clicked, self.rect) - elif self.is_hover: + elif self.hovered: screen.blit(self.surface_hover, self.rect) else: screen.blit(self.surface, self.rect) def render_hover(self): - self.surface_hover = utilis.make_surface_darker(self.surface, 30) + self.surface_hover = utils.make_surface_darker(self.surface, 30) def render_clicked(self): - self.surface_clicked = utilis.make_surface_darker(self.surface, 50) + self.surface_clicked = utils.make_surface_darker(self.surface, 50) class ButtonImage(EasyButton): @@ -127,7 +106,7 @@ def __init__(self, image, onclick_f, hover_image=None, ui_group=None): def draw(self, screen, x, y): self.rect.topleft = (x, y) - if self.is_hover: + if self.hovered: screen.blit(self.hover_image, self.rect) else: screen.blit(self.image, self.rect) @@ -175,8 +154,8 @@ def draw(self, screen, x, y): self.rect.topleft = (x, y) # if hover then blit a transparent rect behind the image - if self.is_hover or self.clicked: - utilis.draw_transparent_rect_with_border_radius( + if self.hovered or self.clicked: + utils.draw_transparent_rect_with_border_radius( screen, self.hover_color, self.rect.inflate(self.inflate, self.inflate), @@ -212,7 +191,7 @@ def draw(self, screen, x, y): image = self.img_success elif self.isWorking: image = self.image_working - elif self.is_hover: + elif self.hovered: image = self.image_hover else: @@ -251,7 +230,7 @@ def __init__( onclick_f=None, rect_color=(0, 0, 0), border_radius=0, - font=DEFAULT_FONT, + font=None, font_color=None, outline_color=None, fixed_width=None, @@ -260,7 +239,7 @@ def __init__( ): if font_color is None: - font_color = utilis.best_contrast_color(rect_color) + font_color = utils.best_contrast_color(rect_color) self.fixed_width = fixed_width if ( diff --git a/src/PygameUIKit/combobox.py b/src/PygameUIKit/combobox.py index ce108c9..ce870a5 100644 --- a/src/PygameUIKit/combobox.py +++ b/src/PygameUIKit/combobox.py @@ -5,8 +5,8 @@ from pygame import Color from .button import ButtonText -from .label import DEFAULT_FONT, Label -from .utilis import draw_transparent_rect_with_border_radius, load_image +from .label import Label +from .utils import draw_transparent_rect_with_border_radius, load_image PADDING = 10 @@ -14,7 +14,7 @@ class ComboBox(ButtonText): - def __init__(self, elements: list[str], ui_group=None, font=DEFAULT_FONT, font_color=(0, 0, 0)): + 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__( @@ -118,8 +118,7 @@ def handle_event(self, event): self.is_open = False elif index is not None: return True - super().handle_event(event) - return False + return super().handle_event(event) def change_selected(self, index): if not 0 <= index < len(self.elements): diff --git a/src/PygameUIKit/demo.py b/src/PygameUIKit/demo.py index 270ee5b..ee5d7b3 100644 --- a/src/PygameUIKit/demo.py +++ b/src/PygameUIKit/demo.py @@ -10,7 +10,7 @@ # # -# This is a file to showcase every EasyObject +# This is a file to showcase every widget # # diff --git a/src/PygameUIKit/label.py b/src/PygameUIKit/label.py index 8a62387..e55592c 100644 --- a/src/PygameUIKit/label.py +++ b/src/PygameUIKit/label.py @@ -2,9 +2,8 @@ import pygame as pg -from .super_object import EasyObject, get_font - -DEFAULT_FONT = None # Kept for callers that imported the old default; resolved lazily. +from .utils import get_font +from .widget import Widget class TextAlignment(Enum): @@ -13,12 +12,14 @@ class TextAlignment(Enum): CENTER = "center" -class Label(EasyObject): +class Label(Widget): + """Static text: ``Label("Hello", "white").draw(surface, x, y)``; set ``text`` to change it.""" + def __init__( self, text, font_color, - font=DEFAULT_FONT, + font=None, text_align=TextAlignment.LEFT, bounding_rect=None, ui_group=None, @@ -29,7 +30,6 @@ def __init__( self.font_color = font_color self.font = font if font is not None else get_font() self.text_surface: pg.Surface | None = None - self.rect: pg.Rect | 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") @@ -61,5 +61,4 @@ def draw(self, screen, x, y): def render_text(self): self.text_surface = self.font.render(self.text, True, self.font_color) - position = self.rect.topleft if self.rect is not None else (0, 0) - self.rect = self.text_surface.get_rect(topleft=position) + self.rect = self.text_surface.get_rect(topleft=self.rect.topleft) diff --git a/src/PygameUIKit/slider.py b/src/PygameUIKit/slider.py index 225da1d..bd52968 100644 --- a/src/PygameUIKit/slider.py +++ b/src/PygameUIKit/slider.py @@ -3,8 +3,8 @@ import pygame from pygame import Color -from .label import DEFAULT_FONT -from .super_object import EasyObject, get_font +from .utils import get_font +from .widget import Widget COLOR_COMPLETED = Color(132, 214, 80) COLOR_IN_PROGRESS = Color(200, 200, 200) @@ -13,14 +13,18 @@ COLOR_CIRCLE2 = COLOR_IN_PROGRESS -class Slider(EasyObject): +class Slider(Widget): + """Horizontal value slider: ``Slider(0, 100, 1).draw(surface, x, y, width, height)``.""" + + cursor = pygame.SYSTEM_CURSOR_HAND + def __init__( self, min, max, step, show_value=False, - font=DEFAULT_FONT, + font=None, font_color=(0, 0, 0), ui_group=None, ): @@ -42,30 +46,29 @@ def __init__( self.on_change = lambda: None + def _hit(self, pos): + return self.rect.inflate(self.circle_radius + 10, self.circle_radius + 10).collidepoint(pos) + def handle_event(self, event): if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: - if self.rect.inflate(self.circle_radius + 10, self.circle_radius + 10).collidepoint( - event.pos - ): - self.dragging = True + self.dragging = self._hit(event.pos) + if self.dragging: self._value_from_position(event.pos[0]) - elif event.type == pygame.MOUSEBUTTONUP and event.button == 1: + return self.dragging + if event.type == pygame.MOUSEBUTTONUP and event.button == 1: + was_dragging = self.dragging if self.dragging: self._value_from_position(event.pos[0]) self.dragging = False - elif event.type == pygame.WINDOWFOCUSLOST: + return was_dragging + if event.type == pygame.WINDOWFOCUSLOST: self.dragging = False elif event.type == pygame.MOUSEMOTION: - if not self.hovered and self.rect.inflate( - self.circle_radius + 10, self.circle_radius + 10 - ).collidepoint(event.pos): - self.on_hover() - elif self.hovered and not self.rect.inflate( - self.circle_radius + 10, self.circle_radius + 10 - ).collidepoint(event.pos): - self.on_unhover() + self.hovered = self._hit(event.pos) + self.circle_radius = 12 if self.hovered else 10 if self.dragging: self._value_from_position(event.pos[0]) + return False def _value_from_position(self, x): if self.rect.w > 0: @@ -151,11 +154,3 @@ def connect( """ if when == "on_change": self.on_change = f - - def on_hover(self): - self.hovered = True - self.circle_radius = 12 - - def on_unhover(self): - self.hovered = False - self.circle_radius = 10 diff --git a/src/PygameUIKit/super_object.py b/src/PygameUIKit/super_object.py deleted file mode 100644 index ead0fd1..0000000 --- a/src/PygameUIKit/super_object.py +++ /dev/null @@ -1,68 +0,0 @@ -import pygame as pg -from pygame import Color - - -class EasyObject: - def __init__(self, *, ui_group=None): - if ui_group is not None: - ui_group.add(self) - self.x = 0 - self.y = 0 - self.w = 0 - self.h = 0 - self.rect = pg.Rect(self.x, self.y, self.w, self.h) - self.hovered = False - - def update_pos(self, x, y): - """This is used when an object is on a surface. Its position relative to the surface might be (0,0) - but its position relative to the screen is not. This function updates the position of the object - """ - self.rect.move_ip(x, y) - self.x, self.y = self.rect.topleft - - -class Group: - def __init__(self, *objects): - self.objects = [] - for obj in objects: - self.add(obj) - - def add(self, obj): - if obj not in self.objects: - self.objects.append(obj) - - def remove(self, obj): - self.objects.remove(obj) - - def draw(self, screen, x, y): - for obj in tuple(self.objects): - obj.draw(screen, x, y) - - def handle_event(self, event): - # Open menus receive pointer events before widgets underneath them. - objects = sorted( - self.objects, key=lambda obj: getattr(obj, "event_priority", 0), reverse=True - ) - for obj in objects: - if hasattr(obj, "handle_event") and obj.handle_event(event): - return True - return False - - def update(self, dt): - for obj in tuple(self.objects): - if hasattr(obj, "update"): - obj.update(dt) - - -class ObjectWithText(EasyObject): - def __init__(self, fontsize, ui_group=None): - super().__init__(ui_group=ui_group) - self.font = get_font(fontsize) - self.color = Color("white") - - -def get_font(size=30): - """Create fonts when widgets are constructed, never at import time.""" - if not pg.font.get_init(): - pg.font.init() - return pg.font.Font(None, size) diff --git a/src/PygameUIKit/text_input.py b/src/PygameUIKit/text_input.py index 463d93a..00f2414 100644 --- a/src/PygameUIKit/text_input.py +++ b/src/PygameUIKit/text_input.py @@ -1,6 +1,7 @@ import pygame as pg -from .super_object import EasyObject, get_font +from .utils import get_font +from .widget import Widget COLOR_INACTIVE = pg.Color("lightskyblue3") COLOR_ACTIVE = pg.Color("dodgerblue2") @@ -13,9 +14,11 @@ def is_char(text): return bool(text) and text.isprintable() -class TextInput(EasyObject): +class TextInput(Widget): """Single-line Unicode input. Send both KEYDOWN and TEXTINPUT events to it.""" + cursor = pg.SYSTEM_CURSOR_IBEAM + def __init__( self, *, @@ -33,13 +36,12 @@ def __init__( self.color = COLOR_INACTIVE self.font_color = pg.Color(font_color) self.text = text - self.cursor = len(text) + self.caret = len(text) self.active = False self.font = font if font is not None else get_font() self.max_width = fixed_width self.border_radius = border_radius self.placeholder = placeholder - self.hover = False self.bg_trans = 0 self._scroll = 0 self._repeat_key = None @@ -48,6 +50,11 @@ def __init__( self.rect = pg.Rect(0, 0, fixed_width or 12, self.font.get_height() + 10) self._render() + @property + def event_priority(self): + # While focused, see clicks first so clicking another widget still unfocuses. + return int(self.active) + def handle_event(self, event): if event.type == pg.MOUSEBUTTONDOWN and event.button == 1: self.active = self.rect.collidepoint(event.pos) @@ -56,31 +63,31 @@ def handle_event(self, event): if self.active: pg.key.start_text_input() pg.key.set_text_input_rect(self.rect) - elif event.type == pg.MOUSEMOTION: - self.hover = self.rect.collidepoint(event.pos) + return self.active + if event.type == pg.MOUSEMOTION: + self.hovered = self.rect.collidepoint(event.pos) elif event.type == pg.WINDOWFOCUSLOST: self.active = False self.color = COLOR_INACTIVE self._repeat_key = None if not self.active: - return + return False if event.type == pg.TEXTINPUT: self.add_letter("".join(char for char in event.text if char.isprintable())) elif event.type == pg.KEYDOWN: if getattr(event, "repeat", False): - return + return True self.handle_key(event.key) if event.key in (pg.K_BACKSPACE, pg.K_DELETE, pg.K_LEFT, pg.K_RIGHT): self._repeat_key = event.key self._repeat_elapsed = 0 self._repeat_delay = TIME_OUT_FIRST / 1000 - elif event.type == pg.KEYUP and event.key == self._repeat_key: - self._repeat_key = None - - # Keep the original batch entry point; Group uses handle_event directly. - def handle_events(self, events): - for event in events: - self.handle_event(event) + elif event.type == pg.KEYUP: + if event.key == self._repeat_key: + self._repeat_key = None + else: + return False + return True def update(self, dt): if not self.active or self._repeat_key is None: @@ -92,37 +99,37 @@ def update(self, dt): self.handle_key(self._repeat_key) def add_letter(self, text): - self.text = self.text[: self.cursor] + text + self.text[self.cursor :] - self.cursor += len(text) + self.text = self.text[: self.caret] + text + self.text[self.caret :] + self.caret += len(text) self._render() def handle_key(self, key): if key == pg.K_BACKSPACE: self.remove_letter() elif key == pg.K_DELETE: - self.text = self.text[: self.cursor] + self.text[self.cursor + 1 :] + self.text = self.text[: self.caret] + self.text[self.caret + 1 :] elif key == pg.K_LEFT: - self.cursor = max(0, self.cursor - 1) + self.caret = max(0, self.caret - 1) elif key == pg.K_RIGHT: - self.cursor = min(len(self.text), self.cursor + 1) + self.caret = min(len(self.text), self.caret + 1) elif key == pg.K_HOME: - self.cursor = 0 + self.caret = 0 elif key == pg.K_END: - self.cursor = len(self.text) + self.caret = len(self.text) self._render() def remove_letter(self): - if self.cursor: - self.text = self.text[: self.cursor - 1] + self.text[self.cursor :] - self.cursor -= 1 + if self.caret: + self.text = self.text[: self.caret - 1] + self.text[self.caret :] + self.caret -= 1 self._render() def _render(self): - self.cursor = max(0, min(len(self.text), self.cursor)) + self.caret = max(0, min(len(self.text), self.caret)) self.txt_surface = self.font.render(self.text, True, self.font_color) if self.max_width is None: self.rect.width = max(12, self.font.size(self.text or self.placeholder)[0] + 10) - cursor_x = self.font.size(self.text[: self.cursor])[0] + cursor_x = self.font.size(self.text[: self.caret])[0] visible_width = self.rect.width - 11 self._scroll = max(0, min(self._scroll, cursor_x)) self._scroll = max(self._scroll, cursor_x - visible_width) @@ -130,7 +137,7 @@ def _render(self): def draw(self, screen, x, y): self.rect.topleft = (x, y) - self.bg_trans = 50 if self.hover and not self.active else 0 + self.bg_trans = 50 if self.hovered and not self.active else 0 surface = pg.Surface(self.rect.size, pg.SRCALPHA) pg.draw.rect( surface, @@ -147,7 +154,7 @@ def draw(self, screen, x, y): else: self.draw_placeholder(surface) if self.active: - cursor_x = 5 + self.font.size(self.text[: self.cursor])[0] - self._scroll + cursor_x = 5 + self.font.size(self.text[: self.caret])[0] - self._scroll pg.draw.line(surface, self.font_color, (cursor_x, 5), (cursor_x, self.rect.height - 6)) pg.key.set_text_input_rect(self.rect) screen.blit(surface, self.rect) @@ -162,5 +169,5 @@ def get_text(self): def set_text(self, text): self.text = text - self.cursor = len(text) + self.caret = len(text) self._render() diff --git a/src/PygameUIKit/utilis.py b/src/PygameUIKit/utils.py similarity index 85% rename from src/PygameUIKit/utilis.py rename to src/PygameUIKit/utils.py index 4bd60a2..b0a4350 100644 --- a/src/PygameUIKit/utilis.py +++ b/src/PygameUIKit/utils.py @@ -41,7 +41,7 @@ def load_image(path, size=None) -> pg.Surface: return image -def ligther_color(color: Color, strength: int = 50) -> Color: +def lighter_color(color: Color, strength: int = 50) -> Color: return Color(color.r, color.g, color.b, max(0, min(255, color.a - strength))) @@ -51,3 +51,10 @@ def set_system_cursor(cursor): pg.mouse.set_cursor(cursor) except pg.error: pass + + +def get_font(size: int = 30) -> pg.font.Font: + """Create fonts when widgets are constructed, never at import time.""" + if not pg.font.get_init(): + pg.font.init() + return pg.font.Font(None, size) diff --git a/src/PygameUIKit/widget.py b/src/PygameUIKit/widget.py new file mode 100644 index 0000000..a713e7d --- /dev/null +++ b/src/PygameUIKit/widget.py @@ -0,0 +1,75 @@ +import pygame as pg + +from . import utils + + +class Widget: + """Base class for every widget. + + Subclasses draw themselves with ``draw(surface, x, y, ...)`` each frame and store the + drawn area in ``rect``. Pass ``ui_group=group`` to receive events through a ``Group``. + """ + + cursor: int | None = None + event_priority: int = 0 + + def __init__(self, *, ui_group: "Group | None" = None): + self.rect = pg.Rect(0, 0, 0, 0) + self.hovered = False + if ui_group is not None: + ui_group.add(self) + + def handle_event(self, event: pg.event.Event) -> bool: + """Handle one event; return True if it was consumed.""" + return False + + def update(self, dt: float) -> None: + """Advance animations by ``dt`` seconds.""" + + def draw(self, surface: pg.Surface, x: int, y: int) -> None: + raise NotImplementedError + + +class Group: + """Forwards events and updates to widgets and sets the mouse cursor. + + ``ui = Group()``; create widgets with ``ui_group=ui``; call ``ui.handle_event(event)`` + for every event and ``ui.update(dt)`` every frame. Draw each widget yourself. + """ + + def __init__(self, *widgets: Widget): + self.widgets: list[Widget] = [] + self._cursor: int | None = None + for widget in widgets: + self.add(widget) + + def add(self, widget: Widget) -> None: + if widget not in self.widgets: + self.widgets.append(widget) + + def remove(self, widget: Widget) -> None: + self.widgets.remove(widget) + + def _by_priority(self) -> list[Widget]: + # Open menus receive events before the widgets underneath them. + return sorted(self.widgets, key=lambda widget: widget.event_priority, reverse=True) + + def handle_event(self, event: pg.event.Event) -> bool: + """Dispatch until a widget consumes the event; return True if one did.""" + consumed = any(widget.handle_event(event) for widget in self._by_priority()) + if event.type == pg.MOUSEMOTION: + self._update_cursor() + return consumed + + def update(self, dt: float) -> None: + for widget in tuple(self.widgets): + widget.update(dt) + + def _update_cursor(self) -> None: + cursor = next( + (w.cursor for w in self._by_priority() if w.hovered and w.cursor is not None), + pg.SYSTEM_CURSOR_ARROW, + ) + if cursor != self._cursor: + self._cursor = cursor + utils.set_system_cursor(cursor) diff --git a/src/assets/play.png b/src/assets/play.png deleted file mode 100644 index f89a8a5..0000000 Binary files a/src/assets/play.png and /dev/null differ diff --git a/src/assets/stop.png b/src/assets/stop.png deleted file mode 100644 index d07081e..0000000 Binary files a/src/assets/stop.png and /dev/null differ diff --git a/src/demo.py b/src/demo.py deleted file mode 100644 index a13082d..0000000 --- a/src/demo.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Compatibility entry point; prefer `uv run pygameuikit-demo`.""" - -from PygameUIKit.demo import main - -if __name__ == "__main__": - main() diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 27310a9..713b392 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -4,11 +4,11 @@ import pygame as pg import pytest -from PygameUIKit import Group, button, slider, text_input +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.utilis import ligther_color, load_image, make_surface_darker +from PygameUIKit.utils import lighter_color, load_image, make_surface_darker def mouse(kind, pos=(20, 20), button_number=1): @@ -81,7 +81,7 @@ def test_text_input_through_group_unicode_editing_and_focus(display): group.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_e, unicode="é")) group.handle_event(pg.event.Event(pg.TEXTINPUT, text="é世")) assert widget.get_text() == "abé世" - assert widget.cursor == 4 + assert widget.caret == 4 group.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_LEFT)) group.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_BACKSPACE)) assert widget.get_text() == "ab世" @@ -192,7 +192,7 @@ def test_color_adjustment_clamps_and_preserves_alpha(): 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) - assert ligther_color(pg.Color(0, 0, 0, 10), 50).a == 0 + assert lighter_color(pg.Color(0, 0, 0, 10), 50).a == 0 def test_image_loading_without_display_mode(tmp_path): @@ -210,19 +210,98 @@ def test_alignment_requires_bounds(alignment): Label("Hello", "white", text_align=alignment) -def test_group_is_stable_when_callback_removes_widget(display): +def test_group_stops_at_first_consumer_and_survives_removal(display): group = Group() - first = button.ButtonText("First", ui_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.connect(lambda: group.remove(first)) first.draw(display, 10, 10) second.draw(display, 10, 10) + assert group.handle_event(mouse(pg.MOUSEBUTTONDOWN)) + assert group.handle_event(mouse(pg.MOUSEBUTTONUP)) + assert calls == [] + assert group.widgets == [second] group.handle_event(mouse(pg.MOUSEBUTTONDOWN)) group.handle_event(mouse(pg.MOUSEBUTTONUP)) assert calls == [True] group.add(second) - assert group.objects == [second] + assert group.widgets == [second] + + +def test_handle_event_reports_consumption(display): + btn = button.ButtonText("Click") + btn.draw(display, 10, 10) + assert not btn.handle_event(mouse(pg.MOUSEMOTION)) + assert not btn.handle_event(mouse(pg.MOUSEBUTTONDOWN, (500, 500))) + assert btn.handle_event(mouse(pg.MOUSEBUTTONDOWN)) + assert btn.handle_event(mouse(pg.MOUSEBUTTONUP)) + assert not btn.handle_event(mouse(pg.MOUSEBUTTONUP)) + + bar = slider.Slider(0, 10, 1) + bar.draw(display, 100, 100, 200, 5) + assert not bar.handle_event(mouse(pg.MOUSEBUTTONDOWN, (500, 500))) + assert bar.handle_event(mouse(pg.MOUSEBUTTONDOWN, (150, 102))) + assert not bar.handle_event(mouse(pg.MOUSEMOTION, (160, 102))) + assert bar.handle_event(mouse(pg.MOUSEBUTTONUP, (160, 102))) + assert not bar.handle_event(mouse(pg.MOUSEBUTTONUP, (160, 102))) + + field = text_input.TextInput(fixed_width=100) + field.draw(display, 10, 10) + assert not field.handle_event(pg.event.Event(pg.TEXTINPUT, text="x")) + assert field.handle_event(mouse(pg.MOUSEBUTTONDOWN)) + assert field.handle_event(pg.event.Event(pg.TEXTINPUT, text="x")) + 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])): + widget.draw(display, 10, 10) + assert not widget.handle_event(mouse(pg.MOUSEBUTTONDOWN)) + + +def test_clicking_a_button_unfocuses_text_input(display): + group = Group() + calls = [] + btn = button.ButtonText("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) + group.handle_event(mouse(pg.MOUSEBUTTONDOWN, (20, 110))) + assert field.active + group.handle_event(mouse(pg.MOUSEBUTTONDOWN)) + group.handle_event(mouse(pg.MOUSEBUTTONUP)) + assert not field.active + assert calls == [True] + + +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) + field = text_input.TextInput(fixed_width=100, ui_group=group) + Label("Label", "white", ui_group=group).draw(display, 10, 200) + btn.draw(display, 10, 10) + field.draw(display, 10, 100) + + btn.handle_event(mouse(pg.MOUSEMOTION)) + assert cursors == [] + group.handle_event(mouse(pg.MOUSEMOTION)) + group.handle_event(mouse(pg.MOUSEMOTION, (21, 21))) + assert cursors == [pg.SYSTEM_CURSOR_HAND] + group.handle_event(mouse(pg.MOUSEMOTION, (20, 110))) + group.handle_event(mouse(pg.MOUSEMOTION, (20, 205))) + group.handle_event(mouse(pg.MOUSEBUTTONDOWN, (20, 205))) + assert cursors == [pg.SYSTEM_CURSOR_HAND, pg.SYSTEM_CURSOR_IBEAM, pg.SYSTEM_CURSOR_ARROW] + + +def test_widget_base_defaults(): + group = Group() + widget = Widget(ui_group=group) + assert group.widgets == [widget] + assert widget.rect == pg.Rect(0, 0, 0, 0) + assert not widget.hovered and widget.cursor is None + assert not group.handle_event(mouse(pg.MOUSEBUTTONDOWN)) + group.update(0.1) def test_dropdown_consumes_clicks_over_other_widgets(display):