diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eaf425..e0caf57 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. `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. ### 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 diff --git a/images/img.png b/images/img.png index d8fa3c2..9c7ed1d 100644 Binary files a/images/img.png and b/images/img.png differ diff --git a/src/PygameUIKit/barchart.py b/src/PygameUIKit/barchart.py index bfc335e..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) @@ -107,9 +109,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..74319c6 100644 --- a/src/PygameUIKit/button.py +++ b/src/PygameUIKit/button.py @@ -19,8 +19,10 @@ 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.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 +35,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() @@ -97,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: @@ -105,12 +113,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) @@ -148,11 +156,14 @@ 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 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) @@ -189,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: @@ -196,7 +209,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): @@ -238,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/demo.py b/src/PygameUIKit/demo.py index cbc5097..f10b5ff 100644 --- a/src/PygameUIKit/demo.py +++ b/src/PygameUIKit/demo.py @@ -28,7 +28,7 @@ TextInput, ) -SIZE = (1100, 720) +SIZE = (1100, 800) ASSETS = Path(__file__).parent / "assets" BACKGROUND = (241, 243, 246) @@ -44,7 +44,7 @@ MARGIN, GAP, PAD = 24, 16, 16 HEADER = 60 COLUMNS = 3 -ROWS = (202, 158, 244) # card heights, top to bottom +ROWS = (202, 158, 124, 184) # card heights, top to bottom PALETTES = { "Ocean": ["#0e7490", "#0891b2", "#06b6d4", "#22d3ee", "#67e8f9", "#0369a1"], @@ -54,12 +54,13 @@ } -def cell(column: int, row: int, columns: int = 1) -> 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/src/PygameUIKit/dropdown.py b/src/PygameUIKit/dropdown.py index 2f52887..7f6aa0d 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: @@ -130,10 +135,10 @@ 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._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,11 +185,14 @@ 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: + 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) @@ -194,9 +202,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..8f1e919 100644 --- a/src/PygameUIKit/label.py +++ b/src/PygameUIKit/label.py @@ -31,36 +31,35 @@ 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() + if not self.visible: + return + 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..65e0625 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 @@ -106,8 +110,10 @@ 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.dragging = self.hovered = False + self._reset() elif event.type == pg.MOUSEMOTION: self.hovered = self._hit(event.pos) if self.dragging: @@ -132,18 +138,21 @@ 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) 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..ad9b4db 100644 --- a/src/PygameUIKit/text_input.py +++ b/src/PygameUIKit/text_input.py @@ -90,14 +90,20 @@ 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 not self.interactive: + return False if event.type == pg.MOUSEBUTTONDOWN and event.button == 1: 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.focused = self.hovered = False + self._reset() if not self._focused: return False if event.type == pg.TEXTINPUT: @@ -163,25 +169,29 @@ 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() - 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..266ba0e 100644 --- a/src/PygameUIKit/widget.py +++ b/src/PygameUIKit/widget.py @@ -8,10 +8,15 @@ class 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. Subclasses start ``handle_event`` with + ``if not self.interactive: return False`` and ``draw`` with ``if not self.visible: return``. """ cursor: int | None = None event_priority: int = 0 + _enabled = True + _visible = True def __init__(self, *, ui_group: "Group | None" = None): self.rect = pg.Rect(0, 0, 0, 0) @@ -19,6 +24,41 @@ 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() + + @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 + + 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,22 +99,25 @@ 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(): - if not consumed: + if not consumed and widget.interactive: 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_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", + ] diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 755c50e..9b1dc02 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -5,7 +5,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 +724,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_group_skips_inactive_custom_widgets(): + calls = [] + + class Box(Widget): + def handle_event(self, event): + calls.append(event) + return True + + box = Box() + group = Group(box) + assert group.handle_event(mouse(pg.MOUSEMOTION)) and len(calls) == 1 + box.enabled = False + assert not group.handle_event(mouse(pg.MOUSEMOTION)) + box.enabled, box.visible = True, False + assert not group.handle_event(mouse(pg.MOUSEMOTION)) + assert len(calls) == 1 and not box.interactive + + +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