From 5d8425f1d70f9a9c57a4ea13ddbfcc12da949988 Mon Sep 17 00:00:00 2001 From: Dorian Om Date: Wed, 28 Oct 2026 03:19:29 +0100 Subject: [PATCH 1/2] Rewrite Slider, TextInput, Dropdown and BarChart Slider takes (min, max, step=1, *, value, on_change, show_value, font, colors), exposes a clamped, snapped value property and calls on_change(value) only when interaction changes the value. The knob grows on hover and drag, Left/Right/Home/End adjust it while hovered, and the shown value uses the decimals that min and step need. TextInput takes (text="", *, placeholder, width=200, font, colors, border_radius, on_change, on_submit). It has a fixed width, a settable text property and focused flag, calls on_change(text) after each edit, submits on Enter and unfocuses on Escape. combobox.ComboBox becomes dropdown.Dropdown, a standalone widget with value and index properties, on_change(value) on user selection, keyboard navigation, a drawn chevron instead of the arrow asset, and a menu that consumes pointer events while open. Group marks widgets after a consumed MOUSEMOTION as not hovered, so nothing under an open menu stays hovered. BarChart takes keyword-only options plus colors and speed, has a values property whose setter replaces all bars, and set_value(index, value). --- src/PygameUIKit/__init__.py | 8 +- src/PygameUIKit/assets/combo_box_arrow.png | Bin 1569 -> 0 bytes src/PygameUIKit/barchart.py | 185 ++++---- src/PygameUIKit/combobox.py | 148 ------ src/PygameUIKit/demo.py | 37 +- src/PygameUIKit/dropdown.py | 226 +++++++++ src/PygameUIKit/slider.py | 243 +++++----- src/PygameUIKit/text_input.py | 274 ++++++----- src/PygameUIKit/widget.py | 13 +- tests/test_widgets.py | 528 +++++++++++++++------ 10 files changed, 999 insertions(+), 663 deletions(-) delete mode 100644 src/PygameUIKit/assets/combo_box_arrow.png delete mode 100644 src/PygameUIKit/combobox.py create mode 100644 src/PygameUIKit/dropdown.py diff --git a/src/PygameUIKit/__init__.py b/src/PygameUIKit/__init__.py index 406cdae..1899717 100644 --- a/src/PygameUIKit/__init__.py +++ b/src/PygameUIKit/__init__.py @@ -1,9 +1,9 @@ """Composable Pygame UI widgets with explicit drawing and event dispatch.""" -from . import barchart, button, combobox, label, slider, text_input, utils, widget +from . import barchart, button, dropdown, label, slider, text_input, utils, widget from .barchart import BarChart from .button import Button, IconButton, IconToggle, ImageButton -from .combobox import ComboBox +from .dropdown import Dropdown from .label import Label from .slider import Slider from .text_input import TextInput @@ -12,7 +12,7 @@ __all__ = [ "BarChart", "Button", - "ComboBox", + "Dropdown", "Group", "IconButton", "IconToggle", @@ -23,7 +23,7 @@ "Widget", "barchart", "button", - "combobox", + "dropdown", "label", "slider", "text_input", diff --git a/src/PygameUIKit/assets/combo_box_arrow.png b/src/PygameUIKit/assets/combo_box_arrow.png deleted file mode 100644 index 2c4fde241f2bb777e813d2ec9f7f3f7a1ec18f61..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1569 zcmai!`B#!@6vtl+CB>!Hw6L^st08Q(G*U~Vr<1UWr=h`%===|N6F0Ha`!VgaBo7qNUsA7aC#fCxGOAU5bs z4-nq8008|)oDcSRa@3TnHDM#gLO+_%yqo?nYZJrK?U83i=KZX_3Bg7Zo3g6MM}}UuF$=&js{JHZv(>?B{(6?abBCU*A9KUDaA`wL`;9r9ku4UBJ5(x_Sz$+Uli9;(^6nEipPyWJQHlFBH8nUq zILKzOSj)t@`CCO$34eK8GgHPk3dh&y8+$UfAD`z3ohs|nWH=lvTK2$=(b_jVMJ*@O z?5Xj>6;)49k4Sr`B%fs8|7&am-?jQZ*A_m|fTRq`*-K5t4J`qFAn)t+@Cto>kx6z4 zh*Yx*h) zt(-co{q$OVVz`a9Fq<*mmrELeC%c!}KuNg|8-dhfO=-Zp@sgUaX%i9S&pPp;o%D9O^9Kk}#Ey z9S06h=V9nMX37#BF`9X2nGTF*`gG}g!?o})K5pVm_X{F{tmEG+LMtO+FtM!_J zsS4}nB*f0cl76%iS1-J~fhIm5bl3P%TIF@>-X$ZFwfR2D$wC3qmQ#24tVY<~;%%At z)a1uEG0!$BOHfG`%?46Ys&V&@B_7@FZ_6$W{yTY30V!sI>LzxZ@xL#qlwlVVJ_viYcM>{y*Mo0j8KHfybU+s_XZfn za5+@vqcQV}hvj2x|MRr{bZyzIPyve<;+{FaPQ5 z_4hq0ky&U2+B3Pi78z)N4ZSaA;TWdiqUfs7ta#>X1q$y9Di=5!=zN z$=?>|Wgh-?BBr29Boevn#{QGL9P9?L#@3`Cd list[float]: + if any(not isfinite(value) or value < 0 for value in values): + raise ValueError("Bar values must be finite and non-negative") + return list(values) + + +def _palette(count: int) -> list[pg.Color]: + return [ + pg.Color([round(c * 255) for c in hsv_to_rgb(i / count, 0.55, 0.85)]) for i in range(count) + ] class BarChart(Widget): - """Animated bars: ``BarChart([10, 20]).draw(surface, x, y, width, height)``.""" + """Bars that grow and shrink toward their values, with a label under each bar. + + ``chart = BarChart([3, 5, 2], labels=["A", "B", "C"], max_value=10, ui_group=ui)``; call + ``chart.set_value(1, 8)`` or set ``chart.values``, ``ui.update(dt)`` to animate and + ``chart.draw(screen, x, y, width=300, height=100)`` every frame. Without ``labels`` the + bars show their values; without ``max_value`` the tallest bar fills the height. + """ def __init__( self, - values: list[int], - labels: list[str] = None, - max_value=None, - ui_group=None, - font=None, - labels_color=(0, 0, 0), + values: Sequence[float], + *, + labels: Sequence[str] | None = None, + max_value: float | None = None, + colors: Sequence[ColorLike] | None = None, + font: pg.font.Font | None = None, + label_color: ColorLike = "black", + speed: float = 200, + ui_group: Group | None = None, ): - super().__init__(ui_group=ui_group) - if any(not isfinite(v) or v < 0 for v in values): - raise ValueError("Bar values must be finite and non-negative") - if max_value is not None and (not isfinite(max_value) or max_value <= 0): - raise ValueError("max_value must be finite and positive") + values = _check_values(values) if labels is not None and len(labels) != len(values): raise ValueError("labels must have the same length as values") - self.values = values[:] - self.max_value = max_value - self.rect = pg.Rect(0, 0, 0, 0) - self.labels_color = labels_color + if colors is not None and len(colors) == 0: + raise ValueError("colors must not be empty") + if max_value is not None and (not isfinite(max_value) or max_value <= 0): + raise ValueError("max_value must be finite and positive") + if not isfinite(speed) or speed <= 0: + raise ValueError("speed must be finite and positive") + self._values = values + self.values_displayed = values[:] self.labels = labels - self.values_displayed = self.values[:] - - self.font = font if font is not None else get_font(15) - self.colors = [hsv_to_rgb(i / len(values), 0.55, 0.85) for i in range(len(values))] - self.colors = color_from_float(self.colors) - - def change_value(self, index, value): - if not isfinite(value) or value < 0: - raise ValueError("Bar values must be finite and non-negative") - self.values[index] = value - - def update(self, dt): - dt = max(0, dt) - for i, value in enumerate(self.values): - if abs(self.values_displayed[i] - value) < CHANGE_SPEED * dt: - self.values_displayed[i] = value - continue - if self.values_displayed[i] < value: - self.values_displayed[i] += CHANGE_SPEED * dt - else: - self.values_displayed[i] -= CHANGE_SPEED * dt - - def draw_bar(self, screen, index, color=(0, 0, 0)): - x, y, width, height = self.rect - bar_width = width / len(self.values) - if self.max_value is None: - max_height = max(1, *self.values, *self.values_displayed) - else: - max_height = self.max_value - fraction = min(1, self.values_displayed[index] / max_height) - bar_height = fraction * height - bar_x = x + index * bar_width - bar_y = y + height - bar_height - - alpha = int(max(255 * fraction, 50)) - draw_transparent_rect_with_border_radius( - screen, - color, - Rect( - bar_x + SPACE_BETWEEN_BARS, - bar_y, - max(0, bar_width - 2 * SPACE_BETWEEN_BARS), - bar_height, - ), - 0, - alpha, - ) - - def draw_labels(self, screen): - if not self.values: - return - x, y, width, height = self.rect - bar_width = width / len(self.values) - font = self.font + self.max_value = max_value + self._auto_colors = colors is None + self.colors = _palette(len(values)) if colors is None else [pg.Color(c) for c in colors] + self.font = font if font is not None else utils.get_font(18) + self.label_color = pg.Color(label_color) + self.speed = speed + super().__init__(ui_group=ui_group) - labels = self.labels - if labels is None: - labels = [f"{v:.0f}" for v in self.values_displayed] - for i, text in enumerate(labels): - botmid_bar = (x + i * bar_width + bar_width // 2, y + height) - text = font.render(text, True, self.labels_color) - screen.blit(text, text.get_rect(midtop=botmid_bar)) + @property + def values(self) -> tuple[float, ...]: + return tuple(self._values) + + @values.setter + def values(self, values: Sequence[float]) -> None: + values = _check_values(values) + shown = self.values_displayed[: len(values)] + self.values_displayed = shown + [0] * (len(values) - len(shown)) + if self._auto_colors and len(values) != len(self._values): + self.colors = _palette(len(values)) + self._values = values + + def set_value(self, index: int, value: float) -> None: + self._values[index] = _check_values([value])[0] + + def update(self, dt: float) -> None: + change = self.speed * max(0, dt) + for i, target in enumerate(self._values): + difference = target - self.values_displayed[i] + if abs(difference) <= change: + self.values_displayed[i] = target + else: + self.values_displayed[i] += copysign(change, difference) - def draw(self, screen, x, y, width=300, height=100): + def draw( + self, surface: pg.Surface, x: int, y: int, width: int = 300, height: int = 100 + ) -> None: if width < 0 or height < 0: raise ValueError("Chart dimensions must be non-negative") self.rect = pg.Rect(x, y, width, height) - for i in range(len(self.values_displayed)): - self.draw_bar(screen, i, color=self.colors[i]) - self.draw_labels(screen) + if not self._values: + return + scale = self.max_value or max(1, *self._values, *self.values_displayed) + slot = width / len(self._values) + labels = self.labels + if labels is None: + labels = [f"{value:.0f}" for value in self.values_displayed] + for i, shown in enumerate(self.values_displayed): + bar_height = round(min(1, shown / scale) * height) + left = round(x + i * slot + BAR_GAP / 2) + bar = pg.Rect(left, y + height - bar_height, max(0, round(slot - BAR_GAP)), bar_height) + color = self.colors[i % len(self.colors)] + pg.draw.rect(surface, color, bar, border_top_left_radius=3, border_top_right_radius=3) + if i < len(labels): + text = self.font.render(labels[i], True, self.label_color) + center = round(x + (i + 0.5) * slot) + surface.blit(text, text.get_rect(midtop=(center, y + height + LABEL_GAP))) diff --git a/src/PygameUIKit/combobox.py b/src/PygameUIKit/combobox.py deleted file mode 100644 index 0853001..0000000 --- a/src/PygameUIKit/combobox.py +++ /dev/null @@ -1,148 +0,0 @@ -import os -from collections.abc import Callable - -import pygame as pg -from pygame import Color - -from .button import Button -from .label import Label -from .utils import draw_transparent_rect_with_border_radius, load_image - -PADDING = 10 - -cwd = os.path.dirname(__file__) - - -class ComboBox(Button): - def __init__(self, elements: list[str], ui_group=None, font=None, font_color=(0, 0, 0)): - if not elements: - raise ValueError("ComboBox requires at least one element") - super().__init__( - elements[0], - self.toggle_open, - color="white", - text_color=font_color, - border_radius=5, - padding=(PADDING, PADDING), - font=font, - align="left", - ui_group=ui_group, - ) - self.font = self.label.font - self.elements = elements - self.selected_index = 0 - self.hovered_index = self.selected_index - self.is_open = False - self._labels: list[Label] = [] - self.actions: dict[int, Callable[[], None]] = {} - - self.img_arrow_down = load_image( - os.path.join(cwd, "assets", "combo_box_arrow.png"), (20, 20) - ) - self.img_arrow_down.fill((0, 0, 0, 255), special_flags=pg.BLEND_RGBA_MULT) - self._pressed_index = None - self.img_arrow_up = pg.transform.rotate(self.img_arrow_down.copy(), 180) - - self.width = max(200, max(self.font.size(text)[0] for text in elements) + 50) - self._resize() - self._render_elements() - - def _render_elements(self): - self._labels.clear() - for element in self.elements: - self._labels.append(Label(element, "white", font=self.font)) - - def draw(self, screen, x, y): - super().draw(screen, x, y) - img = self.img_arrow_up if self.is_open else self.img_arrow_down - arrow_y = y + (self.rect.h - img.get_height()) // 2 - screen.blit(img, (x + self.rect.w - img.get_width() - PADDING, arrow_y)) - - if self.is_open: - height = self.rect.h * len(self.elements) - draw_transparent_rect_with_border_radius( - screen, Color("black"), pg.Rect(x, y + self.rect.h, self.rect.w, height), 5, 200 - ) - for i in range(len(self.elements)): - if i == self.hovered_index or i == self.selected_index: - draw_transparent_rect_with_border_radius( - screen, - Color("white"), - pg.Rect(x, y + self.rect.h * (i + 1), self.rect.w, self.rect.h).inflate( - -PADDING, -PADDING - ), - border_radius=5, - alpha=100, - ) - label_y = y + self.rect.h * (i + 1) + PADDING - self._labels[i].draw(screen, x + PADDING, label_y) - - @property - def event_priority(self): - return int(self.is_open) - - def _option_at(self, pos): - menu = pg.Rect(self.rect.x, self.rect.bottom, self.rect.w, self.rect.h * len(self.elements)) - if menu.collidepoint(pos): - return (pos[1] - menu.y) // self.rect.h - return None - - def handle_event(self, event): - if event.type == pg.WINDOWFOCUSLOST or ( - event.type == pg.KEYDOWN and event.key == pg.K_ESCAPE - ): - self.is_open = False - self._pressed_index = None - self.pressed = False - if self.is_open and event.type in (pg.MOUSEBUTTONDOWN, pg.MOUSEBUTTONUP, pg.MOUSEMOTION): - index = self._option_at(event.pos) - if event.type == pg.MOUSEMOTION: - self.change_hovered(index) - if index is not None: - return True - elif event.button == 1: - if event.type == pg.MOUSEBUTTONDOWN: - self._pressed_index = index - if index is not None: - return True - if not self.rect.collidepoint(event.pos): - self.is_open = False - else: - pressed = self._pressed_index - self._pressed_index = None - if index is not None: - self.is_open = False - if index == pressed: - self.change_selected(index) - return True - if not self.rect.collidepoint(event.pos): - self.is_open = False - elif index is not None: - return True - return super().handle_event(event) - - def change_selected(self, index): - if not 0 <= index < len(self.elements): - raise IndexError("ComboBox index out of range") - self.selected_index = index - self.hovered_index = index - self.text = self.elements[self.selected_index] - self._render_elements() - if index in self.actions: - self.actions[index]() - - def toggle_open(self): - self.is_open = not self.is_open - if self.is_open: - self._render_elements() - - def add_action(self, index, action): - self.actions[index] = action - - def get_value(self) -> str: - return self.elements[self.selected_index] - - def change_hovered(self, i): - if i != self.hovered_index: - self.hovered_index = i - self._render_elements() diff --git a/src/PygameUIKit/demo.py b/src/PygameUIKit/demo.py index bf638d4..ff66dd3 100644 --- a/src/PygameUIKit/demo.py +++ b/src/PygameUIKit/demo.py @@ -5,8 +5,7 @@ import pygame from pygame import Color -from PygameUIKit import Group, button, combobox, slider, text_input -from PygameUIKit.barchart import BarChart +from PygameUIKit import BarChart, Dropdown, Group, Slider, TextInput, button # # @@ -31,10 +30,9 @@ def __init__(self): self.clock = pygame.time.Clock() self.easy_objects = Group() - self.text_input = text_input.TextInput( + self.text_input = TextInput( placeholder="This is a placeholder text", - fixed_width=200, - border_radius=2, + width=240, ui_group=self.easy_objects, font=def_font, ) @@ -46,23 +44,25 @@ def __init__(self): "Shuffle", self.change_values, color=BTN_BLUE, font=def_font, ui_group=self.easy_objects ) - self.slider = slider.Slider( - 0, 100, 1, show_value=True, ui_group=self.easy_objects, font=def_font + self.slider = Slider( + 0, + 100, + on_change=lambda value: self.change_values(), + show_value=True, + ui_group=self.easy_objects, + font=def_font, ) - self.slider.connect(self.change_values) - self.dropdown = combobox.ComboBox( - ["Hello", "World", "And", "You"], ui_group=self.easy_objects, font=def_font + self.dropdown = Dropdown( + ["Hello", "World", "And", "You"], + on_change=self.set_text, + ui_group=self.easy_objects, + font=def_font, ) data = [10, 20, 30, 40, 50] self.chart = BarChart(data, ui_group=self.easy_objects, max_value=100, font=def_font) - for i in range(4): - self.dropdown.add_action( - i, lambda i=i: self.text_input.set_text(self.dropdown.elements[i]) - ) - self.dancing = False def run(self, frames=None): @@ -96,7 +96,7 @@ def draw(self, win): self.btn_pause.draw(win, 100, 200) self.btn_png.draw(win, 100, 300) self.btn_shuffle.draw(win, 100, 430) - self.slider.draw(win, 100, 400, 300, 5) + self.slider.draw(win, 100, 400, 300) self.chart.draw(win, 159, 164, 300, 100) self.dropdown.draw(win, 100, 100) pygame.display.flip() @@ -104,7 +104,10 @@ def draw(self, win): def change_values(self): i = random.randint(0, len(self.chart.values) - 1) new_value = random.randint(0, 100) - self.chart.change_value(i, new_value) + self.chart.set_value(i, new_value) + + def set_text(self, text): + self.text_input.text = text def set_dancing(self, dancing): self.dancing = dancing diff --git a/src/PygameUIKit/dropdown.py b/src/PygameUIKit/dropdown.py new file mode 100644 index 0000000..2f52887 --- /dev/null +++ b/src/PygameUIKit/dropdown.py @@ -0,0 +1,226 @@ +from collections.abc import Callable, Sequence + +import pygame as pg +from pygame.typing import ColorLike + +from . import utils +from .label import Label +from .widget import Group, Widget + +PAD_X, PAD_Y = 12, 8 +ARROW = 10 +GAP = 4 +INSET = 4 +HEADER = -1 + + +class Dropdown(Widget): + """A box showing the selected option that opens a menu of all options below it. + + ``size = Dropdown(["Small", "Large"], on_change=print, ui_group=ui)``; draw it after the + widgets its menu can cover. Choose with the mouse, or with Up/Down and Enter while it is + open; Escape, window focus loss or a click elsewhere closes it. ``on_change(value)`` runs + when the user picks a different option; setting ``value`` or ``index`` does not call it. + """ + + cursor = pg.SYSTEM_CURSOR_HAND + + def __init__( + self, + options: Sequence[str], + *, + index: int = 0, + on_change: Callable[[str], None] | None = None, + width: int | None = None, + font: pg.font.Font | None = None, + color: ColorLike = "white", + text_color: ColorLike | None = None, + highlight_color: ColorLike = (59, 130, 246), + outline_color: ColorLike | None = (170, 170, 170), + border_radius: int = 6, + ui_group: Group | None = None, + ): + if not options: + raise ValueError("Dropdown requires at least one option") + if not 0 <= index < len(options): + raise IndexError("Dropdown index out of range") + if width is not None and width <= 0: + raise ValueError("width must be positive") + if border_radius < 0: + raise ValueError("border_radius must not be negative") + self.options = tuple(options) + self._index = index + self.on_change = on_change + self.width = width + self.font = font if font is not None else utils.get_font(24) + self.color = pg.Color(color) + self.text_color = None if text_color is None else pg.Color(text_color) + self.highlight_color = pg.Color(highlight_color) + self.outline_color = None if outline_color is None else pg.Color(outline_color) + self.border_radius = border_radius + self.is_open = False + self.highlighted: int | None = None + self._pressed: int | None = None + self._labels = [Label(option, font=self.font) for option in self.options] + self._header = Label(self.options[index], font=self.font) + super().__init__(ui_group=ui_group) + self.rect = pg.Rect(0, 0, self._width(), self.font.get_height() + 2 * PAD_Y) + + @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("Dropdown 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) + + @property + def event_priority(self) -> int: + # An open menu sees events before the widgets it covers. + return int(self.is_open) + + def _width(self) -> int: + if self.width is not None: + return self.width + return max(self.font.size(option)[0] for option in self.options) + 3 * PAD_X + ARROW + + def _menu_rect(self) -> pg.Rect: + height = len(self.options) * self.rect.h + 2 * INSET + return pg.Rect(self.rect.x, self.rect.bottom + GAP, self.rect.w, height) + + def _row_rect(self, index: int) -> pg.Rect: + menu = self._menu_rect() + top = menu.y + INSET + index * self.rect.h + return pg.Rect(menu.x + INSET, top, menu.w - 2 * INSET, self.rect.h) + + def _target(self, pos: tuple[int, int]) -> int | None: + """``HEADER``, the index of the option under ``pos``, or None.""" + if self.rect.collidepoint(pos): + return HEADER + if self.is_open: + return next( + (i for i in range(len(self.options)) if self._row_rect(i).collidepoint(pos)), None + ) + return None + + def _covers(self, pos: tuple[int, int]) -> bool: + return self.rect.collidepoint(pos) or (self.is_open and self._menu_rect().collidepoint(pos)) + + def _set_open(self, is_open: bool) -> None: + self.is_open = is_open + self.highlighted = self._index if is_open else None + + def _choose(self, index: int) -> None: + self._set_open(False) + if index != self._index: + self._index = index + if self.on_change is not None: + self.on_change(self.value) + + def handle_event(self, event: pg.event.Event) -> bool: + if event.type == pg.WINDOWFOCUSLOST: + self._set_open(False) + self.hovered = False + self._pressed = None + elif event.type == pg.MOUSEMOTION: + self.hovered = self._covers(event.pos) + if self.is_open: + target = self._target(event.pos) + self.highlighted = None if target == HEADER else target + return self.is_open and self.hovered + elif event.type == pg.MOUSEBUTTONDOWN and event.button == 1: + self._pressed = self._target(event.pos) + if self._pressed is None and not self._covers(event.pos): + self._set_open(False) + return self._covers(event.pos) + elif event.type == pg.MOUSEBUTTONUP and event.button == 1: + pressed, self._pressed = self._pressed, None + target = self._target(event.pos) + if target is not None and target == pressed: + if target == HEADER: + self._set_open(not self.is_open) + else: + self._choose(target) + return True + return self.is_open and self._covers(event.pos) + elif event.type in (pg.MOUSEBUTTONDOWN, pg.MOUSEBUTTONUP): + return self.is_open and self._covers(event.pos) + elif event.type == pg.KEYDOWN and self.is_open: + if event.key == pg.K_ESCAPE: + self._set_open(False) + elif event.key in (pg.K_UP, pg.K_DOWN): + start = self._index if self.highlighted is None else self.highlighted + step = 1 if event.key == pg.K_DOWN else -1 + self.highlighted = max(0, min(len(self.options) - 1, start + step)) + elif event.key in (pg.K_RETURN, pg.K_KP_ENTER, pg.K_SPACE): + if self.highlighted is not None: + self._choose(self.highlighted) + else: + return False + return True + return False + + def _text_color(self, highlighted: bool = False) -> pg.Color: + if highlighted: + return utils.best_contrast_color(self.highlight_color) + if self.text_color is not None: + return self.text_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, fill, rect, border_radius=self.border_radius) + if self.outline_color is not None: + pg.draw.rect(surface, self.outline_color, rect, 1, border_radius=self.border_radius) + + def draw(self, surface: pg.Surface, x: int, y: int) -> None: + self.rect = pg.Rect(x, y, self._width(), self.font.get_height() + 2 * PAD_Y) + if self._pressed == HEADER: + fill = utils.shade(self.color, 0.25) + elif self.is_open or self.hovered: + fill = utils.shade(self.color, 0.08) + else: + fill = self.color + self._draw_box(surface, self.rect, fill) + label = self._header + label.text, label.color = self.value, self._text_color() + label.draw(surface, x + PAD_X, self.rect.centery - label.surface.get_height() // 2) + self._draw_arrow(surface, label.color) + if not self.is_open: + return + menu = self._menu_rect() + shadow = menu.move(0, 3) + utils.draw_transparent_rect_with_border_radius( + surface, pg.Color("black"), shadow, self.border_radius, 40 + ) + self._draw_box(surface, menu, self.color) + for i, option_label in enumerate(self._labels): + row = self._row_rect(i) + if i == self.highlighted: + fill = self.highlight_color + elif i == self._index: + fill = utils.shade(self.color, 0.1) + else: + fill = self.color + if fill != self.color: + pg.draw.rect(surface, fill, row, border_radius=max(0, 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) + + def _draw_arrow(self, surface: pg.Surface, color: pg.Color) -> None: + cx, cy = self.rect.right - PAD_X - ARROW // 2, self.rect.centery + half, rise = ARROW // 2, -(ARROW // 4) if self.is_open else ARROW // 4 + points = [(cx - half, cy - rise), (cx, cy + rise), (cx + half, cy - rise)] + pg.draw.lines(surface, color, False, points, 2) diff --git a/src/PygameUIKit/slider.py b/src/PygameUIKit/slider.py index bd52968..1f76253 100644 --- a/src/PygameUIKit/slider.py +++ b/src/PygameUIKit/slider.py @@ -1,156 +1,149 @@ import math +from collections.abc import Callable +from decimal import Decimal -import pygame -from pygame import Color +import pygame as pg +from pygame.typing import ColorLike -from .utils import get_font -from .widget import Widget +from . import utils +from .widget import Group, Widget -COLOR_COMPLETED = Color(132, 214, 80) -COLOR_IN_PROGRESS = Color(200, 200, 200) -COLOR_CIRCLE1 = Color(241, 241, 241) -COLOR_CIRCLE2 = COLOR_IN_PROGRESS +def _decimals(*numbers: float) -> int: + """How many decimals it takes to write every number in ``numbers`` exactly.""" + return max(0, *(-Decimal(str(n)).normalize().as_tuple().exponent for n in numbers)) class Slider(Widget): - """Horizontal value slider: ``Slider(0, 100, 1).draw(surface, x, y, width, height)``.""" + """A horizontal slider for numbers from ``min`` to ``max`` in multiples of ``step``. - cursor = pygame.SYSTEM_CURSOR_HAND + ``volume = Slider(0, 100, 5, value=50, on_change=set_volume, ui_group=ui)``; + ``volume.draw(screen, x, y, width=200)`` every frame. Drag or click the track, or press + Left/Right/Home/End while hovering it. Setting ``value`` does not call ``on_change``. + """ + + cursor = pg.SYSTEM_CURSOR_HAND def __init__( self, - min, - max, - step, - show_value=False, - font=None, - font_color=(0, 0, 0), - ui_group=None, + min: float, + max: float, + step: float = 1, + *, + value: float | None = None, + on_change: Callable[[float], None] | None = None, + show_value: bool = False, + font: pg.font.Font | None = None, + fill_color: ColorLike = (59, 130, 246), + track_color: ColorLike = (200, 200, 200), + knob_color: ColorLike = "white", + text_color: ColorLike = "black", + ui_group: Group | None = None, ): - super().__init__(ui_group=ui_group) if not all(math.isfinite(v) for v in (min, max, step)) or max <= min or step <= 0: raise ValueError("Slider requires finite min < max and step > 0") self.min = min self.max = max - self.step = step - self.current_value = min + self._decimals = _decimals(min, step) + self.value = min if value is None else value + self.on_change = on_change self.show_value = show_value - # ui - self.circle_radius = 10 - self.rect = pygame.Rect(0, 0, 100, self.circle_radius / 2) + self.font = font if font is not None else utils.get_font(24) + self.fill_color = pg.Color(fill_color) + self.track_color = pg.Color(track_color) + self.knob_color = pg.Color(knob_color) + self.text_color = pg.Color(text_color) self.dragging = False - self.font = font if font is not None else get_font() - self.font_color = font_color + super().__init__(ui_group=ui_group) + self.rect = pg.Rect(0, 0, 200, 6) - self.on_change = lambda: None + @property + def value(self) -> float: + return self._value - def _hit(self, pos): - return self.rect.inflate(self.circle_radius + 10, self.circle_radius + 10).collidepoint(pos) + @value.setter + def value(self, value: float) -> None: + if not math.isfinite(value): + raise ValueError("Slider value must be finite") + value = max(self.min, min(self.max, value)) + if value != self.max: + steps = round((value - self.min) / self.step) + value = min(self.max, round(self.min + steps * self.step, self._decimals)) + self._value = value - def handle_event(self, event): - if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + @property + def fraction(self) -> float: + return (self._value - self.min) / (self.max - self.min) + + @property + def value_text(self) -> str: + """The value as shown by ``show_value``, with as many decimals as ``step`` needs.""" + return f"{self._value:.{self._decimals}f}" + + def _knob_radius(self) -> int: + radius = self.rect.h // 2 + 5 + return radius + 2 if self.hovered or self.dragging else radius + + def _hit(self, pos: tuple[int, int]) -> bool: + reach = self.rect.h // 2 + 7 + return self.rect.inflate(2 * reach, 2 * reach - self.rect.h).collidepoint(pos) + + def _change(self, value: float) -> None: + old = self._value + self.value = value + if self._value != old and self.on_change is not None: + self.on_change(self._value) + + def _change_from_x(self, x: int) -> None: + if self.rect.w > 0: + fraction = (x - self.rect.x) / self.rect.w + self._change(self.min + fraction * (self.max - self.min)) + + def _nudge(self, steps: int) -> None: + position = (self._value - self.min) / self.step + position = math.floor(position + 1e-9) if steps > 0 else math.ceil(position - 1e-9) + self._change(self.min + (position + steps) * self.step) + + def handle_event(self, event: pg.event.Event) -> bool: + if event.type == pg.WINDOWFOCUSLOST: + self.dragging = self.hovered = False + elif event.type == pg.MOUSEMOTION: + self.hovered = self._hit(event.pos) + if self.dragging: + self._change_from_x(event.pos[0]) + elif event.type == pg.MOUSEBUTTONDOWN and event.button == 1: self.dragging = self._hit(event.pos) if self.dragging: - self._value_from_position(event.pos[0]) + self._change_from_x(event.pos[0]) 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]) + elif event.type == pg.MOUSEBUTTONUP and event.button == 1 and self.dragging: + self._change_from_x(event.pos[0]) self.dragging = False - return was_dragging - if event.type == pygame.WINDOWFOCUSLOST: - self.dragging = False - elif event.type == pygame.MOUSEMOTION: - 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 True + elif event.type == pg.KEYDOWN and (self.hovered or self.dragging): + if event.key in (pg.K_LEFT, pg.K_RIGHT): + self._nudge(1 if event.key == pg.K_RIGHT else -1) + elif event.key in (pg.K_HOME, pg.K_END): + self._change(self.min if event.key == pg.K_HOME else self.max) + else: + return False + return True return False - def _value_from_position(self, x): - if self.rect.w > 0: - self.change_value_to(self.min + (x - self.rect.x) / self.rect.w * (self.max - self.min)) - - @property - def fraction(self): - return (self.current_value - self.min) / (self.max - self.min) - - def change_value_to(self, value): - if not math.isfinite(value): - raise ValueError("Slider value must be finite") - value = max(self.min, min(self.max, value)) - if value not in (self.min, self.max): - value = self.min + round((value - self.min) / self.step) * self.step - value = max(self.min, min(self.max, value)) - if value != self.current_value: - self.current_value = value - self.on_change() - - def draw_bar(self, win): - """Draws the completed part and the in progress part of the slider""" - # Draw the completed part - pygame.draw.rect( - win, - COLOR_COMPLETED, - (self.rect.x, self.rect.y, self.rect.w * self.fraction, self.rect.h), - ) - # Draw the in progress part - pygame.draw.rect( - win, - COLOR_IN_PROGRESS, - ( - self.rect.x + self.rect.w * self.fraction, - self.rect.y, - self.rect.w * (1 - self.fraction), - self.rect.h, - ), - ) - - def draw_circle(self, win): - - # Draw the circle - value_pos = self.rect.x + self.rect.w * self.fraction - y = self.rect.y + self.rect.h // 2 - - rad_size = self.circle_radius if not self.dragging else 15 - pygame.draw.circle(win, COLOR_CIRCLE1, (value_pos, y), rad_size) - pygame.draw.circle(win, COLOR_CIRCLE2, (value_pos, y), rad_size - 2) - - def draw_value(self, win): - x, y, w, h = self.rect - - if "." in str(self.current_value): - value = round(self.current_value, 2) - else: - value = int(self.current_value) - - text = self.font.render(str(value), True, self.font_color) - win.blit(text, (x + w + 10, y + h // 2 - text.get_height() // 2)) - - def draw(self, win, x, y, w=100, h=5): - if w <= 0 or h <= 0: + def draw(self, surface: pg.Surface, x: int, y: int, width: int = 200, height: int = 6) -> None: + if width <= 0 or height <= 0: raise ValueError("Slider dimensions must be positive") - self.rect = pygame.Rect(x, y, w, h) - self.draw_bar(win) - self.draw_circle(win) + self.rect = pg.Rect(x, y, width, height) + knob_x = x + round(width * self.fraction) + pg.draw.rect(surface, self.track_color, self.rect, border_radius=height // 2) + filled = pg.Rect(x, y, knob_x - x, height) + pg.draw.rect(surface, self.fill_color, filled, border_radius=height // 2) + center = (knob_x, self.rect.centery) + radius = self._knob_radius() + pg.draw.circle(surface, self.fill_color, center, radius) + pg.draw.circle(surface, self.knob_color, center, radius - 2) if self.show_value: - self.draw_value(win) - - def get_value(self): - return self.current_value - - def connect( - self, - f, - when="on_change", - ): - """ - Connect a function to the slider events - :param f: function to call when the WHEN event is triggered - :param when: "on_change" only atm - """ - if when == "on_change": - self.on_change = f + text = self.font.render(self.value_text, True, self.text_color) + reach = height // 2 + 7 + surface.blit(text, text.get_rect(midleft=(self.rect.right + reach + 6, center[1]))) diff --git a/src/PygameUIKit/text_input.py b/src/PygameUIKit/text_input.py index 00f2414..11fbb5a 100644 --- a/src/PygameUIKit/text_input.py +++ b/src/PygameUIKit/text_input.py @@ -1,87 +1,121 @@ -import pygame as pg - -from .utils import get_font -from .widget import Widget +from collections.abc import Callable -COLOR_INACTIVE = pg.Color("lightskyblue3") -COLOR_ACTIVE = pg.Color("dodgerblue2") -TIME_OUT_FIRST = 500 -TIME_OUT = 40 -SMALL_FONT = None +import pygame as pg +from pygame.typing import ColorLike +from . import utils +from .widget import Group, Widget -def is_char(text): - return bool(text) and text.isprintable() +PAD_X, PAD_Y = 8, 6 +REPEAT_DELAY = 0.5 +REPEAT_INTERVAL = 0.04 +REPEATING_KEYS = (pg.K_BACKSPACE, pg.K_DELETE, pg.K_LEFT, pg.K_RIGHT) class TextInput(Widget): - """Single-line Unicode input. Send both KEYDOWN and TEXTINPUT events to it.""" + """A fixed-width, single-line Unicode text field. + + ``name = TextInput(placeholder="Name", on_submit=print, ui_group=ui)``; + ``name.draw(screen, x, y)`` every frame. Click it to focus it, type, press Enter to call + ``on_submit(text)`` or Escape to unfocus. ``on_change(text)`` runs after every edit; setting + ``text`` does not call it. Forward both ``KEYDOWN`` and ``TEXTINPUT`` events. + """ cursor = pg.SYSTEM_CURSOR_IBEAM def __init__( self, + text: str = "", *, - text="", - font=None, - fixed_width=None, - font_color=(0, 0, 0), - border_radius=0, - placeholder="", - ui_group=None, + placeholder: str = "", + width: int = 200, + font: pg.font.Font | None = None, + text_color: ColorLike = "black", + placeholder_color: ColorLike = (150, 150, 150), + background_color: ColorLike = "white", + border_color: ColorLike = (170, 170, 170), + focus_color: ColorLike = (59, 130, 246), + border_radius: int = 4, + on_change: Callable[[str], None] | None = None, + on_submit: Callable[[str], None] | None = None, + ui_group: Group | None = None, ): - super().__init__(ui_group=ui_group) - if fixed_width is not None and fixed_width < 12: - raise ValueError("fixed_width must be at least 12 pixels") - self.color = COLOR_INACTIVE - self.font_color = pg.Color(font_color) - self.text = 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 + if width < 2 * PAD_X + 4: + raise ValueError(f"width must be at least {2 * PAD_X + 4} pixels") + if border_radius < 0: + raise ValueError("border_radius must not be negative") self.placeholder = placeholder - self.bg_trans = 0 + self.font = font if font is not None else utils.get_font(24) + self.text_color = pg.Color(text_color) + self.placeholder_color = pg.Color(placeholder_color) + self.background_color = pg.Color(background_color) + self.border_color = pg.Color(border_color) + self.focus_color = pg.Color(focus_color) + self.border_radius = border_radius + self.on_change = on_change + self.on_submit = on_submit + self._focused = False self._scroll = 0 + self._repeat_key: int | None = None + self._repeat_elapsed = 0.0 + self._repeat_delay = REPEAT_DELAY + super().__init__(ui_group=ui_group) + self.rect = pg.Rect(0, 0, width, self.font.get_height() + 2 * PAD_Y) + self.text = text + + @property + def text(self) -> str: + return self._text + + @text.setter + def text(self, value: str) -> None: + self._text = value + self.caret = len(value) + self._scroll_to_caret() + + @property + def focused(self) -> bool: + return self._focused + + @focused.setter + def focused(self, value: bool) -> None: + if value and not self._focused: + pg.key.start_text_input() + pg.key.set_text_input_rect(self.rect) + self._focused = bool(value) self._repeat_key = None - self._repeat_elapsed = 0 - self._repeat_delay = TIME_OUT_FIRST / 1000 - self.rect = pg.Rect(0, 0, fixed_width or 12, self.font.get_height() + 10) - self._render() @property - def event_priority(self): + def event_priority(self) -> int: # While focused, see clicks first so clicking another widget still unfocuses. - return int(self.active) + return int(self._focused) - def handle_event(self, event): + def handle_event(self, event: pg.event.Event) -> bool: if event.type == pg.MOUSEBUTTONDOWN and event.button == 1: - self.active = self.rect.collidepoint(event.pos) - self.color = COLOR_ACTIVE if self.active else COLOR_INACTIVE - self._repeat_key = None - if self.active: - pg.key.start_text_input() - pg.key.set_text_input_rect(self.rect) - return self.active + self.focused = self.rect.collidepoint(event.pos) + return self._focused 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: + self.focused = self.hovered = False + if not self._focused: return False if event.type == pg.TEXTINPUT: - self.add_letter("".join(char for char in event.text if char.isprintable())) + self._insert("".join(char for char in event.text if char.isprintable())) elif event.type == pg.KEYDOWN: if getattr(event, "repeat", False): 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 + if event.key in (pg.K_RETURN, pg.K_KP_ENTER): + if self.on_submit is not None: + self.on_submit(self._text) + elif event.key == pg.K_ESCAPE: + self.focused = False + else: + self._press(event.key) + if event.key in REPEATING_KEYS: + self._repeat_key = event.key + self._repeat_elapsed = 0.0 + self._repeat_delay = REPEAT_DELAY elif event.type == pg.KEYUP: if event.key == self._repeat_key: self._repeat_key = None @@ -89,85 +123,67 @@ def handle_event(self, event): return False return True - def update(self, dt): - if not self.active or self._repeat_key is None: + def update(self, dt: float) -> None: + if not self._focused or self._repeat_key is None: return self._repeat_elapsed += max(0, dt) while self._repeat_elapsed >= self._repeat_delay: self._repeat_elapsed -= self._repeat_delay - self._repeat_delay = TIME_OUT / 1000 - self.handle_key(self._repeat_key) - - def add_letter(self, 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() + self._repeat_delay = REPEAT_INTERVAL + self._press(self._repeat_key) + + def _edit(self, text: str, caret: int) -> None: + changed = text != self._text + self._text = text + self.caret = caret + self._scroll_to_caret() + if changed and self.on_change is not None: + self.on_change(text) + + def _insert(self, text: str) -> None: + before, after = self._text[: self.caret], self._text[self.caret :] + self._edit(before + text + after, self.caret + len(text)) + + def _press(self, key: int) -> None: + text, caret = self._text, self.caret + if key == pg.K_BACKSPACE and caret: + self._edit(text[: caret - 1] + text[caret:], caret - 1) elif key == pg.K_DELETE: - self.text = self.text[: self.caret] + self.text[self.caret + 1 :] - elif key == pg.K_LEFT: - self.caret = max(0, self.caret - 1) - elif key == pg.K_RIGHT: - self.caret = min(len(self.text), self.caret + 1) - elif key == pg.K_HOME: - self.caret = 0 - elif key == pg.K_END: - self.caret = len(self.text) - self._render() - - def remove_letter(self): - if self.caret: - self.text = self.text[: self.caret - 1] + self.text[self.caret :] - self.caret -= 1 - self._render() - - def _render(self): - 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.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) - self._scroll = min(self._scroll, max(0, self.txt_surface.get_width() - visible_width)) - - def draw(self, screen, x, y): + self._edit(text[:caret] + text[caret + 1 :], caret) + elif key in (pg.K_LEFT, pg.K_RIGHT, pg.K_HOME, pg.K_END): + moves = {pg.K_LEFT: caret - 1, pg.K_RIGHT: caret + 1, pg.K_HOME: 0} + self._edit(text, moves.get(key, len(text))) + + def _scroll_to_caret(self) -> None: + self.caret = max(0, min(len(self._text), self.caret)) + caret_x = self.font.size(self._text[: self.caret])[0] + visible = self.rect.w - 2 * PAD_X - 1 + self._scroll = max(0, min(self._scroll, caret_x)) + self._scroll = max(self._scroll, caret_x - visible) + self._scroll = min(self._scroll, max(0, self.font.size(self._text)[0] - visible)) + + def draw(self, surface: pg.Surface, x: int, y: int) -> None: self.rect.topleft = (x, y) - 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, - (255, 255, 255, self.bg_trans), - surface.get_rect(), - border_radius=self.border_radius, - ) - pg.draw.rect( - surface, self.color, surface.get_rect(), width=1, border_radius=self.border_radius - ) - surface.set_clip(pg.Rect(5, 5, self.rect.width - 10, self.rect.height - 10)) - if self.text: - surface.blit(self.txt_surface, (5 - self._scroll, 5)) + box = pg.Surface(self.rect.size, pg.SRCALPHA) + area = box.get_rect() + pg.draw.rect(box, self.background_color, area, border_radius=self.border_radius) + if self._focused: + border, thickness = self.focus_color, 2 else: - self.draw_placeholder(surface) - if self.active: - 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)) + border = utils.shade(self.border_color, 0.25) if self.hovered else self.border_color + thickness = 1 + pg.draw.rect(box, 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 + if self._text: + rendered = self.font.render(self._text, True, self.text_color) + box.blit(rendered, (PAD_X - self._scroll, text_y)) + elif self.placeholder: + box.blit( + self.font.render(self.placeholder, True, self.placeholder_color), (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)) pg.key.set_text_input_rect(self.rect) - screen.blit(surface, self.rect) - - def draw_placeholder(self, surface): - placeholder = self.font.render(self.placeholder, True, self.font_color) - placeholder.set_alpha(100) - surface.blit(placeholder, (5, 5)) - - def get_text(self): - return self.text - - def set_text(self, text): - self.text = text - self.caret = len(text) - self._render() + surface.blit(box, self.rect) diff --git a/src/PygameUIKit/widget.py b/src/PygameUIKit/widget.py index a713e7d..989dfe5 100644 --- a/src/PygameUIKit/widget.py +++ b/src/PygameUIKit/widget.py @@ -55,8 +55,17 @@ def _by_priority(self) -> list[Widget]: 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()) + """Dispatch until a widget consumes the event; return True if one did. + + A widget consumes ``MOUSEMOTION`` when the pointer is over it and it covers what is + below (an open dropdown menu), so the remaining widgets are marked not hovered. + """ + consumed = False + for widget in self._by_priority(): + if not consumed: + consumed = widget.handle_event(event) + elif event.type == pg.MOUSEMOTION: + widget.hovered = False if event.type == pg.MOUSEMOTION: self._update_cursor() return consumed diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 228103b..755c50e 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -4,10 +4,7 @@ import pygame as pg import pytest -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 +from PygameUIKit import BarChart, Dropdown, Group, Label, Widget, button, slider, text_input, utils from PygameUIKit.utils import best_contrast_color, lighter_color, load_image, shade @@ -35,7 +32,7 @@ def test_import_does_not_initialize_pygame(): lambda: Label("Hello"), lambda: slider.Slider(0, 10, 1), lambda: text_input.TextInput(), - lambda: ComboBox(["Hello", "World"]), + lambda: Dropdown(["Hello", "World"]), lambda: BarChart([1, 2]), ], ) @@ -72,119 +69,6 @@ def test_button_without_callback_and_image_without_hover(display): widget.handle_event(mouse(pg.MOUSEBUTTONUP)) -def test_text_input_through_group_unicode_editing_and_focus(display): - group = Group() - widget = text_input.TextInput(text="ab", fixed_width=100, ui_group=group) - widget.draw(display, 10, 10) - group.handle_event(mouse(pg.MOUSEBUTTONDOWN)) - 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.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世" - group.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_DELETE)) - assert widget.get_text() == "ab" - group.handle_event(mouse(pg.MOUSEBUTTONDOWN, (400, 400))) - group.handle_event(pg.event.Event(pg.TEXTINPUT, text="ignored")) - assert widget.get_text() == "ab" - - -def test_text_input_repeat_and_scroll(display): - widget = text_input.TextInput(text="a" * 100, fixed_width=80) - widget.draw(display, 10, 10) - widget.handle_event(mouse(pg.MOUSEBUTTONDOWN)) - widget.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_BACKSPACE)) - widget.update(0.6) - assert len(widget.text) < 99 - widget.handle_event(pg.event.Event(pg.KEYUP, key=pg.K_BACKSPACE)) - text = widget.text - widget.update(1) - assert widget.text == text - widget.handle_key(pg.K_HOME) - assert widget._scroll == 0 - widget.handle_key(pg.K_END) - assert widget._scroll > 0 - widget.draw(display, 10, 10) - - -@pytest.mark.parametrize("low,high", [(10, 20), (-10, 0), (-20, -10), (0, 1)]) -def test_slider_uses_actual_range_and_clamps(low, high, display): - widget = slider.Slider(low, high, (high - low) / 10) - widget.draw(display, 100, 100, 200, 5) - assert widget.fraction == 0 - widget.handle_event(mouse(pg.MOUSEBUTTONDOWN, (200, 102))) - assert widget.get_value() == pytest.approx((low + high) / 2) - assert widget.fraction == pytest.approx(0.5) - widget.handle_event(mouse(pg.MOUSEMOTION, (500, 102))) - assert widget.get_value() == high - widget.handle_event(mouse(pg.MOUSEBUTTONUP, (0, 102))) - assert widget.get_value() == low - widget.change_value_to(high + 100) - assert widget.get_value() == high - widget.draw(display, 100, 100, 200, 5) - - -def test_slider_steps_are_relative_to_minimum(): - widget = slider.Slider(3, 14, 2) - widget.change_value_to(7) - assert widget.get_value() == 7 - widget.change_value_to(100) - assert widget.get_value() == 14 - - -@pytest.mark.parametrize("args", [(0, 0, 1), (2, 1, 1), (0, 1, 0), (0, 1, -1)]) -def test_slider_rejects_invalid_ranges(args): - with pytest.raises(ValueError): - slider.Slider(*args) - - -def test_combobox_selection_and_callback_observe_current_value(display): - group = Group() - widget = ComboBox(["One", "A much longer second option"], ui_group=group) - widget.draw(display, 10, 10) - assert widget.rect.width >= widget.font.size(widget.elements[1])[0] + 40 - values = [] - widget.add_action(1, lambda: values.append((widget.get_value(), widget.text))) - group.handle_event(mouse(pg.MOUSEBUTTONDOWN)) - group.handle_event(mouse(pg.MOUSEBUTTONUP)) - assert widget.is_open - widget.draw(display, 10, 10) - pos = (20, 10 + 2 * widget.rect.height + 5) - group.handle_event(mouse(pg.MOUSEBUTTONDOWN, pos)) - group.handle_event(mouse(pg.MOUSEBUTTONUP, pos)) - assert widget.selected_index == 1 - assert values == [(widget.elements[1], widget.elements[1])] - assert not widget.is_open - - -def test_combobox_rejects_empty_and_bad_indices(): - with pytest.raises(ValueError): - ComboBox([]) - with pytest.raises(IndexError): - ComboBox(["One"]).change_selected(-1) - - -@pytest.mark.parametrize("values", [[], [0], [0, 0], [5, 10]]) -def test_chart_handles_empty_zero_and_narrow_draws(values, display): - widget = BarChart(values) - widget.draw(display, 0, 0, 2, 100) - if values: - widget.change_value(0, 100) - widget.update(1) - widget.draw(display, 0, 0, 300, 100) - assert widget.values_displayed[0] == 100 - - -@pytest.mark.parametrize( - "kwargs", [dict(values=[-1]), dict(values=[1], max_value=0), dict(values=[1], labels=[])] -) -def test_chart_validation(kwargs): - with pytest.raises(ValueError): - BarChart(**kwargs) - - def test_color_helpers(): assert lighter_color(pg.Color(0, 0, 0, 10), 50).a == 0 assert best_contrast_color("white") == pg.Color("black") @@ -235,7 +119,7 @@ def test_handle_event_reports_consumption(display): 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 = text_input.TextInput(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)) @@ -252,14 +136,14 @@ def test_clicking_a_button_unfocuses_text_input(display): group = Group() calls = [] btn = button.Button("Click", lambda: calls.append(True), ui_group=group) - field = text_input.TextInput(fixed_width=100, ui_group=group) + field = text_input.TextInput(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 + assert field.focused group.handle_event(mouse(pg.MOUSEBUTTONDOWN)) group.handle_event(mouse(pg.MOUSEBUTTONUP)) - assert not field.active + assert not field.focused assert calls == [True] @@ -268,7 +152,7 @@ def test_group_sets_cursor_of_hovered_widget(display, monkeypatch): monkeypatch.setattr(utils, "set_system_cursor", cursors.append) group = Group() btn = button.Button("Click", ui_group=group) - field = text_input.TextInput(fixed_width=100, ui_group=group) + field = text_input.TextInput(width=100, ui_group=group) Label("Label", ui_group=group).draw(display, 10, 200) btn.draw(display, 10, 10) field.draw(display, 10, 100) @@ -294,29 +178,6 @@ def test_widget_base_defaults(): group.update(0.1) -def test_dropdown_consumes_clicks_over_other_widgets(display): - group = Group() - calls = [] - underneath = button.Button("Under menu", lambda: calls.append(True), ui_group=group) - menu = ComboBox(["First", "Second"], ui_group=group) - menu.draw(display, 10, 10) - underneath.draw(display, 10, 10 + menu.rect.height) - menu.is_open = True - pos = (20, underneath.rect.y + 5) - group.handle_event(mouse(pg.MOUSEBUTTONDOWN, pos)) - group.handle_event(mouse(pg.MOUSEBUTTONUP, pos)) - assert not menu.is_open - assert calls == [] - - -def test_dropdown_does_not_select_on_unmatched_release(display): - menu = ComboBox(["First", "Second"]) - menu.draw(display, 10, 10) - menu.is_open = True - menu.handle_event(mouse(pg.MOUSEBUTTONUP, (20, 15 + 2 * menu.rect.height))) - assert menu.selected_index == 0 - - def test_button_focus_loss_cancels_click(display): calls = [] widget = button.Button("Click", lambda: calls.append(True)) @@ -481,3 +342,378 @@ def test_label_rerenders_only_on_change(): assert label.surface is not surface and label.color == pg.Color("blue") with pytest.raises(ValueError): Label("Hi", align="justify") + + +@pytest.mark.parametrize("low,high", [(10, 20), (-10, 0), (-20, -10), (0, 1)]) +def test_slider_uses_actual_range_and_clamps(low, high, display): + values = [] + widget = slider.Slider(low, high, (high - low) / 10, on_change=values.append) + widget.draw(display, 100, 100, 200, 6) + assert widget.fraction == 0 + assert widget.handle_event(mouse(pg.MOUSEBUTTONDOWN, (200, 103))) + assert widget.value == pytest.approx((low + high) / 2) + assert widget.fraction == pytest.approx(0.5) + widget.handle_event(mouse(pg.MOUSEMOTION, (500, 103))) + assert widget.value == high + assert widget.handle_event(mouse(pg.MOUSEBUTTONUP, (0, 103))) + assert widget.value == low + assert values == [pytest.approx((low + high) / 2), high, low] + widget.value = high + 100 + assert widget.value == high and len(values) == 3 + widget.draw(display, 100, 100, 200, 6) + + +def test_slider_steps_are_relative_to_minimum(): + widget = slider.Slider(3, 14, 2, value=7) + assert widget.value == 7 + widget.value = 8.2 + assert widget.value == 9 + widget.value = 100 + assert widget.value == 14 + assert slider.Slider(0, 10).value == 0 + + +@pytest.mark.parametrize( + "args,kwargs", + [ + ((0, 0, 1), {}), + ((2, 1, 1), {}), + ((0, 1, 0), {}), + ((0, 1, -1), {}), + ((0, 1), {"value": float("nan")}), + ], +) +def test_slider_rejects_invalid_arguments_before_joining_group(args, kwargs): + group = Group() + with pytest.raises(ValueError): + slider.Slider(*args, ui_group=group, **kwargs) + assert group.widgets == [] + + +def test_slider_on_change_fires_only_for_new_values_from_interaction(display): + values = [] + widget = slider.Slider(0, 10, value=4, on_change=values.append) + widget.draw(display, 0, 100, 100, 6) + widget.value = 6 + assert values == [] + widget.handle_event(mouse(pg.MOUSEBUTTONDOWN, (60, 103))) + widget.handle_event(mouse(pg.MOUSEMOTION, (61, 103))) + widget.handle_event(mouse(pg.MOUSEBUTTONUP, (61, 103))) + assert values == [] + widget.handle_event(mouse(pg.MOUSEBUTTONDOWN, (80, 103))) + assert values == [8] + widget.handle_event(pg.event.Event(pg.WINDOWFOCUSLOST)) + widget.handle_event(mouse(pg.MOUSEMOTION, (90, 300))) + assert values == [8] and not widget.dragging + + +def test_slider_keys_step_while_hovered(display): + values = [] + widget = slider.Slider(3, 14, 2, value=13, on_change=values.append) + widget.draw(display, 0, 100, 100, 6) + right = pg.event.Event(pg.KEYDOWN, key=pg.K_RIGHT) + assert not widget.handle_event(right) + radius = widget._knob_radius() + widget.handle_event(mouse(pg.MOUSEMOTION, (50, 103))) + assert widget._knob_radius() > radius + assert widget.handle_event(right) + assert widget.value == 14 + assert widget.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_LEFT)) + assert widget.value == 13 + widget.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_HOME)) + widget.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_LEFT)) + assert widget.value == 3 + widget.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_END)) + assert values == [14, 13, 3, 14] + assert not widget.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_a)) + + +@pytest.mark.parametrize( + "args,value,text", + [ + ((0, 100, 5), 50, "50"), + ((0, 1, 0.1), 0.3, "0.3"), + ((0, 1, 0.25), 0.5, "0.50"), + ((0.5, 10, 1), 2.5, "2.5"), + ((0, 1e-4, 1e-5), 3e-5, "0.00003"), + ((0, 10, 2.0), 4, "4"), + ], +) +def test_slider_value_text_uses_step_precision(args, value, text, display): + widget = slider.Slider(*args, value=value, show_value=True) + assert widget.value_text == text + widget.draw(display, 10, 10) + + +def test_slider_snaps_without_float_drift(): + widget = slider.Slider(0, 1, 0.1) + widget._nudge(1) + widget._nudge(1) + widget._nudge(1) + assert widget.value == 0.3 + assert isinstance(slider.Slider(0, 100, value=42.4).value, int) + + +def test_text_input_through_group_unicode_editing_and_focus(display): + group = Group() + changes = [] + widget = text_input.TextInput("ab", width=100, on_change=changes.append, ui_group=group) + widget.draw(display, 10, 10) + group.handle_event(mouse(pg.MOUSEBUTTONDOWN)) + assert widget.focused and widget.event_priority == 1 + group.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_e, unicode="é")) + group.handle_event(pg.event.Event(pg.TEXTINPUT, text="é世")) + assert widget.text == "abé世" + 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.text == "ab世" + group.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_DELETE)) + assert widget.text == "ab" + group.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_DELETE)) + assert changes == ["abé世", "ab世", "ab"] + group.handle_event(mouse(pg.MOUSEBUTTONDOWN, (400, 400))) + assert not widget.focused + group.handle_event(pg.event.Event(pg.TEXTINPUT, text="ignored")) + assert widget.text == "ab" + + +def test_text_input_repeat_and_scroll(display): + widget = text_input.TextInput("a" * 100, width=80) + widget.draw(display, 10, 10) + widget.handle_event(mouse(pg.MOUSEBUTTONDOWN)) + widget.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_BACKSPACE)) + widget.update(0.6) + assert len(widget.text) < 99 + widget.handle_event(pg.event.Event(pg.KEYUP, key=pg.K_BACKSPACE)) + text = widget.text + widget.update(1) + assert widget.text == text + widget.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_HOME)) + assert widget._scroll == 0 + widget.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_END)) + assert widget._scroll > 0 + assert widget.rect.w == 80 + widget.draw(display, 10, 10) + + +def test_text_input_enter_submits_and_escape_unfocuses(display): + submitted, changes = [], [] + widget = text_input.TextInput(on_submit=submitted.append, on_change=changes.append) + widget.draw(display, 10, 10) + enter = pg.event.Event(pg.KEYDOWN, key=pg.K_RETURN) + assert not widget.handle_event(enter) + widget.focused = True + widget.handle_event(pg.event.Event(pg.TEXTINPUT, text="hi")) + assert widget.handle_event(enter) + assert submitted == ["hi"] and widget.focused + assert widget.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_ESCAPE)) + assert not widget.focused and widget.event_priority == 0 + assert changes == ["hi"] + widget.on_submit = None + widget.focused = True + widget.handle_event(enter) + + +def test_text_input_text_setter_moves_caret_without_callback(display): + changes = [] + widget = text_input.TextInput("start", placeholder="Name", on_change=changes.append) + assert widget.caret == 5 + widget.draw(display, 10, 10) + widget.text = "" + widget.draw(display, 10, 10) + widget.text = "hello" + assert widget.caret == 5 and changes == [] + assert text_input.TextInput(width=300).rect.w == 300 + + +@pytest.mark.parametrize("kwargs", [dict(width=10), dict(border_radius=-1)]) +def test_text_input_validation_happens_before_joining_group(kwargs): + group = Group() + with pytest.raises(ValueError): + text_input.TextInput(ui_group=group, **kwargs) + assert group.widgets == [] + + +def option_pos(menu, index): + return menu._row_rect(index).center + + +def test_dropdown_selection_and_callback_observe_current_value(display): + group = Group() + widget = Dropdown(["One", "A much longer second option"], ui_group=group) + widget.draw(display, 10, 10) + assert widget.rect.width >= widget.font.size(widget.options[1])[0] + 30 + values = [] + widget.on_change = lambda value: values.append((value, widget.value, widget.index)) + click(widget) + assert widget.is_open and widget.event_priority == 1 + widget.draw(display, 10, 10) + pos = option_pos(widget, 1) + group.handle_event(mouse(pg.MOUSEBUTTONDOWN, pos)) + group.handle_event(mouse(pg.MOUSEBUTTONUP, pos)) + assert values == [(widget.options[1], widget.options[1], 1)] + assert not widget.is_open and widget.event_priority == 0 + click(widget) + click(widget, option_pos(widget, 1)) + assert len(values) == 1 + + +def test_dropdown_setters_do_not_fire_and_validate(): + values = [] + widget = Dropdown(["A", "B", "C"], index=2, on_change=values.append) + assert widget.value == "C" + widget.index = 0 + widget.value = "B" + assert widget.index == 1 and values == [] + with pytest.raises(IndexError): + widget.index = 3 + with pytest.raises(ValueError): + widget.value = "D" + group = Group() + for kwargs in (dict(options=[]), dict(options=["A"], width=0)): + with pytest.raises(ValueError): + Dropdown(ui_group=group, **kwargs) + with pytest.raises(IndexError): + Dropdown(["A"], index=-1, ui_group=group) + assert group.widgets == [] + assert Dropdown(["A"], width=150).rect.w == 150 + + +def test_dropdown_consumes_clicks_over_other_widgets(display): + group = Group() + calls = [] + underneath = button.Button("Under menu", lambda: calls.append(True), ui_group=group) + menu = Dropdown(["First", "Second"], ui_group=group) + menu.draw(display, 10, 10) + underneath.draw(display, 10, 10 + menu.rect.height + 8) + click(menu) + pos = option_pos(menu, 0) + assert underneath.rect.collidepoint(pos) + assert group.handle_event(mouse(pg.MOUSEBUTTONDOWN, pos)) + assert group.handle_event(mouse(pg.MOUSEBUTTONUP, pos)) + assert not menu.is_open + assert calls == [] + click(menu) + assert group.handle_event(mouse(pg.MOUSEBUTTONDOWN, pos, button_number=3)) + assert menu.is_open + + +def test_dropdown_does_not_select_on_unmatched_release(display): + menu = Dropdown(["First", "Second"]) + menu.draw(display, 10, 10) + click(menu) + assert menu.handle_event(mouse(pg.MOUSEBUTTONUP, option_pos(menu, 1))) + assert menu.index == 0 and menu.is_open + + +def test_dropdown_closes_on_outside_click_escape_and_focus_loss(display): + group = Group() + calls = [] + menu = Dropdown(["First", "Second"], ui_group=group) + outside = button.Button("Outside", lambda: calls.append(True), ui_group=group) + menu.draw(display, 10, 10) + outside.draw(display, 400, 10) + click(menu) + group.handle_event(mouse(pg.MOUSEBUTTONDOWN, (410, 15))) + group.handle_event(mouse(pg.MOUSEBUTTONUP, (410, 15))) + assert not menu.is_open and calls == [True] + escape = pg.event.Event(pg.KEYDOWN, key=pg.K_ESCAPE) + assert not menu.handle_event(escape) + click(menu) + assert menu.handle_event(escape) + assert not menu.is_open + click(menu) + menu.handle_event(pg.event.Event(pg.WINDOWFOCUSLOST)) + assert not menu.is_open + + +def test_dropdown_keyboard_navigation(display): + values = [] + menu = Dropdown(["A", "B", "C"], on_change=values.append) + menu.draw(display, 10, 10) + down = pg.event.Event(pg.KEYDOWN, key=pg.K_DOWN) + enter = pg.event.Event(pg.KEYDOWN, key=pg.K_RETURN) + assert not menu.handle_event(down) + click(menu) + assert menu.highlighted == 0 + for _ in range(5): + assert menu.handle_event(down) + assert menu.highlighted == 2 + menu.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_UP)) + menu.draw(display, 10, 10) + assert menu.handle_event(enter) + assert values == ["B"] and not menu.is_open + assert not menu.handle_event(enter) + + +def test_open_dropdown_occludes_hover_and_sets_cursor(display, monkeypatch): + cursors = [] + monkeypatch.setattr(utils, "set_system_cursor", cursors.append) + group = Group() + field = text_input.TextInput(width=200, ui_group=group) + menu = Dropdown(["First", "Second"], ui_group=group) + menu.draw(display, 10, 10) + field.draw(display, 10, menu.rect.bottom + 8) + pos = option_pos(menu, 0) + assert field.rect.collidepoint(pos) + group.handle_event(mouse(pg.MOUSEMOTION, pos)) + assert field.hovered and cursors == [pg.SYSTEM_CURSOR_IBEAM] + click(menu) + assert group.handle_event(mouse(pg.MOUSEMOTION, pos)) + assert not field.hovered and menu.hovered and menu.highlighted == 0 + assert cursors[-1] == pg.SYSTEM_CURSOR_HAND + menu.draw(display, 10, 10) + assert not group.handle_event(mouse(pg.MOUSEMOTION, (500, 500))) + assert not menu.hovered and menu.highlighted is None + assert cursors[-1] == pg.SYSTEM_CURSOR_ARROW + + +@pytest.mark.parametrize("values", [[], [0], [0, 0], [5, 10]]) +def test_chart_handles_empty_zero_and_narrow_draws(values, display): + widget = BarChart(values) + widget.draw(display, 0, 0, 2, 100) + if values: + widget.set_value(0, 100) + widget.update(1) + widget.draw(display, 0, 0, 300, 100) + assert widget.values_displayed[0] == 100 + + +@pytest.mark.parametrize( + "kwargs", + [ + dict(values=[-1]), + dict(values=[1], max_value=0), + dict(values=[1], labels=[]), + dict(values=[1], colors=[]), + dict(values=[1], speed=0), + ], +) +def test_chart_validation_happens_before_joining_group(kwargs): + group = Group() + with pytest.raises(ValueError): + BarChart(ui_group=group, **kwargs) + assert group.widgets == [] + with pytest.raises(ValueError): + BarChart([1]).set_value(0, float("inf")) + + +def test_chart_values_setter_and_animation_speed(display): + widget = BarChart([10, 20], speed=10) + colors = widget.colors + widget.values = [30, 0] + assert widget.values == (30, 0) and widget.colors == colors + widget.update(0.5) + assert widget.values_displayed == [15, 15] + widget.values = [1, 2, 3] + assert widget.values_displayed == [15, 15, 0] + assert len(widget.colors) == 3 and widget.colors[:2] != colors + widget.update(10) + assert widget.values_displayed == [1, 2, 3] + custom = BarChart([1], colors=["red"], labels=["A"]) + custom.values = [1, 2] + assert custom.colors == [pg.Color("red")] + custom.draw(display, 0, 0) + with pytest.raises(ValueError): + widget.values = [1, -1] From 496f9182dbec3f728004e7b0a3d39f75badffafb Mon Sep 17 00:00:00 2001 From: Dorian Om Date: Wed, 28 Oct 2026 03:19:29 +0100 Subject: [PATCH 2/2] Document the new Slider, TextInput, Dropdown and BarChart --- CHANGELOG.md | 9 +++++++++ README.md | 10 +++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d26541..1aadbb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ - `IconButton`'s clickable area and hover backdrop are the icon plus `padding` on each side, starting at (x, y). - Add `utils.shade(color, amount)`; `utils.best_contrast_color` uses perceived brightness and returns a `pygame.Color`. - Remove the duplicate root `src/demo.py` and `src/assets/`; the packaged `pygameuikit-demo` is the demo. +- `Slider(min, max, step=1, *, value=None, on_change=None, show_value=False, font=None, fill_color, track_color, knob_color, text_color)` has a `value` property (clamped and snapped to `step`, without float drift) and draws with `draw(surface, x, y, width=200, height=6)`. The knob grows on hover and while dragging. Left/Right step it and Home/End jump to the ends while it is hovered or dragged. `show_value` uses as many decimals as `min` and `step` need (`value_text`). +- `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)` has a fixed width and an opaque background. Enter calls `on_submit(text)`, Escape unfocuses, and `on_change(text)` runs after every edit. `focused` and `text` are settable. +- `Dropdown(options, *, index=0, on_change=None, width=None, font=None, color, text_color, highlight_color, outline_color, border_radius)` replaces `ComboBox`. It draws itself (no longer a `Button` subclass) with a drawn chevron, highlights the hovered and selected options, and can be driven with Up/Down, Enter/Space and Escape while open. While open it consumes mouse events over itself and its menu; a click elsewhere, Escape or window focus loss closes it. `on_change(value)` runs only when the user picks a different option. +- When a widget consumes a `MOUSEMOTION` (an open dropdown menu under the pointer), `Group` marks the widgets after it as not hovered, so nothing underneath an open menu stays highlighted or sets the cursor. +- `BarChart(values, *, labels=None, max_value=None, colors=None, font=None, label_color="black", speed=200)`: `values` is a property (a tuple; setting it replaces all bars and regenerates automatic colors when the count changes), `set_value(index, value)` updates one bar, `colors` cycle if there are fewer than bars, and `speed` is the animation rate in value units per second. Bars are solid with rounded tops. ### Upgrading from 0.2 @@ -39,6 +44,10 @@ - `ButtonThreadImage` and `ButtonThreadText` are removed. Set `button.text` (or `image_button.image`) from your own state each frame instead, e.g. `button.text = "Searching..." if thread.is_alive() else "Find match"`. - `Label(text, font_color, font, text_align, bounding_rect)` → `Label(text, color, font=..., align=...)`. Pass the bounding width to `draw(surface, x, y, width)` instead of `bounding_rect`; the `TextAlignment` enum is removed (use `"left"`, `"center"`, `"right"`). Labels no longer add 10 px of padding: draw at `(x + 10, y + 10)` for the old placement. `label.render_text()` and `label.text_surface` → `label.surface` (re-rendered automatically). - `utils.make_surface_darker` is removed; use `utils.shade` for colors. +- Slider: `slider.get_value()` and `slider.current_value` → `slider.value`; `change_value_to(v)` → `slider.value = v` (no callback). `connect(f)` → `on_change=f` (or assign `slider.on_change`); the callback now receives the new value. `font_color` → `text_color`. `step` defaults to 1, and options after `step` are keyword-only. `draw(win, x, y, w=100, h=5)` → `draw(surface, x, y, width=200, height=6)`. `circle_radius`, `draw_bar`, `draw_circle` and `draw_value` are removed. +- TextInput: `text` is now the first positional argument. `fixed_width=None` (auto-grow) → `width=200` (always fixed). `font_color` → `text_color`. `active` → `focused`. `get_text()`/`set_text(t)` → the `text` property (setting it moves the caret to the end). `add_letter`, `remove_letter`, `handle_key`, `draw_placeholder`, `color` and `bg_trans` are removed. Enter and Escape are now handled (submit and unfocus). +- `PygameUIKit.combobox` → `PygameUIKit.dropdown`; `ComboBox(elements, font=..., font_color=...)` → `Dropdown(options, font=..., text_color=...)`. `elements` → `options` (a tuple), `selected_index` → `index`, `get_value()` → `value`, `change_selected(i)` → `index = i` (no callback). `add_action(index, f)` → one `on_change(value)` callback for every selection. `toggle_open()` and `change_hovered()` are removed; `hovered_index` → `highlighted`. The `combo_box_arrow.png` asset is removed. +- BarChart: `labels`, `max_value`, `font` and `ui_group` are keyword-only. `labels_color` → `label_color`. `change_value(index, value)` → `set_value(index, value)`. `values` is read as a tuple; assign a new sequence instead of mutating it. `CHANGE_SPEED` → the `speed` argument. ## 0.2.0 diff --git a/README.md b/README.md index 5921e82..8a29033 100644 --- a/README.md +++ b/README.md @@ -54,17 +54,17 @@ while running: pygame.quit() ``` -Add widgets with `ui_group=ui`, forward each event once, and call `ui.update(dt)` with seconds. `ui.handle_event(event)` returns `True` when a widget consumed the event (for example a button click or typing into a focused text input), so you can skip your own handling for it. The group also sets the mouse cursor for the hovered widget. Draw each widget at your chosen position. Sliders accept `draw(screen, x, y, width, height)`; charts use the same layout arguments. Draw dropdowns last so their menus appear above other widgets. +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 and charts also take `width` and `height`. Draw dropdowns last so their menus appear above other widgets. Callbacks (`on_click`, `on_change`, `on_submit`) run only for user interaction, never when you set `value` or `text` yourself. - `Button("Play", on_click, color="seagreen")`: rounded text button sized to its text; set `text` to relabel it. Options: `width` and `align`, `outline_color`, `border_radius`, `padding`, `text_color` (black or white for contrast by default). - `ImageButton(image, on_click, hover_image=None, text="")`: an image, optionally with centered text. - `IconButton(icon, on_click)`: an icon with a translucent backdrop on hover. - `IconToggle(icon_off, icon_on, on_change)`: switches icons on click and calls `on_change(value)`; read or set `value`. -- `slider.Slider(min, max, step)`: `get_value()` and `connect(callback)`. -- `combobox.ComboBox(["One", "Two"])`: `get_value()` and `add_action(index, callback)`. -- `text_input.TextInput(placeholder="Name")`: `get_text()` and `set_text(text)`. +- `Slider(0, 100, 5, value=50, on_change=callback, show_value=True)`: drag, click or use the arrow keys while hovering; read or set `value`. +- `TextInput(placeholder="Name", width=200, on_change=callback, on_submit=callback)`: Enter calls `on_submit(text)`, Escape unfocuses; read or set `text` and `focused`. +- `Dropdown(["One", "Two"], on_change=callback)`: pick with the mouse or Up/Down and Enter; read or set `value` (the option) or `index`. - `Label("Hello", "white")`: draws at exactly (x, y); `draw(screen, x, y, width)` aligns it within `width` using `align="left" | "center" | "right"`. Set `text` to change it. -- `barchart.BarChart([10, 20, 30])`: `change_value(index, value)`; accepts non-negative values. +- `BarChart([10, 20, 30], labels=["A", "B", "C"], max_value=50)`: animated bars; `set_value(index, value)` or set `values`. Values must be non-negative. Text inputs use Pygame's `TEXTINPUT` events for Unicode text and `KEYDOWN` for editing. Forward both through `Group`; don't also forward the same events separately to the input. Pass a custom `pygame.font.Font` if you need a particular font or character set.