Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 42 additions & 3 deletions src/PygameUIKit/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
8 changes: 5 additions & 3 deletions src/PygameUIKit/barchart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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],
Expand Down
77 changes: 28 additions & 49 deletions src/PygameUIKit/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
Expand Down
9 changes: 4 additions & 5 deletions src/PygameUIKit/combobox.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@
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

cwd = os.path.dirname(__file__)


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__(
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/PygameUIKit/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

#
#
# This is a file to showcase every EasyObject
# This is a file to showcase every widget
#
#

Expand Down
15 changes: 7 additions & 8 deletions src/PygameUIKit/label.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Loading
Loading