Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified images/img.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions src/PygameUIKit/barchart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)))
30 changes: 23 additions & 7 deletions src/PygameUIKit/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -189,14 +200,17 @@ 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:
alpha = min(255, self.hover_alpha * 3 // 2) if self.pressed else self.hover_alpha
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):
Expand Down Expand Up @@ -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)
64 changes: 55 additions & 9 deletions src/PygameUIKit/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
TextInput,
)

SIZE = (1100, 720)
SIZE = (1100, 800)
ASSETS = Path(__file__).parent / "assets"

BACKGROUND = (241, 243, 246)
Expand All @@ -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"],
Expand All @@ -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
)


Expand Down Expand Up @@ -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
Expand All @@ -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),
]

Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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)


Expand Down
22 changes: 15 additions & 7 deletions src/PygameUIKit/dropdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
37 changes: 18 additions & 19 deletions src/PygameUIKit/label.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Loading
Loading