From 5439051bf2aa01bf7b4c02b6dae4562b81865e1b Mon Sep 17 00:00:00 2001 From: Dorian Om Date: Wed, 28 Oct 2026 03:38:16 +0100 Subject: [PATCH 1/4] Add enabled and visible to every widget Widget gets enabled and visible properties. Widget.__init_subclass__ wraps each subclass's handle_event and draw once, so disabled or hidden widgets ignore events (through a Group or called directly) and hidden ones draw nothing; custom widgets get the same checks. Turning either off calls a small _reset() hook that clears hover, press, drag, focus and open menus; window focus loss now uses the same hook. Disabled widgets draw their colors through utils.disabled_color (mostly desaturated, faded toward white) and images through utils.disabled_image (greyscale, translucent). Label renders lazily so it follows its state. Group sets the cursor after every event and update, so disabling the widget under the pointer gives the arrow back immediately. --- src/PygameUIKit/barchart.py | 4 +- src/PygameUIKit/button.py | 20 +++-- src/PygameUIKit/dropdown.py | 18 ++-- src/PygameUIKit/label.py | 35 ++++---- src/PygameUIKit/slider.py | 17 ++-- src/PygameUIKit/text_input.py | 18 ++-- src/PygameUIKit/utils.py | 16 ++++ src/PygameUIKit/widget.py | 67 ++++++++++++++- tests/test_widgets.py | 154 +++++++++++++++++++++++++++++++++- 9 files changed, 299 insertions(+), 50 deletions(-) diff --git a/src/PygameUIKit/barchart.py b/src/PygameUIKit/barchart.py index bfc335e..c59c04e 100644 --- a/src/PygameUIKit/barchart.py +++ b/src/PygameUIKit/barchart.py @@ -107,9 +107,9 @@ def draw( 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)] + color = self._paint(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) + text = self.font.render(labels[i], True, self._paint(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/button.py b/src/PygameUIKit/button.py index 5383a56..ecbfc57 100644 --- a/src/PygameUIKit/button.py +++ b/src/PygameUIKit/button.py @@ -20,7 +20,7 @@ def __init__(self, on_click: Callable[[], None] | None, *, ui_group: Group | Non def handle_event(self, event: pg.event.Event) -> bool: if event.type == pg.WINDOWFOCUSLOST: - self.pressed = self.hovered = False + self._reset() elif event.type == pg.MOUSEMOTION: self.hovered = self.rect.collidepoint(event.pos) elif event.type == pg.MOUSEBUTTONDOWN and event.button == 1: @@ -33,6 +33,10 @@ def handle_event(self, event: pg.event.Event) -> bool: return True return False + def _reset(self) -> None: + super()._reset() + self.pressed = False + def _activate(self) -> None: if self.on_click is not None: self.on_click() @@ -105,12 +109,12 @@ 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, fill, self.rect, border_radius=self.border_radius) + pg.draw.rect(surface, self._paint(fill), self.rect, border_radius=self.border_radius) if self.outline_color is not None: - pg.draw.rect( - surface, self.outline_color, self.rect, 2, border_radius=self.border_radius - ) + outline = self._paint(self.outline_color) + pg.draw.rect(surface, outline, self.rect, 2, border_radius=self.border_radius) self.label.color = self._text_color() + self.label.enabled = self.enabled pad_x = self.padding[0] label_y = y + (self.rect.h - self.label.surface.get_height()) // 2 self.label.draw(surface, x + pad_x, label_y, self.rect.w - 2 * pad_x) @@ -152,7 +156,8 @@ def draw(self, surface: pg.Surface, x: int, y: int) -> None: if self.hover_image is not None and (self.hovered or self.pressed): image = self.hover_image self.rect = image.get_rect(topleft=(x, y)) - surface.blit(image, self.rect) + surface.blit(image if self.enabled else utils.disabled_image(image), self.rect) + self.label.enabled = self.enabled label_y = self.rect.centery - self.label.surface.get_height() // 2 self.label.draw(surface, x, label_y, self.rect.w) @@ -196,7 +201,8 @@ def draw(self, surface: pg.Surface, x: int, y: int) -> None: utils.draw_transparent_rect_with_border_radius( surface, self.hover_color, self.rect, self.border_radius, alpha ) - surface.blit(self.image, (x + self.padding, y + self.padding)) + image = self.image if self.enabled else utils.disabled_image(self.image) + surface.blit(image, (x + self.padding, y + self.padding)) class IconToggle(IconButton): diff --git a/src/PygameUIKit/dropdown.py b/src/PygameUIKit/dropdown.py index 2f52887..7e8b0c4 100644 --- a/src/PygameUIKit/dropdown.py +++ b/src/PygameUIKit/dropdown.py @@ -122,6 +122,11 @@ def _set_open(self, is_open: bool) -> None: self.is_open = is_open self.highlighted = self._index if is_open else None + def _reset(self) -> None: + super()._reset() + self._set_open(False) + self._pressed = None + def _choose(self, index: int) -> None: self._set_open(False) if index != self._index: @@ -131,9 +136,7 @@ def _choose(self, index: int) -> None: def handle_event(self, event: pg.event.Event) -> bool: if event.type == pg.WINDOWFOCUSLOST: - self._set_open(False) - self.hovered = False - self._pressed = None + self._reset() elif event.type == pg.MOUSEMOTION: self.hovered = self._covers(event.pos) if self.is_open: @@ -180,9 +183,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, fill, rect, border_radius=self.border_radius) + pg.draw.rect(surface, self._paint(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) + outline = self._paint(self.outline_color) + pg.draw.rect(surface, outline, 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) @@ -194,9 +198,9 @@ def draw(self, surface: pg.Surface, x: int, y: int) -> None: fill = self.color self._draw_box(surface, self.rect, fill) label = self._header - label.text, label.color = self.value, self._text_color() + label.text, label.color, label.enabled = self.value, self._text_color(), self.enabled label.draw(surface, x + PAD_X, self.rect.centery - label.surface.get_height() // 2) - self._draw_arrow(surface, label.color) + self._draw_arrow(surface, self._paint(label.color)) if not self.is_open: return menu = self._menu_rect() diff --git a/src/PygameUIKit/label.py b/src/PygameUIKit/label.py index 48dd36d..8cf994c 100644 --- a/src/PygameUIKit/label.py +++ b/src/PygameUIKit/label.py @@ -31,36 +31,33 @@ def __init__( ): self.align = _check_align(align) self.font = font if font is not None else get_font() - self._text = text - self._color = pg.Color(color) - self.surface = self.font.render(text, True, self._color) + self.text = text + self.color = color + self._rendered: tuple[str, pg.Color] | None = None super().__init__(ui_group=ui_group) self.rect = self.surface.get_rect() - @property - def text(self) -> str: - return self._text - - @text.setter - def text(self, value: str) -> None: - if value != self._text: - self.surface = self.font.render(value, True, self._color) - self._text = value - @property def color(self) -> pg.Color: return self._color @color.setter def color(self, value: ColorLike) -> None: - value = pg.Color(value) - if value != self._color: - self._color = value - self.surface = self.font.render(self._text, True, value) + self._color = pg.Color(value) + + @property + def surface(self) -> pg.Surface: + """The rendered text; re-rendered only when ``text``, ``color`` or ``enabled`` change.""" + key = (self.text, self._paint(self._color)) + if key != self._rendered: + self._surface = self.font.render(key[0], True, key[1]) + self._rendered = key + return self._surface def draw(self, surface: pg.Surface, x: int, y: int, width: int | None = None) -> None: - text_width, height = self.surface.get_size() + text = self.surface + text_width, height = text.get_size() width = text_width if width is None else width self.rect = pg.Rect(x, y, width, height) offset = {"left": 0, "center": (width - text_width) // 2, "right": width - text_width} - surface.blit(self.surface, (x + offset[self.align], y)) + surface.blit(text, (x + offset[self.align], y)) diff --git a/src/PygameUIKit/slider.py b/src/PygameUIKit/slider.py index 1f76253..6135b42 100644 --- a/src/PygameUIKit/slider.py +++ b/src/PygameUIKit/slider.py @@ -81,6 +81,10 @@ 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 _reset(self) -> None: + super()._reset() + self.dragging = False + def _knob_radius(self) -> int: radius = self.rect.h // 2 + 5 return radius + 2 if self.hovered or self.dragging else radius @@ -107,7 +111,7 @@ def _nudge(self, steps: int) -> None: def handle_event(self, event: pg.event.Event) -> bool: if event.type == pg.WINDOWFOCUSLOST: - self.dragging = self.hovered = False + self._reset() elif event.type == pg.MOUSEMOTION: self.hovered = self._hit(event.pos) if self.dragging: @@ -136,14 +140,15 @@ def draw(self, surface: pg.Surface, x: int, y: int, width: int = 200, height: in raise ValueError("Slider dimensions must be positive") 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) + 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, self.fill_color, filled, border_radius=height // 2) + pg.draw.rect(surface, fill, 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) + pg.draw.circle(surface, fill, center, radius) + pg.draw.circle(surface, self._paint(self.knob_color), center, radius - 2) if self.show_value: - text = self.font.render(self.value_text, True, self.text_color) + text = self.font.render(self.value_text, True, self._paint(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 11fbb5a..9d8992c 100644 --- a/src/PygameUIKit/text_input.py +++ b/src/PygameUIKit/text_input.py @@ -90,6 +90,10 @@ def event_priority(self) -> int: # While focused, see clicks first so clicking another widget still unfocuses. return int(self._focused) + def _reset(self) -> None: + super()._reset() + self.focused = False + def handle_event(self, event: pg.event.Event) -> bool: if event.type == pg.MOUSEBUTTONDOWN and event.button == 1: self.focused = self.rect.collidepoint(event.pos) @@ -97,7 +101,7 @@ def handle_event(self, event: pg.event.Event) -> bool: if event.type == pg.MOUSEMOTION: self.hovered = self.rect.collidepoint(event.pos) elif event.type == pg.WINDOWFOCUSLOST: - self.focused = self.hovered = False + self._reset() if not self._focused: return False if event.type == pg.TEXTINPUT: @@ -166,22 +170,24 @@ def draw(self, surface: pg.Surface, x: int, y: int) -> None: self.rect.topleft = (x, y) 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) + 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, border, area, thickness, border_radius=self.border_radius) + 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 if self._text: - rendered = self.font.render(self._text, True, self.text_color) + rendered = self.font.render(self._text, True, self._paint(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) + placeholder = self.font.render( + self.placeholder, True, self._paint(self.placeholder_color) ) + box.blit(placeholder, (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)) diff --git a/src/PygameUIKit/utils.py b/src/PygameUIKit/utils.py index 1086e78..4e109fa 100644 --- a/src/PygameUIKit/utils.py +++ b/src/PygameUIKit/utils.py @@ -27,6 +27,22 @@ def shade(color: ColorLike, amount: float) -> Color: return shaded +def disabled_color(color: ColorLike) -> Color: + """``color`` mostly desaturated and faded toward white: how disabled widgets are drawn.""" + color = Color(color) + grey = round(_luma(color)) + faded = color.lerp((grey, grey, grey), 0.7).lerp("white", 0.45) + faded.a = color.a + return faded + + +def disabled_image(image: pg.Surface) -> pg.Surface: + """A greyscale, translucent copy of ``image`` to match ``disabled_color``.""" + faded = pg.transform.grayscale(image) + faded.set_alpha(140) + return faded + + def load_image(path, size=None) -> pg.Surface: image = pg.image.load(path) if size is not None: diff --git a/src/PygameUIKit/widget.py b/src/PygameUIKit/widget.py index 989dfe5..1b4ea6d 100644 --- a/src/PygameUIKit/widget.py +++ b/src/PygameUIKit/widget.py @@ -1,17 +1,47 @@ +from functools import wraps + import pygame as pg from . import utils +def _ignored_while_inactive(handle_event): + @wraps(handle_event) + def wrapper(self: "Widget", event: pg.event.Event) -> bool: + return self.enabled and self.visible and handle_event(self, event) + + return wrapper + + +def _skipped_while_hidden(draw): + @wraps(draw) + def wrapper(self: "Widget", *args, **kwargs) -> None: + if self.visible: + draw(self, *args, **kwargs) + + return wrapper + + class Widget: """Base class for every widget. Subclasses draw themselves with ``draw(surface, x, y, ...)`` each frame and store the drawn area in ``rect``. Pass ``ui_group=group`` to receive events through a ``Group``. + Set ``enabled = False`` to grey a widget out and ``visible = False`` to hide it; either + way it ignores events. Every subclass's ``handle_event`` and ``draw`` get these checks. """ cursor: int | None = None event_priority: int = 0 + _enabled = True + _visible = True + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if "handle_event" in vars(cls): + cls.handle_event = _ignored_while_inactive(cls.handle_event) + if "draw" in vars(cls): + cls.draw = _skipped_while_hidden(cls.draw) def __init__(self, *, ui_group: "Group | None" = None): self.rect = pg.Rect(0, 0, 0, 0) @@ -19,6 +49,36 @@ def __init__(self, *, ui_group: "Group | None" = None): if ui_group is not None: ui_group.add(self) + @property + def enabled(self) -> bool: + """Disabled widgets are drawn greyed out and ignore events.""" + return self._enabled + + @enabled.setter + def enabled(self, value: bool) -> None: + self._enabled = bool(value) + if not value: + self._reset() + + @property + def visible(self) -> bool: + """Hidden widgets draw nothing and ignore events.""" + return self._visible + + @visible.setter + def visible(self, value: bool) -> None: + self._visible = bool(value) + if not value: + self._reset() + + def _reset(self) -> None: + """Drop hover, press, drag, focus and open menus: when disabled, hidden or unfocused.""" + self.hovered = False + + def _paint(self, color: pg.Color) -> pg.Color: + """``color``, greyed out while the widget is disabled.""" + return color if self._enabled else utils.disabled_color(color) + def handle_event(self, event: pg.event.Event) -> bool: """Handle one event; return True if it was consumed.""" return False @@ -59,6 +119,7 @@ def handle_event(self, event: pg.event.Event) -> bool: 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. + Disabled and hidden widgets ignore every event. """ consumed = False for widget in self._by_priority(): @@ -66,15 +127,17 @@ def handle_event(self, event: pg.event.Event) -> bool: consumed = widget.handle_event(event) elif event.type == pg.MOUSEMOTION: widget.hovered = False - if event.type == pg.MOUSEMOTION: - self._update_cursor() + self._update_cursor() return consumed def update(self, dt: float) -> None: for widget in tuple(self.widgets): widget.update(dt) + self._update_cursor() def _update_cursor(self) -> None: + # Also runs after clicks and updates, so disabling or hiding the widget under the + # pointer gives the arrow back without waiting for the mouse to move. cursor = next( (w.cursor for w in self._by_priority() if w.hovered and w.cursor is not None), pg.SYSTEM_CURSOR_ARROW, diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 755c50e..022b31f 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,3 +1,4 @@ +import inspect import subprocess import sys @@ -5,7 +6,14 @@ import pytest 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 +from PygameUIKit.utils import ( + best_contrast_color, + disabled_color, + disabled_image, + lighter_color, + load_image, + shade, +) def mouse(kind, pos=(20, 20), button_number=1): @@ -717,3 +725,147 @@ def test_chart_values_setter_and_animation_speed(display): custom.draw(display, 0, 0) with pytest.raises(ValueError): widget.values = [1, -1] + + +def interactive_widgets(group, calls): + return [ + button.Button("Click", lambda: calls.append("click"), ui_group=group), + button.IconToggle(pg.Surface((20, 20)), pg.Surface((20, 20)), calls.append, ui_group=group), + slider.Slider(0, 10, on_change=calls.append, ui_group=group), + text_input.TextInput(width=100, on_change=calls.append, ui_group=group), + Dropdown(["A", "B"], on_change=calls.append, ui_group=group), + ] + + +def poke(widget, send): + """Hover, click, type into and press keys on ``widget``; return what was consumed.""" + pos = widget.rect.center + events = [mouse(pg.MOUSEMOTION, pos), mouse(pg.MOUSEBUTTONDOWN, pos)] + events += [mouse(pg.MOUSEBUTTONUP, pos), pg.event.Event(pg.TEXTINPUT, text="x")] + events += [pg.event.Event(pg.KEYDOWN, key=key) for key in (pg.K_RIGHT, pg.K_DOWN, pg.K_RETURN)] + return [send(event) for event in events] + + +@pytest.mark.parametrize("state", ["enabled", "visible"]) +@pytest.mark.parametrize("through_group", [True, False]) +def test_disabled_and_hidden_widgets_ignore_events(state, through_group, display): + group = Group() + calls = [] + widgets = interactive_widgets(group, calls) + for i, widget in enumerate(widgets): + widget.draw(display, 20, 20 + 60 * i) + setattr(widget, state, False) + for widget in widgets: + consumed = poke(widget, group.handle_event if through_group else widget.handle_event) + assert not any(consumed) + assert not widget.hovered + assert calls == [] + assert not widgets[3].focused and not widgets[4].is_open + setattr(widgets[0], state, True) + click(widgets[0], widgets[0].rect.center) + assert calls == ["click"] + + +@pytest.mark.parametrize("state", ["enabled", "visible"]) +def test_disabling_or_hiding_clears_interaction_state(state, display): + btn, toggle, bar, field, menu = interactive_widgets(Group(), []) + for i, widget in enumerate((btn, bar, field, menu)): + widget.draw(display, 20, 20 + 60 * i) + widget.handle_event(mouse(pg.MOUSEMOTION, widget.rect.center)) + widget.handle_event(mouse(pg.MOUSEBUTTONDOWN, widget.rect.center)) + menu.handle_event(mouse(pg.MOUSEBUTTONUP, menu.rect.center)) + assert btn.pressed and bar.dragging and field.focused and menu.is_open + for widget in (btn, bar, field, menu): + assert widget.hovered + setattr(widget, state, False) + assert not widget.hovered and widget.event_priority == 0 + assert not btn.pressed and not bar.dragging and not field.focused and not menu.is_open + setattr(btn, state, True) + assert not btn.handle_event(mouse(pg.MOUSEBUTTONUP, btn.rect.center)) + + +@pytest.mark.parametrize( + "factory", + [ + lambda: Label("Hello"), + lambda: button.Button("Hello"), + lambda: button.ImageButton(pg.Surface((30, 30)), text="Hello"), + lambda: button.IconButton(pg.Surface((30, 30))), + lambda: button.IconToggle(pg.Surface((30, 30)), pg.Surface((30, 30))), + lambda: slider.Slider(0, 10, value=5, show_value=True), + lambda: text_input.TextInput("Hello"), + lambda: Dropdown(["Hello", "World"]), + lambda: BarChart([1, 2], labels=["A", "B"]), + ], +) +def test_hidden_widgets_draw_nothing_and_disabled_ones_draw_dimmed(factory, display): + widget = factory() + + def pixels_after_draw(): + display.fill((40, 120, 200)) + widget.draw(display, 10, 10) + return pg.image.tobytes(display, "RGB") + + display.fill((40, 120, 200)) + background = pg.image.tobytes(display, "RGB") + normal = pixels_after_draw() + widget.enabled = False + disabled = pixels_after_draw() + widget.visible = False + assert pixels_after_draw() == background + widget.enabled = widget.visible = True + assert pixels_after_draw() == normal + assert normal != background and disabled not in (normal, background) + + +def test_cursor_resets_when_the_hovered_widget_is_disabled_or_hidden(display, monkeypatch): + cursors = [] + monkeypatch.setattr(utils, "set_system_cursor", cursors.append) + group = Group() + btn = button.Button("Busy", lambda: setattr(btn, "enabled", False), ui_group=group) + btn.draw(display, 10, 10) + group.handle_event(mouse(pg.MOUSEMOTION)) + assert cursors == [pg.SYSTEM_CURSOR_HAND] + group.handle_event(mouse(pg.MOUSEBUTTONDOWN)) + group.handle_event(mouse(pg.MOUSEBUTTONUP)) + assert cursors[-1] == pg.SYSTEM_CURSOR_ARROW + group.handle_event(mouse(pg.MOUSEMOTION, (21, 21))) + assert cursors[-1] == pg.SYSTEM_CURSOR_ARROW and not btn.hovered + btn.enabled = True + group.handle_event(mouse(pg.MOUSEMOTION)) + assert cursors[-1] == pg.SYSTEM_CURSOR_HAND + btn.visible = False + group.update(0.1) + assert cursors == [pg.SYSTEM_CURSOR_HAND, pg.SYSTEM_CURSOR_ARROW] * 2 + + +def test_custom_widgets_get_the_state_checks(display): + class Box(Widget): + def handle_event(self, event): + return True + + def draw(self, surface, x, y, size=10): + self.rect = pg.draw.rect(surface, "red", (x, y, size, size)) + + box = Box() + assert box.enabled and box.visible and box.handle_event(mouse(pg.MOUSEMOTION)) + box.enabled = False + assert box.handle_event(mouse(pg.MOUSEMOTION)) is False + box.enabled, box.visible = True, False + box.draw(display, 5, 5) + assert box.rect.size == (0, 0) + assert list(inspect.signature(Box.draw).parameters) == ["self", "surface", "x", "y", "size"] + + +def test_disabled_color_and_image_helpers(): + blue = pg.Color(59, 130, 246, 200) + dimmed = disabled_color(blue) + assert dimmed.a == 200 + assert dimmed.hsva[1] < blue.hsva[1] / 2 and sum(dimmed[:3]) > sum(blue[:3]) + assert disabled_color("white") == pg.Color("white") + image = pg.Surface((4, 3), pg.SRCALPHA) + image.fill("red") + faded = disabled_image(image) + assert faded.get_size() == (4, 3) and faded.get_alpha() < 255 + r, g, b, _ = faded.get_at((0, 0)) + assert r == g == b From b76487a8c5a7fdb986551e94207533da5833f457 Mon Sep 17 00:00:00 2001 From: Dorian Om Date: Wed, 28 Oct 2026 03:38:16 +0100 Subject: [PATCH 2/4] Add a States card to the demo Two buttons disable or hide a set of showcase widgets across the cards and log the change. The window grows to 800 px so the States card fits above the event log; the bar chart spans both bottom rows, and the log shows its newest lines when they do not all fit. Refresh the screenshot. --- images/img.png | Bin 80762 -> 87951 bytes src/PygameUIKit/demo.py | 64 ++++++++++++++++++++++++++++++++++------ tests/test_demo.py | 22 ++++++++++++++ 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/images/img.png b/images/img.png index d8fa3c2a9e9fe69aabfdc1f05db31c5421504c7a..9c7ed1d6f118c51282b6b2487d98dac4e7b81ddb 100644 GIT binary patch delta 29388 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 delta 22142 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 pg.Rect: - """The rectangle of the card at ``column``, ``row`` spanning ``columns`` grid columns.""" +def cell(column: int, row: int, columns: int = 1, rows: int = 1) -> pg.Rect: + """The rectangle of the card at ``column``, ``row`` spanning ``columns`` and ``rows``.""" width = (SIZE[0] - 2 * MARGIN - (COLUMNS - 1) * GAP) // COLUMNS top = HEADER + sum(ROWS[:row]) + row * GAP + height = sum(ROWS[row : row + rows]) + (rows - 1) * GAP return pg.Rect( - MARGIN + column * (width + GAP), top, columns * width + (columns - 1) * GAP, ROWS[row] + MARGIN + column * (width + GAP), top, columns * width + (columns - 1) * GAP, height ) @@ -226,6 +227,31 @@ def __init__(self): ] self.animation_timer = 0.0 + # States: every widget has ``enabled`` and ``visible``. Two buttons flip them for + # these widgets, at least one from each interactive card. + self.showcase = [ + *self.color_buttons, + self.icon_button, + self.animate, + self.image_button, + self.int_slider, + self.text_input, + self.palette, + ] + state_width = (cell(2, 2).w - 2 * PAD - 8) // 2 + self.disable = Button( + "Disable widgets", self.on_disable, width=state_width, font=font, ui_group=ui + ) + self.hide = Button( + "Hide widgets", + self.on_hide, + color="white", + outline_color=BORDER, + width=state_width, + font=font, + ui_group=ui, + ) + self.log_empty = Label("Interact with a widget...", MUTED, font=self.code_font) # The layout lives here. Cards are drawn in this order, so the Dropdown card comes @@ -239,10 +265,11 @@ def __init__(self): Card( "BarChart", "BarChart(values, labels=..., max_value=100)", - cell(0, 2, 2), + cell(0, 2, 2, 2), self.draw_chart, ), - Card("Event log", "on_click() / on_change(value)", cell(2, 2), self.draw_log), + 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), ] @@ -281,6 +308,20 @@ def on_randomize(self) -> None: self.randomize_chart() self.log('Button("Randomize").on_click()') + def on_disable(self) -> None: + enabled = not self.showcase[0].enabled + for widget in self.showcase: + widget.enabled = enabled + self.disable.text = "Disable widgets" if enabled else "Enable widgets" + self.log(f"widget.enabled = {enabled}") + + def on_hide(self) -> None: + visible = not self.showcase[0].visible + for widget in self.showcase: + widget.visible = visible + self.hide.text = "Hide widgets" if visible else "Show widgets" + self.log(f"widget.visible = {visible}") + def randomize_chart(self) -> None: self.chart.values = [random.randint(5, 100) for _ in self.chart.values] @@ -381,13 +422,18 @@ def draw_chart(self, surface: pg.Surface, area: pg.Rect) -> None: for i, hint in enumerate(self.chart_hints): hint.draw(surface, x, self.randomize.rect.bottom + 14 + i * 22) + 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) + def draw_log(self, surface: pg.Surface, area: pg.Rect) -> None: if not self.events: self.log_empty.draw(surface, area.x, area.y) + line_height = self.code_font.get_height() + 1 surface.set_clip(area) - for i, event in enumerate(self.events): + for i, event in enumerate(list(self.events)[-(area.h // line_height) :]): text = self.code_font.render(event, True, INK) - surface.blit(text, (area.x, area.y + i * (self.code_font.get_height() + 1))) + surface.blit(text, (area.x, area.y + i * line_height)) surface.set_clip(None) diff --git a/tests/test_demo.py b/tests/test_demo.py index 388bba7..5d71cbb 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -37,3 +37,25 @@ def test_demo_screenshot(tmp_path): path = tmp_path / "demo.png" demo.main(["--screenshot", str(path)]) assert pg.image.load(path).get_size() == demo.SIZE + + +def test_demo_disables_and_hides_the_showcase_widgets(): + app = demo.Demo() + app.draw() + click(app, app.disable) + assert not any(widget.enabled for widget in app.showcase) + assert app.disable.text == "Enable widgets" + click(app, app.text_input) + assert not app.text_input.focused + click(app, app.hide) + app.draw() + assert not any(widget.visible for widget in app.showcase) + click(app, app.disable) + click(app, app.hide) + assert all(widget.enabled and widget.visible for widget in app.showcase) + assert list(app.events) == [ + "widget.enabled = False", + "widget.visible = False", + "widget.enabled = True", + "widget.visible = True", + ] From 01cb62f2b078cb169b87d0dbfdc9833837f38732 Mon Sep 17 00:00:00 2001 From: Dorian Om Date: Wed, 28 Oct 2026 03:38:16 +0100 Subject: [PATCH 3/4] Document enabled and visible Describe widget states in the README guide and the changelog, and note that Group now updates the cursor after every event and update. --- CHANGELOG.md | 4 +++- README.md | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eaf425..8c397fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,9 @@ - `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. -- The demo (`pygameuikit-demo`) is rewritten as a showcase: one card per widget family with its main options and API, and an event log that shows each callback as it fires. `--screenshot PATH` saves a frame as a PNG and exits without opening a window. +- Every widget has `enabled` and `visible` properties (both `True` by default). Disabled widgets are drawn greyed out (`utils.disabled_color` and `utils.disabled_image`); hidden widgets draw nothing. Both ignore events, whether they come through a `Group` or a direct `handle_event` call, and turning either off clears hover, press, drag, focus and open menus. Widget subclasses, including your own, get these checks around their `handle_event` and `draw`. +- `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. ### Upgrading from 0.2 diff --git a/README.md b/README.md index f1f02ca..94b2e38 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,10 @@ while running: pygame.quit() ``` -- **Group.** Pass `ui_group=ui` to every interactive widget. Forward each event once with `ui.handle_event(event)`. It returns `True` when a widget used the event (a button click, typing into a focused text input), so check it before your own handling. Call `ui.update(dt)` with seconds every frame; it animates charts and repeats held keys. After mouse motion, the group sets the cursor of the hovered widget. +- **Group.** Pass `ui_group=ui` to every interactive widget. Forward each event once with `ui.handle_event(event)`. It returns `True` when a widget used the event (a button click, typing into a focused text input), so check it before your own handling. Call `ui.update(dt)` with seconds every frame; it animates charts and repeats held keys. After each event and update, the group sets the cursor of the hovered widget. - **Drawing.** There is no layout system. Call `widget.draw(surface, x, y, ...)` every frame. The widget stores the drawn area in `widget.rect`, which is also its clickable area. Draw dropdowns last. - **Callbacks.** `on_click()`, `on_change(value)` and `on_submit(text)` are keyword arguments, and attributes you can reassign; `None` means no callback. They run only for user interaction, never when you set `value` or `text` yourself. +- **States.** Every widget has `enabled` and `visible`, both `True` by default. A disabled widget is drawn greyed out; a hidden one draws nothing. Both ignore events, and turning either off clears hover, press, drag, focus and open menus, so `submit.enabled = False` is all it takes to block a button while work is in progress. - **Colors and fonts.** Colors are anything `pygame.Color` accepts. Pass `font=pygame.font.Font(...)` for a particular font or character set; default fonts are created when the widget is. ### Widgets From 734f12303abe42cdf669bb4a1c02edaa4eb8736e Mon Sep 17 00:00:00 2001 From: Dorian Om Date: Wed, 28 Oct 2026 03:40:35 +0100 Subject: [PATCH 4/4] Replace method wrapping with explicit enabled/visible guards Widgets now start handle_event with `if not self.interactive` and draw with `if not self.visible`, and Group skips widgets that aren't interactive. Plain guard lines are easier to follow than rewriting subclass methods in __init_subclass__. --- CHANGELOG.md | 2 +- src/PygameUIKit/barchart.py | 2 ++ src/PygameUIKit/button.py | 10 ++++++++++ src/PygameUIKit/dropdown.py | 4 ++++ src/PygameUIKit/label.py | 2 ++ src/PygameUIKit/slider.py | 4 ++++ src/PygameUIKit/text_input.py | 4 ++++ src/PygameUIKit/widget.py | 36 ++++++++--------------------------- tests/test_widgets.py | 19 +++++++++--------- 9 files changed, 44 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c397fd..e0caf57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ - `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. -- Every widget has `enabled` and `visible` properties (both `True` by default). Disabled widgets are drawn greyed out (`utils.disabled_color` and `utils.disabled_image`); hidden widgets draw nothing. Both ignore events, whether they come through a `Group` or a direct `handle_event` call, and turning either off clears hover, press, drag, focus and open menus. Widget subclasses, including your own, get these checks around their `handle_event` and `draw`. +- Every widget has `enabled` and `visible` properties (both `True` by default). Disabled widgets are drawn greyed out (`utils.disabled_color` and `utils.disabled_image`); hidden widgets draw nothing. Both ignore events, whether they come through a `Group` or a direct `handle_event` call, and turning either off clears hover, press, drag, focus and open menus. `Group` skips widgets that aren't `interactive` (enabled and visible); your own widgets should start `draw` with `if not self.visible: return`. - `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. diff --git a/src/PygameUIKit/barchart.py b/src/PygameUIKit/barchart.py index c59c04e..e8abdf0 100644 --- a/src/PygameUIKit/barchart.py +++ b/src/PygameUIKit/barchart.py @@ -93,6 +93,8 @@ def update(self, dt: float) -> None: def draw( self, surface: pg.Surface, x: int, y: int, width: int = 300, height: int = 100 ) -> None: + if not self.visible: + return if width < 0 or height < 0: raise ValueError("Chart dimensions must be non-negative") self.rect = pg.Rect(x, y, width, height) diff --git a/src/PygameUIKit/button.py b/src/PygameUIKit/button.py index ecbfc57..74319c6 100644 --- a/src/PygameUIKit/button.py +++ b/src/PygameUIKit/button.py @@ -19,6 +19,8 @@ def __init__(self, on_click: Callable[[], None] | None, *, ui_group: Group | Non self.pressed = False 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: @@ -101,6 +103,8 @@ def _resize(self) -> None: self.rect.size = (self.width or text_width + 2 * pad_x, text_height + 2 * pad_y) def draw(self, surface: pg.Surface, x: int, y: int) -> None: + if not self.visible: + return self._resize() self.rect.topleft = (x, y) if self.pressed: @@ -152,6 +156,8 @@ def text(self, value: str) -> None: self.label.text = value def draw(self, surface: pg.Surface, x: int, y: int) -> None: + if not self.visible: + return image = self.image if self.hover_image is not None and (self.hovered or self.pressed): image = self.hover_image @@ -194,6 +200,8 @@ def __init__( self.rect = pg.Rect(0, 0, width + 2 * padding, height + 2 * padding) def draw(self, surface: pg.Surface, x: int, y: int) -> None: + if not self.visible: + return width, height = self.image.get_size() self.rect = pg.Rect(x, y, width + 2 * self.padding, height + 2 * self.padding) if self.hovered or self.pressed: @@ -244,5 +252,7 @@ def _activate(self) -> None: self.on_change(self.value) def draw(self, surface: pg.Surface, x: int, y: int) -> None: + if not self.visible: + return self.image = self.image_on if self.value else self.image_off super().draw(surface, x, y) diff --git a/src/PygameUIKit/dropdown.py b/src/PygameUIKit/dropdown.py index 7e8b0c4..7f6aa0d 100644 --- a/src/PygameUIKit/dropdown.py +++ b/src/PygameUIKit/dropdown.py @@ -135,6 +135,8 @@ def _choose(self, index: int) -> None: self.on_change(self.value) 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: @@ -189,6 +191,8 @@ def _draw_box(self, surface: pg.Surface, rect: pg.Rect, fill: pg.Color) -> None: pg.draw.rect(surface, outline, rect, 1, border_radius=self.border_radius) def draw(self, surface: pg.Surface, x: int, y: int) -> None: + if not self.visible: + return 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) diff --git a/src/PygameUIKit/label.py b/src/PygameUIKit/label.py index 8cf994c..8f1e919 100644 --- a/src/PygameUIKit/label.py +++ b/src/PygameUIKit/label.py @@ -55,6 +55,8 @@ def surface(self) -> pg.Surface: return self._surface def draw(self, surface: pg.Surface, x: int, y: int, width: int | None = None) -> None: + if not self.visible: + return text = self.surface text_width, height = text.get_size() width = text_width if width is None else width diff --git a/src/PygameUIKit/slider.py b/src/PygameUIKit/slider.py index 6135b42..65e0625 100644 --- a/src/PygameUIKit/slider.py +++ b/src/PygameUIKit/slider.py @@ -110,6 +110,8 @@ def _nudge(self, steps: int) -> None: self._change(self.min + (position + steps) * self.step) 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: @@ -136,6 +138,8 @@ def handle_event(self, event: pg.event.Event) -> bool: return False def draw(self, surface: pg.Surface, x: int, y: int, width: int = 200, height: int = 6) -> None: + if not self.visible: + return if width <= 0 or height <= 0: raise ValueError("Slider dimensions must be positive") self.rect = pg.Rect(x, y, width, height) diff --git a/src/PygameUIKit/text_input.py b/src/PygameUIKit/text_input.py index 9d8992c..ad9b4db 100644 --- a/src/PygameUIKit/text_input.py +++ b/src/PygameUIKit/text_input.py @@ -95,6 +95,8 @@ def _reset(self) -> None: self.focused = False def handle_event(self, event: pg.event.Event) -> bool: + if not self.interactive: + return False if event.type == pg.MOUSEBUTTONDOWN and event.button == 1: self.focused = self.rect.collidepoint(event.pos) return self._focused @@ -167,6 +169,8 @@ def _scroll_to_caret(self) -> None: 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: + if not self.visible: + return self.rect.topleft = (x, y) box = pg.Surface(self.rect.size, pg.SRCALPHA) area = box.get_rect() diff --git a/src/PygameUIKit/widget.py b/src/PygameUIKit/widget.py index 1b4ea6d..266ba0e 100644 --- a/src/PygameUIKit/widget.py +++ b/src/PygameUIKit/widget.py @@ -1,34 +1,16 @@ -from functools import wraps - import pygame as pg from . import utils -def _ignored_while_inactive(handle_event): - @wraps(handle_event) - def wrapper(self: "Widget", event: pg.event.Event) -> bool: - return self.enabled and self.visible and handle_event(self, event) - - return wrapper - - -def _skipped_while_hidden(draw): - @wraps(draw) - def wrapper(self: "Widget", *args, **kwargs) -> None: - if self.visible: - draw(self, *args, **kwargs) - - return wrapper - - class Widget: """Base class for every widget. Subclasses draw themselves with ``draw(surface, x, y, ...)`` each frame and store the drawn area in ``rect``. Pass ``ui_group=group`` to receive events through a ``Group``. Set ``enabled = False`` to grey a widget out and ``visible = False`` to hide it; either - way it ignores events. Every subclass's ``handle_event`` and ``draw`` get these checks. + way it ignores events. Subclasses start ``handle_event`` with + ``if not self.interactive: return False`` and ``draw`` with ``if not self.visible: return``. """ cursor: int | None = None @@ -36,13 +18,6 @@ class Widget: _enabled = True _visible = True - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - if "handle_event" in vars(cls): - cls.handle_event = _ignored_while_inactive(cls.handle_event) - if "draw" in vars(cls): - cls.draw = _skipped_while_hidden(cls.draw) - def __init__(self, *, ui_group: "Group | None" = None): self.rect = pg.Rect(0, 0, 0, 0) self.hovered = False @@ -71,6 +46,11 @@ def visible(self, value: bool) -> None: if not value: self._reset() + @property + def interactive(self) -> bool: + """True when the widget is enabled and visible, so it reacts to events.""" + return self._enabled and self._visible + def _reset(self) -> None: """Drop hover, press, drag, focus and open menus: when disabled, hidden or unfocused.""" self.hovered = False @@ -123,7 +103,7 @@ def handle_event(self, event: pg.event.Event) -> bool: """ consumed = False for widget in self._by_priority(): - if not consumed: + if not consumed and widget.interactive: consumed = widget.handle_event(event) elif event.type == pg.MOUSEMOTION: widget.hovered = False diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 022b31f..9b1dc02 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,4 +1,3 @@ -import inspect import subprocess import sys @@ -839,22 +838,22 @@ def test_cursor_resets_when_the_hovered_widget_is_disabled_or_hidden(display, mo assert cursors == [pg.SYSTEM_CURSOR_HAND, pg.SYSTEM_CURSOR_ARROW] * 2 -def test_custom_widgets_get_the_state_checks(display): +def test_group_skips_inactive_custom_widgets(): + calls = [] + class Box(Widget): def handle_event(self, event): + calls.append(event) return True - def draw(self, surface, x, y, size=10): - self.rect = pg.draw.rect(surface, "red", (x, y, size, size)) - box = Box() - assert box.enabled and box.visible and box.handle_event(mouse(pg.MOUSEMOTION)) + group = Group(box) + assert group.handle_event(mouse(pg.MOUSEMOTION)) and len(calls) == 1 box.enabled = False - assert box.handle_event(mouse(pg.MOUSEMOTION)) is False + assert not group.handle_event(mouse(pg.MOUSEMOTION)) box.enabled, box.visible = True, False - box.draw(display, 5, 5) - assert box.rect.size == (0, 0) - assert list(inspect.signature(Box.draw).parameters) == ["self", "surface", "x", "y", "size"] + assert not group.handle_event(mouse(pg.MOUSEMOTION)) + assert len(calls) == 1 and not box.interactive def test_disabled_color_and_image_helpers():