diff --git a/CHANGELOG.md b/CHANGELOG.md index e0caf57..52f226b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 94b2e38..2620ad2 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/images/img.png b/images/img.png index 9c7ed1d..e0b04db 100644 Binary files a/images/img.png and b/images/img.png differ diff --git a/src/PygameUIKit/__init__.py b/src/PygameUIKit/__init__.py index 1899717..2128fab 100644 --- a/src/PygameUIKit/__init__.py +++ b/src/PygameUIKit/__init__.py @@ -1,10 +1,12 @@ """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 @@ -12,19 +14,24 @@ __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", diff --git a/src/PygameUIKit/button.py b/src/PygameUIKit/button.py index 74319c6..08dd88f 100644 --- a/src/PygameUIKit/button.py +++ b/src/PygameUIKit/button.py @@ -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] diff --git a/src/PygameUIKit/choice.py b/src/PygameUIKit/choice.py new file mode 100644 index 0000000..cf6a8cf --- /dev/null +++ b/src/PygameUIKit/choice.py @@ -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) diff --git a/src/PygameUIKit/demo.py b/src/PygameUIKit/demo.py index f10b5ff..314742a 100644 --- a/src/PygameUIKit/demo.py +++ b/src/PygameUIKit/demo.py @@ -18,17 +18,21 @@ from PygameUIKit import ( BarChart, Button, + Checkbox, Dropdown, Group, IconButton, IconToggle, ImageButton, Label, + ProgressBar, + RadioGroup, Slider, TextInput, + utils, ) -SIZE = (1100, 800) +SIZE = (1200, 860) ASSETS = Path(__file__).parent / "assets" BACKGROUND = (241, 243, 246) @@ -44,7 +48,11 @@ MARGIN, GAP, PAD = 24, 16, 16 HEADER = 60 COLUMNS = 3 -ROWS = (202, 158, 124, 184) # card heights, top to bottom +ROWS = (202, 158, 176, 176) # card heights, top to bottom + +DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] +SPEEDS = {"Slow": 50, "Normal": 200, "Fast": 800} # BarChart speed, in values per second +LOADING_SECONDS = 3 PALETTES = { "Ocean": ["#0e7490", "#0891b2", "#06b6d4", "#22d3ee", "#67e8f9", "#0369a1"], @@ -74,8 +82,7 @@ class Card: draw_content: Callable[[pg.Surface, pg.Rect], None] def draw(self, surface: pg.Surface, title_font: pg.font.Font, code_font: pg.font.Font): - pg.draw.rect(surface, CARD, self.rect, border_radius=10) - pg.draw.rect(surface, BORDER, self.rect, 1, border_radius=10) + utils.draw_rounded_rect(surface, CARD, self.rect, 10, outline_color=BORDER) x, y = self.rect.x + PAD, self.rect.y + PAD surface.blit(title_font.render(self.title, True, INK), (x, y)) y += title_font.get_height() + 4 @@ -91,7 +98,8 @@ def badge(size: tuple[int, int], top: pg.typing.ColorLike, bottom: pg.typing.Col gradient.set_at((0, 1), bottom) image = pg.transform.smoothscale(gradient, size) mask = pg.Surface(size, pg.SRCALPHA) - pg.draw.rect(mask, "white", mask.get_rect(), border_radius=size[1] // 2) + mask.fill((255, 255, 255, 0)) + utils.draw_rounded_rect(mask, "white", mask.get_rect(), size[1] // 2) image.blit(mask, (0, 0), special_flags=pg.BLEND_RGBA_MIN) return image @@ -209,7 +217,7 @@ def __init__(self): # BarChart self.chart = BarChart( [40, 75, 30, 90, 55, 65], - labels=["Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + labels=DAYS, max_value=100, colors=PALETTES[self.palette.value], font=self.small_font, @@ -219,14 +227,40 @@ def __init__(self): self.randomize = Button("Randomize", self.on_randomize, font=font, ui_group=ui) self.chart_hints = [ Label(text, MUTED, font=self.small_font) - for text in ( - "Press R (ignored while typing)", - "Animate with the IconToggle", - "Recolor with the Dropdown", - ) + for text in ("Or press R (not while typing)", "Recolor with the Dropdown") ] self.animation_timer = 0.0 + # Checkbox and RadioGroup + self.animate_box = Checkbox( + "Animate chart", on_change=self.on_animate_box, font=font, ui_group=ui + ) + self.values_box = Checkbox( + "Show values", on_change=self.on_values_box, font=font, ui_group=ui + ) + self.speed = RadioGroup( + list(SPEEDS), + index=1, + on_change=self.on_speed, + direction="horizontal", + spacing=16, + font=font, + ui_group=ui, + ) + self.speed_label = Label("Bar speed", INK, font=font) + self.choices_hint = Label( + "The IconToggle and the Checkbox stay in sync", MUTED, font=self.small_font + ) + + # ProgressBar: display only, so it needs no group. + self.loading = ProgressBar(show_text=True, font=font) + self.loading_time = 1.9 # seconds into the loop, so the first frame shows progress + self.slider_progress = ProgressBar(max_value=100, color=GREEN, show_text=True, font=font) + self.progress_labels = [Label(text, INK, font=font) for text in ("Loading", "Slider")] + self.progress_hint = Label( + "The second bar follows the Integer slider", MUTED, font=self.small_font + ) + # States: every widget has ``enabled`` and ``visible``. Two buttons flip them for # these widgets, at least one from each interactive card. self.showcase = [ @@ -237,6 +271,9 @@ def __init__(self): self.int_slider, self.text_input, self.palette, + self.animate_box, + self.speed, + self.loading, ] state_width = (cell(2, 2).w - 2 * PAD - 8) // 2 self.disable = Button( @@ -251,6 +288,9 @@ def __init__(self): font=font, ui_group=ui, ) + self.states_hint = Label( + "Toggles widgets in every interactive card", MUTED, font=self.small_font + ) self.log_empty = Label("Interact with a widget...", MUTED, font=self.code_font) @@ -263,11 +303,13 @@ def __init__(self): Card("Slider", "Slider(0, 100, on_change=...)", cell(0, 1), self.draw_sliders), Card("TextInput", "TextInput(on_submit=...)", cell(1, 1), self.draw_text_input), Card( - "BarChart", - "BarChart(values, labels=..., max_value=100)", - cell(0, 2, 2, 2), - self.draw_chart, + "Checkbox and RadioGroup", + "Checkbox(text) / RadioGroup(options)", + cell(0, 2), + self.draw_choices, ), + Card("ProgressBar", "ProgressBar(value, max_value=1)", cell(0, 3), self.draw_progress), + Card("BarChart", "BarChart(values, labels=...)", cell(1, 2, 1, 2), self.draw_chart), Card("States", "widget.enabled / widget.visible", cell(2, 2), self.draw_states), Card("Event log", "on_click() / on_change(value)", cell(2, 3), self.draw_log), Card("Dropdown", "Dropdown(options, on_change=...)", cell(2, 1), self.draw_dropdown), @@ -294,8 +336,21 @@ def on_icon_click(self) -> None: self.log("IconButton.on_click()") def on_animate(self, value: bool) -> None: + self.animate_box.value = value # setting a value never calls on_change self.log(f"IconToggle.on_change({value})") + def on_animate_box(self, value: bool) -> None: + self.animate.value = value + self.log(f"Checkbox.on_change({value})") + + def on_values_box(self, value: bool) -> None: + self.chart.labels = None if value else DAYS # without labels, bars show their values + self.log(f"Checkbox.on_change({value})") + + def on_speed(self, value: str) -> None: + self.chart.speed = SPEEDS[value] + self.log(f"RadioGroup.on_change({value!r})") + def on_submit(self, text: str) -> None: self.submitted.text = f"Submitted: {text}" self.log(f"TextInput.on_submit({text!r})") @@ -349,6 +404,10 @@ def handle_event(self, event: pg.event.Event) -> None: def update(self, dt: float) -> None: self.ui.update(dt) # animates the chart and repeats held keys in the text input + # The loading bar fills in LOADING_SECONDS, stays full (value clamps) and restarts. + self.loading_time = (self.loading_time + dt) % (LOADING_SECONDS + 1) + self.loading.value = self.loading_time / LOADING_SECONDS + self.slider_progress.value = self.int_slider.value if self.animate.value: self.animation_timer += dt if self.animation_timer >= 0.2: @@ -389,7 +448,7 @@ def draw_icons(self, surface: pg.Surface, area: pg.Rect) -> None: def draw_labels(self, surface: pg.Surface, area: pg.Rect) -> None: box = pg.Rect(area.x, area.y, area.w, 3 * 26 + 8) - pg.draw.rect(surface, BACKGROUND, box, border_radius=6) + utils.draw_rounded_rect(surface, BACKGROUND, box, 6) for i, label in enumerate(self.aligned_labels): label.draw(surface, box.x + 10, box.y + 10 + i * 26, box.w - 20) self.label_hint.draw(surface, area.x, box.bottom + 10) @@ -400,7 +459,7 @@ def draw_sliders(self, surface: pg.Surface, area: pg.Rect) -> None: ): y = area.y + 8 + i * 32 label.draw(surface, area.x, y - label.rect.h // 2 + 3) - bar.draw(surface, area.x + 98, y, width=160) + bar.draw(surface, area.x + 98, y, width=area.w - 160) self.slider_hint.draw(surface, area.x, area.y + 66) def draw_text_input(self, surface: pg.Surface, area: pg.Rect) -> None: @@ -415,16 +474,34 @@ def draw_dropdown(self, surface: pg.Surface, area: pg.Rect) -> None: self.palette.draw(surface, area.x + 120, area.y) def draw_chart(self, surface: pg.Surface, area: pg.Rect) -> None: - chart_width = area.w - 240 - self.chart.draw(surface, area.x, area.y, chart_width, area.h - 24) - x = area.x + chart_width + 24 - self.randomize.draw(surface, x, area.y) + self.randomize.draw(surface, area.x, area.bottom - self.randomize.rect.h) + x = self.randomize.rect.right + 16 for i, hint in enumerate(self.chart_hints): - hint.draw(surface, x, self.randomize.rect.bottom + 14 + i * 22) + hint.draw(surface, x, self.randomize.rect.centery - 15 + i * 17) + self.chart.draw(surface, area.x, area.y, area.w, self.randomize.rect.y - area.y - 40) + + def draw_choices(self, surface: pg.Surface, area: pg.Rect) -> None: + self.animate_box.draw(surface, area.x, area.y) + self.values_box.draw(surface, area.x + area.w // 2, area.y) + y = self.animate_box.rect.bottom + 14 + self.speed_label.draw(surface, area.x, y) + self.speed.draw( + surface, area.x + 90, y + (self.speed_label.rect.h - self.speed.rect.h) // 2 + ) + self.choices_hint.draw(surface, area.x, area.bottom - self.choices_hint.rect.h) + + def draw_progress(self, surface: pg.Surface, area: pg.Rect) -> None: + bars = (self.loading, self.slider_progress) + for i, (label, bar) in enumerate(zip(self.progress_labels, bars, strict=True)): + y = area.y + i * 30 + label.draw(surface, area.x, y) + bar.draw(surface, area.x + 98, label.rect.centery - 6, width=area.w - 160) + self.progress_hint.draw(surface, area.x, area.bottom - self.progress_hint.rect.h) def draw_states(self, surface: pg.Surface, area: pg.Rect) -> None: self.disable.draw(surface, area.x, area.y) self.hide.draw(surface, self.disable.rect.right + 8, area.y) + self.states_hint.draw(surface, area.x, area.bottom - self.states_hint.rect.h) def draw_log(self, surface: pg.Surface, area: pg.Rect) -> None: if not self.events: diff --git a/src/PygameUIKit/dropdown.py b/src/PygameUIKit/dropdown.py index 7f6aa0d..f135891 100644 --- a/src/PygameUIKit/dropdown.py +++ b/src/PygameUIKit/dropdown.py @@ -185,10 +185,10 @@ def _text_color(self, highlighted: bool = False) -> pg.Color: return utils.best_contrast_color(self.color) def _draw_box(self, surface: pg.Surface, rect: pg.Rect, fill: pg.Color) -> None: - pg.draw.rect(surface, self._paint(fill), rect, border_radius=self.border_radius) - if self.outline_color is not None: - outline = self._paint(self.outline_color) - pg.draw.rect(surface, outline, rect, 1, 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), rect, self.border_radius, outline_color=outline + ) def draw(self, surface: pg.Surface, x: int, y: int) -> None: if not self.visible: @@ -222,7 +222,7 @@ def draw(self, surface: pg.Surface, x: int, y: int) -> None: else: fill = self.color if fill != self.color: - pg.draw.rect(surface, fill, row, border_radius=max(0, self.border_radius - 2)) + utils.draw_rounded_rect(surface, fill, row, self.border_radius - 2) option_label.color = self._text_color(i == self.highlighted) label_y = row.centery - option_label.surface.get_height() // 2 option_label.draw(surface, x + PAD_X, label_y) diff --git a/src/PygameUIKit/progress.py b/src/PygameUIKit/progress.py new file mode 100644 index 0000000..c687ce5 --- /dev/null +++ b/src/PygameUIKit/progress.py @@ -0,0 +1,88 @@ +import math + +import pygame as pg +from pygame.typing import ColorLike + +from . import utils +from .widget import Group, Widget + +TEXT_GAP = 8 + + +class ProgressBar(Widget): + """A horizontal bar filled to ``value / max_value``; it only displays, never takes input. + + ``loading = ProgressBar(max_value=total, show_text=True)``; set ``loading.value = done`` + and call ``loading.draw(screen, x, y, width=200, height=12)`` every frame. ``value`` is + clamped to 0..``max_value``; ``show_text`` prints the percentage right of the bar. + """ + + def __init__( + self, + value: float = 0.0, + *, + max_value: float = 1.0, + color: ColorLike = (59, 130, 246), + track_color: ColorLike = (220, 223, 228), + border_radius: int | None = None, + show_text: bool = False, + font: pg.font.Font | None = None, + text_color: ColorLike = "black", + ui_group: Group | None = None, + ): + if not math.isfinite(max_value) or max_value <= 0: + raise ValueError("max_value must be finite and positive") + if border_radius is not None and border_radius < 0: + raise ValueError("border_radius must not be negative") + self.max_value = max_value + self.value = value + self.color = pg.Color(color) + self.track_color = pg.Color(track_color) + self.border_radius = border_radius + self.show_text = show_text + self.font = font if font is not None else utils.get_font(24) + self.text_color = pg.Color(text_color) + super().__init__(ui_group=ui_group) + self.rect = pg.Rect(0, 0, 200, 12) + + @property + def value(self) -> float: + return self._value + + @value.setter + def value(self, value: float) -> None: + if not math.isfinite(value): + raise ValueError("ProgressBar value must be finite") + self._value = max(0, min(self.max_value, value)) + + @property + def fraction(self) -> float: + """How full the bar is, from 0 to 1.""" + return min(1.0, self._value / self.max_value) + + @property + def percent_text(self) -> str: + """The percentage shown by ``show_text``, such as ``"42%"``.""" + return f"{self.fraction:.0%}" + + def draw(self, surface: pg.Surface, x: int, y: int, width: int = 200, height: int = 12) -> None: + if not self.visible: + return + if width <= 0 or height <= 0: + raise ValueError("ProgressBar dimensions must be positive") + self.rect = pg.Rect(x, y, width, height) + radius = height // 2 if self.border_radius is None else self.border_radius + utils.draw_rounded_rect(surface, self._paint(self.track_color), self.rect, radius) + filled = pg.Rect(x, y, round(width * self.fraction), height) + if filled.w: + # Draw the whole track shape clipped to the filled part, so even a sliver keeps + # the track's rounded left end. + clip = surface.get_clip() + surface.set_clip(filled.clip(clip)) + utils.draw_rounded_rect(surface, self._paint(self.color), self.rect, radius) + surface.set_clip(clip) + if self.show_text: + text = self.font.render(self.percent_text, True, self._paint(self.text_color)) + surface.blit( + text, text.get_rect(midleft=(self.rect.right + TEXT_GAP, self.rect.centery)) + ) diff --git a/src/PygameUIKit/slider.py b/src/PygameUIKit/slider.py index 65e0625..76eb6f3 100644 --- a/src/PygameUIKit/slider.py +++ b/src/PygameUIKit/slider.py @@ -145,9 +145,8 @@ def draw(self, surface: pg.Surface, x: int, y: int, width: int = 200, height: in self.rect = pg.Rect(x, y, width, height) knob_x = x + round(width * self.fraction) fill = self._paint(self.fill_color) - pg.draw.rect(surface, self._paint(self.track_color), self.rect, border_radius=height // 2) - filled = pg.Rect(x, y, knob_x - x, height) - pg.draw.rect(surface, fill, filled, border_radius=height // 2) + utils.draw_rounded_rect(surface, self._paint(self.track_color), self.rect, height // 2) + utils.draw_rounded_rect(surface, fill, pg.Rect(x, y, knob_x - x, height), height // 2) center = (knob_x, self.rect.centery) radius = self._knob_radius() pg.draw.circle(surface, fill, center, radius) diff --git a/src/PygameUIKit/text_input.py b/src/PygameUIKit/text_input.py index ad9b4db..af674e6 100644 --- a/src/PygameUIKit/text_input.py +++ b/src/PygameUIKit/text_input.py @@ -172,28 +172,33 @@ def draw(self, surface: pg.Surface, x: int, y: int) -> None: if not self.visible: return self.rect.topleft = (x, y) - box = pg.Surface(self.rect.size, pg.SRCALPHA) - area = box.get_rect() - background = self._paint(self.background_color) - pg.draw.rect(box, background, area, border_radius=self.border_radius) if self._focused: border, thickness = self.focus_color, 2 else: border = utils.shade(self.border_color, 0.25) if self.hovered else self.border_color thickness = 1 - pg.draw.rect(box, self._paint(border), area, thickness, border_radius=self.border_radius) - box.set_clip(area.inflate(-2 * PAD_X, 0)) - text_y = (area.h - self.font.get_height()) // 2 + utils.draw_rounded_rect( + surface, + self._paint(self.background_color), + self.rect, + self.border_radius, + outline_color=self._paint(border), + outline_width=thickness, + ) + clip = surface.get_clip() + surface.set_clip(self.rect.inflate(-2 * PAD_X, 0).clip(clip)) + text_y = y + (self.rect.h - self.font.get_height()) // 2 if self._text: rendered = self.font.render(self._text, True, self._paint(self.text_color)) - box.blit(rendered, (PAD_X - self._scroll, text_y)) + surface.blit(rendered, (x + PAD_X - self._scroll, text_y)) elif self.placeholder: placeholder = self.font.render( self.placeholder, True, self._paint(self.placeholder_color) ) - box.blit(placeholder, (PAD_X, text_y)) + surface.blit(placeholder, (x + PAD_X, text_y)) if self._focused: - caret_x = PAD_X + self.font.size(self._text[: self.caret])[0] - self._scroll - pg.draw.line(box, self.text_color, (caret_x, text_y), (caret_x, area.h - text_y - 1)) + caret_x = x + PAD_X + self.font.size(self._text[: self.caret])[0] - self._scroll + bottom = self.rect.bottom - (text_y - y) - 1 + pg.draw.line(surface, self.text_color, (caret_x, text_y), (caret_x, bottom)) pg.key.set_text_input_rect(self.rect) - surface.blit(box, self.rect) + surface.set_clip(clip) diff --git a/src/PygameUIKit/utils.py b/src/PygameUIKit/utils.py index 4e109fa..8cf6149 100644 --- a/src/PygameUIKit/utils.py +++ b/src/PygameUIKit/utils.py @@ -4,12 +4,56 @@ def draw_transparent_rect_with_border_radius(screen, color, rect, border_radius, alpha): + color = Color(color) surf = pg.Surface(rect.size, pg.SRCALPHA) - pg.draw.rect(surf, color, surf.get_rect().inflate(-1, -1), border_radius=border_radius) + surf.fill((color.r, color.g, color.b, 0)) # antialiased edges blend toward color, not black + draw_rounded_rect(surf, color, surf.get_rect(), border_radius) surf.set_alpha(alpha) screen.blit(surf, rect) +def draw_rounded_rect( + surface: pg.Surface, + color: ColorLike, + rect: pg.Rect, + radius: int, + *, + outline_color: ColorLike | None = None, + outline_width: int = 1, +) -> None: + """A filled rectangle with antialiased rounded corners, optionally inside an outline. + + The outline is drawn as the whole shape in ``outline_color`` with the fill inset by + ``outline_width`` on top, so the fill should be opaque. + """ + rect = pg.Rect(rect) + if outline_color is not None and outline_width > 0: + _fill_rounded_rect(surface, outline_color, rect, radius) + rect = rect.inflate(-2 * outline_width, -2 * outline_width) + radius -= outline_width + _fill_rounded_rect(surface, color, rect, radius) + + +def _fill_rounded_rect(surface: pg.Surface, color: ColorLike, rect: pg.Rect, radius: int) -> None: + if rect.w <= 0 or rect.h <= 0: + return + radius = min(radius, rect.w // 2, rect.h // 2) + if radius < 1: + surface.fill(color, rect) + return + # Three bands rather than two overlapping rectangles: fill cost grows with the row count. + band = pg.Rect(rect.x + radius, rect.y, rect.w - 2 * radius, radius) + surface.fill(color, band) + surface.fill(color, band.move(0, rect.h - radius)) + surface.fill(color, rect.inflate(0, -2 * radius)) + left, top = rect.x + radius, rect.y + radius + right, bottom = rect.right - radius - 1, rect.bottom - radius - 1 + pg.draw.aacircle(surface, color, (left, top), radius, draw_top_left=True) + pg.draw.aacircle(surface, color, (right, top), radius, draw_top_right=True) + pg.draw.aacircle(surface, color, (left, bottom), radius, draw_bottom_left=True) + pg.draw.aacircle(surface, color, (right, bottom), radius, draw_bottom_right=True) + + def _luma(color: pg.Color) -> float: return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b diff --git a/tests/test_demo.py b/tests/test_demo.py index 5d71cbb..01899bd 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -3,9 +3,9 @@ from PygameUIKit import demo -def click(app, widget): +def click(app, widget, pos=None): for kind in (pg.MOUSEBUTTONDOWN, pg.MOUSEBUTTONUP): - app.handle_event(pg.event.Event(kind, pos=widget.rect.center, button=1)) + app.handle_event(pg.event.Event(kind, pos=pos or widget.rect.center, button=1)) def test_demo_runs_a_few_frames(): @@ -59,3 +59,25 @@ def test_demo_disables_and_hides_the_showcase_widgets(): "widget.enabled = True", "widget.visible = True", ] + + +def test_demo_choices_drive_the_chart_and_progress_follows(): + app = demo.Demo() + app.draw() + click(app, app.animate_box) + assert app.animate.value and app.animate_box.value + click(app, app.animate) + assert not app.animate_box.value + click(app, app.values_box) + assert app.chart.labels is None + click(app, app.speed, app.speed._rows[2].center) + assert app.chart.speed == demo.SPEEDS["Fast"] + assert list(app.events) == [ + "Checkbox.on_change(True)", + "IconToggle.on_change(False)", + "Checkbox.on_change(True)", + "RadioGroup.on_change('Fast')", + ] + app.int_slider.value = 70 + app.update(0.5) + assert app.slider_progress.value == 70 and 0 < app.loading.fraction <= 1 diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 9b1dc02..634c315 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -4,7 +4,20 @@ import pygame as pg import pytest -from PygameUIKit import BarChart, Dropdown, Group, Label, Widget, button, slider, text_input, utils +from PygameUIKit import ( + BarChart, + Checkbox, + Dropdown, + Group, + Label, + ProgressBar, + RadioGroup, + Widget, + button, + slider, + text_input, + utils, +) from PygameUIKit.utils import ( best_contrast_color, disabled_color, @@ -41,6 +54,9 @@ def test_import_does_not_initialize_pygame(): lambda: text_input.TextInput(), lambda: Dropdown(["Hello", "World"]), lambda: BarChart([1, 2]), + lambda: Checkbox("Hello"), + lambda: RadioGroup(["Hello", "World"]), + lambda: ProgressBar(0.5, show_text=True), ], ) def test_default_fonts_survive_pygame_restart(factory, display): @@ -225,6 +241,7 @@ def test_click_needs_press_and_release_inside(display): 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))), + lambda: Checkbox("Hi"), ], ) def test_clickables_share_hover_cursor_and_focus_loss(factory, display): @@ -726,6 +743,136 @@ def test_chart_values_setter_and_animation_speed(display): widget.values = [1, -1] +def test_checkbox_toggles_on_box_and_label_clicks(display): + calls = [] + box = Checkbox("Sound", on_change=calls.append) + box.draw(display, 10, 10) + size = box.rect.h + assert box.rect.w > 2 * size and box.cursor == pg.SYSTEM_CURSOR_HAND + assert click(box, (10 + size // 2, box.rect.centery)) == [True, True] + assert box.value and calls == [True] + click(box, (box.rect.right - 2, box.rect.centery)) + assert not box.value and calls == [True, False] + assert click(box, (box.rect.right + 5, box.rect.centery)) == [False, False] + box.value = True + box.text = "Music" + assert box.value and calls == [True, False] + plain = Checkbox(value=1) + plain.draw(display, 10, 50) + assert plain.value is True and plain.rect.w == plain.rect.h + + +def test_radio_group_selects_clicked_option(display): + calls = [] + radio = RadioGroup(["Slow", "Normal", "Fast"], index=1, on_change=calls.append) + radio.draw(display, 10, 10) + rows = radio._rows + assert radio.rect == rows[0].unionall(rows[1:]) and rows[1].y > rows[0].bottom + assert click(radio, (rows[2].right - 2, rows[2].centery)) == [True, True] + assert (radio.index, radio.value, calls) == (2, "Fast", ["Fast"]) + assert click(radio, (rows[2].x + 2, rows[2].centery)) == [True, True] + assert calls == ["Fast"] + radio.handle_event(mouse(pg.MOUSEBUTTONDOWN, rows[0].center)) + radio.handle_event(mouse(pg.MOUSEBUTTONUP, rows[1].center)) + assert radio.index == 2 + gap = (rows[0].centerx, rows[0].bottom + 2) + assert click(radio, gap) == [False, False] + radio.handle_event(mouse(pg.MOUSEMOTION, rows[0].center)) + assert radio.hovered and radio.highlighted == 0 + radio.handle_event(mouse(pg.MOUSEMOTION, gap)) + assert not radio.hovered and radio.highlighted is None + radio.on_change = lambda value: calls.append(radio.value) + click(radio, rows[0].center) + assert calls == ["Fast", "Slow"] + + +def test_radio_group_horizontal_layout(display): + radio = RadioGroup(["A", "B"], direction="horizontal", spacing=20) + radio.draw(display, 10, 10) + first, second = radio._rows + assert second.x == first.right + 20 and second.y == first.y + assert radio.rect.h == first.h + + +def test_radio_group_setters_do_not_fire_and_validate(): + calls = [] + radio = RadioGroup(["A", "B", "C"], index=2, on_change=calls.append) + assert radio.value == "C" + radio.index = 0 + radio.value = "B" + assert radio.index == 1 and calls == [] + with pytest.raises(IndexError): + radio.index = 3 + with pytest.raises(IndexError): + radio.index = -1 + with pytest.raises(ValueError): + radio.value = "D" + group = Group() + for kwargs in (dict(options=[]), dict(direction="diagonal"), dict(spacing=-1)): + with pytest.raises(ValueError): + RadioGroup(**{"options": ["A"], **kwargs}, ui_group=group) + with pytest.raises(IndexError): + RadioGroup(["A"], index=1, ui_group=group) + assert group.widgets == [] + + +def test_progress_bar_clamps_and_reports_fraction(display): + bar = ProgressBar(2) + assert bar.value == 1 and bar.fraction == 1 and bar.percent_text == "100%" + bar.value = -3 + assert bar.value == 0 and bar.fraction == 0 + bar = ProgressBar(25, max_value=50, color="red", track_color="black") + assert bar.fraction == 0.5 and bar.percent_text == "50%" + bar.max_value = 20 + assert bar.fraction == 1 + bar.max_value = 100 + display.fill("white") + bar.draw(display, 10, 10, width=100, height=10) + assert bar.rect == pg.Rect(10, 10, 100, 10) + assert display.get_at((30, 15)) == pg.Color("red") + assert display.get_at((50, 15)) == pg.Color("black") + assert not bar.handle_event(mouse(pg.MOUSEBUTTONDOWN, (30, 15))) and bar.cursor is None + with pytest.raises(ValueError): + bar.value = float("nan") + with pytest.raises(ValueError): + bar.draw(display, 0, 0, width=0) + group = Group() + for kwargs in (dict(max_value=0), dict(max_value=float("inf")), dict(border_radius=-1)): + with pytest.raises(ValueError): + ProgressBar(ui_group=group, **kwargs) + with pytest.raises(ValueError): + ProgressBar(float("nan"), ui_group=group) + assert group.widgets == [] + + +@pytest.mark.parametrize("state", ["enabled", "visible"]) +def test_disabled_or_hidden_choices_ignore_clicks_and_reset(state, display): + calls = [] + group = Group() + box = Checkbox("Sound", on_change=calls.append, ui_group=group) + radio = RadioGroup(["A", "B"], on_change=calls.append, ui_group=group) + box.draw(display, 10, 10) + radio.draw(display, 10, 50) + target = radio._rows[1].center + for widget, pos in ((box, box.rect.center), (radio, target)): + widget.handle_event(mouse(pg.MOUSEMOTION, pos)) + widget.handle_event(mouse(pg.MOUSEBUTTONDOWN, pos)) + assert box.pressed and radio.highlighted == 1 and radio.hovered + for widget in (box, radio): + setattr(widget, state, False) + assert not box.pressed and not box.hovered + assert radio.highlighted is None and not radio.hovered + for widget, pos in ((box, box.rect.center), (radio, target)): + assert not any(click(widget, pos)) + group.handle_event(mouse(pg.MOUSEBUTTONDOWN, pos)) + group.handle_event(mouse(pg.MOUSEBUTTONUP, pos)) + assert not box.value and radio.index == 0 and calls == [] + setattr(radio, state, True) + assert not radio.handle_event(mouse(pg.MOUSEBUTTONUP, target)) + click(radio, target) + assert calls == ["B"] + + def interactive_widgets(group, calls): return [ button.Button("Click", lambda: calls.append("click"), ui_group=group), @@ -795,6 +942,10 @@ def test_disabling_or_hiding_clears_interaction_state(state, display): lambda: text_input.TextInput("Hello"), lambda: Dropdown(["Hello", "World"]), lambda: BarChart([1, 2], labels=["A", "B"]), + lambda: Checkbox("Hello"), + lambda: Checkbox(value=True), + lambda: RadioGroup(["Hello", "World"], direction="horizontal"), + lambda: ProgressBar(0.5, show_text=True), ], ) def test_hidden_widgets_draw_nothing_and_disabled_ones_draw_dimmed(factory, display): @@ -868,3 +1019,17 @@ def test_disabled_color_and_image_helpers(): assert faded.get_size() == (4, 3) and faded.get_alpha() < 255 r, g, b, _ = faded.get_at((0, 0)) assert r == g == b + + +def test_draw_rounded_rect_fill_outline_and_antialiased_corners(): + surface = pg.Surface((40, 30)) + surface.fill("white") + utils.draw_rounded_rect(surface, "red", pg.Rect(5, 5, 30, 20), 6, outline_color="blue") + assert surface.get_at((20, 15)) == pg.Color("red") + assert surface.get_at((20, 5)) == pg.Color("blue") and surface.get_at((5, 15)) == pg.Color( + "blue" + ) + assert surface.get_at((5, 5)) == pg.Color("white") + corner = [surface.get_at((x, y)) for x in range(5, 11) for y in range(5, 11)] + assert any(c not in (pg.Color("white"), pg.Color("blue"), pg.Color("red")) for c in corner) + utils.draw_rounded_rect(surface, "red", pg.Rect(0, 0, 0, 10), 6)