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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
- `Group` updates the cursor after every event and `update(dt)`, not only after mouse motion, so disabling or hiding the widget under the pointer restores the arrow right away.
- The demo (`pygameuikit-demo`) is rewritten as a showcase: one card per widget family with its main options and API, an event log that shows each callback as it fires, and a States card that disables or hides a set of widgets. `--screenshot PATH` saves a frame as a PNG and exits without opening a window.
- The README has a short guide to the main loop and a reference for every widget.
- Add `Checkbox(text="", *, value=False, on_change=None, color, text_color, font=None)`: a box with an antialiased check mark and an optional label; clicking the box or the label toggles `value` and calls `on_change(value)`.
- Add `RadioGroup(options, *, index=0, on_change=None, direction="vertical", spacing=10, color, text_color, font=None)`: one widget that draws a list of radio options, vertically or horizontally, with `value` and `index` like `Dropdown` and a highlight on the hovered option. `on_change(value)` runs when the user picks a different option.
- Add `ProgressBar(value=0.0, *, max_value=1.0, color, track_color, border_radius=None, show_text=False, font=None, text_color)`: a display-only bar with a clamped `value`, `fraction` and an optional percentage, drawn with `draw(surface, x, y, width=200, height=12)`.
- Add `utils.draw_rounded_rect(surface, color, rect, radius, *, outline_color=None, outline_width=1)`, a filled rectangle with antialiased corners and an optional outline. Buttons, dropdowns, text inputs, sliders, icon-button backdrops and the new widgets use it, so rounded corners are smooth instead of jagged.
- The demo has Checkbox and RadioGroup and ProgressBar cards: checkboxes animate the chart (in sync with the IconToggle) and show its values, a horizontal RadioGroup sets the bar speed, and two progress bars loop and follow the Integer slider. The States card also toggles the new widgets. The window is now 1200×860.

### Upgrading from 0.2

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PygameUIKit

Small UI widgets for Pygame: buttons, sliders, dropdowns, text inputs, labels, and animated bar charts. You control the layout; the kit handles interaction and drawing.
Small UI widgets for Pygame: buttons, checkboxes, radio buttons, sliders, dropdowns, text inputs, labels, progress bars, and animated bar charts. You control the layout; the kit handles interaction and drawing.

![The PygameUIKit demo: cards showing every widget and an event log](https://raw.githubusercontent.com/Times0/PygameUIKit/master/images/img.png)

Expand Down Expand Up @@ -83,10 +83,13 @@ Options after `*` are keyword-only, and every widget also takes `ui_group`.
- `ImageButton(image, on_click=None, *, hover_image=None, text="", text_color="white", font=None)`. An image, swapped for `hover_image` while hovered or pressed, with optional centered `text`.
- `IconButton(image, on_click=None, *, hover_color=(128, 128, 128), hover_alpha=128, padding=6, border_radius=6)`. An icon with a translucent backdrop while hovered. The clickable area is the icon plus `padding`, starting at (x, y).
- `IconToggle(image_off, image_on, on_change=None, *, value=False, ...)`. Like `IconButton`, but each click switches `value` and the icon. Callback: `on_change(value)` with the new bool.
- `Checkbox(text="", *, value=False, on_change=None, color=(59, 130, 246), text_color="black", font=None)`. A box with a check mark and an optional label to its right; clicking either toggles `value`. Properties: `value`, `text`. Callback: `on_change(value)` with the new bool.
- `RadioGroup(options, *, index=0, on_change=None, direction="vertical", spacing=10, color=(59, 130, 246), text_color="black", font=None)`. One widget that draws every option with a radio circle, stacked or in a row (`direction="horizontal"`); `spacing` is the gap between options. Click an option or its circle to select it. `rect` covers all options. Properties: `value` (the option), `index`, `highlighted` (the hovered option's index or `None`). Callback: `on_change(value)` when the user picks a different option.
- `Slider(min, max, step=1, *, value=None, on_change=None, show_value=False, font=None, fill_color, track_color, knob_color, text_color)`. `draw(surface, x, y, width=200, height=6)`. Drag or click the track; Left/Right, Home and End work while it is hovered. `value` is clamped and snapped to `step`, and `show_value` prints it to the right of the track. Callback: `on_change(value)`.
- `TextInput(text="", *, placeholder="", width=200, font=None, text_color, placeholder_color, background_color, border_color, focus_color, border_radius=4, on_change=None, on_submit=None)`. A fixed-width, single-line field. Click it to focus it; Enter calls `on_submit(text)` and Escape unfocuses. Properties: `text`, `focused`. Callbacks: `on_change(text)` after each edit, `on_submit(text)`. It reads `TEXTINPUT` events for Unicode text and `KEYDOWN` for editing, so forward both, once, through the group.
- `Dropdown(options, *, index=0, on_change=None, width=None, font=None, color, text_color, highlight_color, outline_color, border_radius=6)`. Click to open the menu, then pick with the mouse or with Up/Down and Enter. Escape or a click elsewhere closes it. Properties: `value` (the option), `index`, `is_open`. Callback: `on_change(value)` when the user picks a different option.
- `BarChart(values, *, labels=None, max_value=None, colors=None, font=None, label_color="black", speed=200)`. `draw(surface, x, y, width=300, height=100)`. Bars move toward their values at `speed` units per second during `ui.update(dt)`. `set_value(index, value)` changes one bar; setting `values` replaces them all. Values must be non-negative. Without `labels`, each bar shows its value; without `max_value`, the tallest bar fills the height. `colors` cycle.
- `ProgressBar(value=0.0, *, max_value=1.0, color=(59, 130, 246), track_color=(220, 223, 228), border_radius=None, show_text=False, font=None, text_color="black")`. `draw(surface, x, y, width=200, height=12)`. Display only: it ignores events and needs no group. `value` is clamped to 0..`max_value`; `fraction` is how full the bar is (0 to 1). `border_radius=None` rounds the ends fully, and `show_text` prints the percentage (`percent_text`) to the right of the bar.

## Develop

Expand Down
Binary file modified images/img.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion src/PygameUIKit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,37 @@
"""Composable Pygame UI widgets with explicit drawing and event dispatch."""

from . import barchart, button, dropdown, label, slider, text_input, utils, widget
from . import barchart, button, choice, dropdown, label, progress, slider, text_input, utils, widget
from .barchart import BarChart
from .button import Button, IconButton, IconToggle, ImageButton
from .choice import Checkbox, RadioGroup
from .dropdown import Dropdown
from .label import Label
from .progress import ProgressBar
from .slider import Slider
from .text_input import TextInput
from .widget import Group, Widget

__all__ = [
"BarChart",
"Button",
"Checkbox",
"Dropdown",
"Group",
"IconButton",
"IconToggle",
"ImageButton",
"Label",
"ProgressBar",
"RadioGroup",
"Slider",
"TextInput",
"Widget",
"barchart",
"button",
"choice",
"dropdown",
"label",
"progress",
"slider",
"text_input",
"utils",
Expand Down
13 changes: 9 additions & 4 deletions src/PygameUIKit/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,15 @@ def draw(self, surface: pg.Surface, x: int, y: int) -> None:
fill = utils.shade(self.color, 0.12)
else:
fill = self.color
pg.draw.rect(surface, self._paint(fill), self.rect, border_radius=self.border_radius)
if self.outline_color is not None:
outline = self._paint(self.outline_color)
pg.draw.rect(surface, outline, self.rect, 2, border_radius=self.border_radius)
outline = None if self.outline_color is None else self._paint(self.outline_color)
utils.draw_rounded_rect(
surface,
self._paint(fill),
self.rect,
self.border_radius,
outline_color=outline,
outline_width=2,
)
self.label.color = self._text_color()
self.label.enabled = self.enabled
pad_x = self.padding[0]
Expand Down
241 changes: 241 additions & 0 deletions src/PygameUIKit/choice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
from collections.abc import Callable, Sequence

import pygame as pg
from pygame.typing import ColorLike

from . import utils
from .button import _Clickable
from .label import Label
from .widget import Group, Widget

GAP = 8
BORDER = (170, 170, 170)
DIRECTIONS = ("vertical", "horizontal")


def _mark_size(font: pg.font.Font) -> int:
"""The side of a checkbox and the diameter of a radio circle for ``font``."""
return font.get_height() + 2


class Checkbox(_Clickable):
"""A box with a check mark and an optional label; clicking either toggles ``value``.

``sound = Checkbox("Sound", value=True, on_change=set_sound, ui_group=ui)``;
``sound.draw(screen, x, y)`` every frame. ``on_change(value)`` gets the new bool; setting
``value`` does not call it.
"""

def __init__(
self,
text: str = "",
*,
value: bool = False,
on_change: Callable[[bool], None] | None = None,
color: ColorLike = (59, 130, 246),
text_color: ColorLike = "black",
font: pg.font.Font | None = None,
ui_group: Group | None = None,
):
self.value = bool(value)
self.on_change = on_change
self.color = pg.Color(color)
self.font = font if font is not None else utils.get_font(24)
self.label = Label(text, text_color, font=self.font)
super().__init__(None, ui_group=ui_group)
self._layout(0, 0)

@property
def text(self) -> str:
return self.label.text

@text.setter
def text(self, value: str) -> None:
self.label.text = value

@property
def text_color(self) -> pg.Color:
return self.label.color

@text_color.setter
def text_color(self, value: ColorLike) -> None:
self.label.color = value

def _activate(self) -> None:
self.value = not self.value
if self.on_change is not None:
self.on_change(self.value)

def _layout(self, x: int, y: int) -> pg.Rect:
"""Set ``rect`` for a draw at (x, y) and return the box."""
size = _mark_size(self.font)
width, height = size, size
if self.text:
text_width, text_height = self.label.surface.get_size()
width, height = size + GAP + text_width, max(size, text_height)
self.rect = pg.Rect(x, y, width, height)
return pg.Rect(x, y + (height - size) // 2, size, size)

def draw(self, surface: pg.Surface, x: int, y: int) -> None:
if not self.visible:
return
box = self._layout(x, y)
radius = max(2, box.w // 5)
if self.value:
fill = utils.shade(self.color, 0.25 if self.pressed else 0.12 if self.hovered else 0)
utils.draw_rounded_rect(surface, self._paint(fill), box, radius)
self._draw_check(surface, box, self._paint(utils.best_contrast_color(self.color)))
else:
fill = utils.shade("white", 0.08) if self.pressed else pg.Color("white")
border = self.color if self.hovered or self.pressed else pg.Color(BORDER)
paint = self._paint
utils.draw_rounded_rect(
surface, paint(fill), box, radius, outline_color=paint(border), outline_width=2
)
if self.text:
self.label.enabled = self.enabled
label_y = self.rect.centery - self.label.surface.get_height() // 2
self.label.draw(surface, box.right + GAP, label_y)

@staticmethod
def _draw_check(surface: pg.Surface, box: pg.Rect, color: pg.Color) -> None:
# Drawn four times larger and scaled down, which smooths the edges and the joint.
scale = 4
size = box.w * scale
mark = pg.Surface((size, size), pg.SRCALPHA)
mark.fill((color.r, color.g, color.b, 0)) # no dark fringe when scaled
points = [(0.26 * size, 0.5 * size), (0.43 * size, 0.67 * size), (0.75 * size, 0.33 * size)]
width = max(2 * scale, round(size / 6))
pg.draw.lines(mark, color, False, points, width)
for point in points:
pg.draw.circle(mark, color, point, width / 2)
surface.blit(pg.transform.smoothscale(mark, box.size), box)


class RadioGroup(Widget):
"""A list of options with a radio circle each, of which exactly one is selected.

``speed = RadioGroup(["Slow", "Fast"], on_change=set_speed, ui_group=ui)``;
``speed.draw(screen, x, y)`` every frame. Click an option or its circle to select it.
``on_change(value)`` runs when the user picks a different option; setting ``value`` or
``index`` does not call it. ``direction="horizontal"`` lays the options out in a row.
"""

cursor = pg.SYSTEM_CURSOR_HAND

def __init__(
self,
options: Sequence[str],
*,
index: int = 0,
on_change: Callable[[str], None] | None = None,
direction: str = "vertical",
spacing: int = 10,
color: ColorLike = (59, 130, 246),
text_color: ColorLike = "black",
font: pg.font.Font | None = None,
ui_group: Group | None = None,
):
if not options:
raise ValueError("RadioGroup requires at least one option")
if not 0 <= index < len(options):
raise IndexError("RadioGroup index out of range")
if direction not in DIRECTIONS:
raise ValueError(f"direction must be one of {DIRECTIONS}, not {direction!r}")
if spacing < 0:
raise ValueError("spacing must not be negative")
self.options = tuple(options)
self._index = index
self.on_change = on_change
self.direction = direction
self.spacing = spacing
self.color = pg.Color(color)
self.text_color = pg.Color(text_color)
self.font = font if font is not None else utils.get_font(24)
self.highlighted: int | None = None
self._pressed: int | None = None
self._labels = [Label(option, font=self.font) for option in self.options]
super().__init__(ui_group=ui_group)
self._layout(0, 0)

@property
def index(self) -> int:
return self._index

@index.setter
def index(self, value: int) -> None:
if not 0 <= value < len(self.options):
raise IndexError("RadioGroup index out of range")
self._index = value

@property
def value(self) -> str:
return self.options[self._index]

@value.setter
def value(self, value: str) -> None:
if value not in self.options:
raise ValueError(f"{value!r} is not one of the options")
self._index = self.options.index(value)

def _layout(self, x: int, y: int) -> None:
"""Set ``rect`` and the clickable row of each option for a draw at (x, y)."""
size = _mark_size(self.font)
self._rows: list[pg.Rect] = []
for option in self.options:
text_width, text_height = self.font.size(option)
row = pg.Rect(x, y, size + GAP + text_width, max(size, text_height))
self._rows.append(row)
if self.direction == "vertical":
y = row.bottom + self.spacing
else:
x = row.right + self.spacing
self.rect = self._rows[0].unionall(self._rows[1:])

def _option_at(self, pos: tuple[int, int]) -> int | None:
return next((i for i, row in enumerate(self._rows) if row.collidepoint(pos)), None)

def _reset(self) -> None:
super()._reset()
self.highlighted = None
self._pressed = None

def handle_event(self, event: pg.event.Event) -> bool:
if not self.interactive:
return False
if event.type == pg.WINDOWFOCUSLOST:
self._reset()
elif event.type == pg.MOUSEMOTION:
self.highlighted = self._option_at(event.pos)
self.hovered = self.highlighted is not None
elif event.type == pg.MOUSEBUTTONDOWN and event.button == 1:
self._pressed = self._option_at(event.pos)
return self._pressed is not None
elif event.type == pg.MOUSEBUTTONUP and event.button == 1 and self._pressed is not None:
pressed, self._pressed = self._pressed, None
if self._option_at(event.pos) == pressed:
if pressed != self._index:
self._index = pressed
if self.on_change is not None:
self.on_change(self.value)
return True
return False

def draw(self, surface: pg.Surface, x: int, y: int) -> None:
if not self.visible:
return
self._layout(x, y)
size = _mark_size(self.font)
radius = size // 2
for i, (row, label) in enumerate(zip(self._rows, self._labels, strict=True)):
center = (row.x + radius, row.centery)
selected = i == self._index
border = self.color if selected or i == self.highlighted else pg.Color(BORDER)
fill = utils.shade("white", 0.08) if i == self._pressed else pg.Color("white")
pg.draw.aacircle(surface, self._paint(border), center, radius)
pg.draw.aacircle(surface, self._paint(fill), center, radius - 2)
if selected:
dot = utils.shade(self.color, 0.12) if i == self.highlighted else self.color
pg.draw.aacircle(surface, self._paint(dot), center, radius // 2)
label.color, label.enabled = self.text_color, self.enabled
label.draw(surface, row.x + size + GAP, row.centery - label.surface.get_height() // 2)
Loading
Loading