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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
- `IconButton`'s clickable area and hover backdrop are the icon plus `padding` on each side, starting at (x, y).
- Add `utils.shade(color, amount)`; `utils.best_contrast_color` uses perceived brightness and returns a `pygame.Color`.
- Remove the duplicate root `src/demo.py` and `src/assets/`; the packaged `pygameuikit-demo` is the demo.
- `Slider(min, max, step=1, *, value=None, on_change=None, show_value=False, font=None, fill_color, track_color, knob_color, text_color)` has a `value` property (clamped and snapped to `step`, without float drift) and draws with `draw(surface, x, y, width=200, height=6)`. The knob grows on hover and while dragging. Left/Right step it and Home/End jump to the ends while it is hovered or dragged. `show_value` uses as many decimals as `min` and `step` need (`value_text`).
- `TextInput(text="", *, placeholder="", width=200, font=None, text_color, placeholder_color, background_color, border_color, focus_color, border_radius=4, on_change=None, on_submit=None)` has a fixed width and an opaque background. Enter calls `on_submit(text)`, Escape unfocuses, and `on_change(text)` runs after every edit. `focused` and `text` are settable.
- `Dropdown(options, *, index=0, on_change=None, width=None, font=None, color, text_color, highlight_color, outline_color, border_radius)` replaces `ComboBox`. It draws itself (no longer a `Button` subclass) with a drawn chevron, highlights the hovered and selected options, and can be driven with Up/Down, Enter/Space and Escape while open. While open it consumes mouse events over itself and its menu; a click elsewhere, Escape or window focus loss closes it. `on_change(value)` runs only when the user picks a different option.
- When a widget consumes a `MOUSEMOTION` (an open dropdown menu under the pointer), `Group` marks the widgets after it as not hovered, so nothing underneath an open menu stays highlighted or sets the cursor.
- `BarChart(values, *, labels=None, max_value=None, colors=None, font=None, label_color="black", speed=200)`: `values` is a property (a tuple; setting it replaces all bars and regenerates automatic colors when the count changes), `set_value(index, value)` updates one bar, `colors` cycle if there are fewer than bars, and `speed` is the animation rate in value units per second. Bars are solid with rounded tops.

### Upgrading from 0.2

Expand All @@ -39,6 +44,10 @@
- `ButtonThreadImage` and `ButtonThreadText` are removed. Set `button.text` (or `image_button.image`) from your own state each frame instead, e.g. `button.text = "Searching..." if thread.is_alive() else "Find match"`.
- `Label(text, font_color, font, text_align, bounding_rect)` → `Label(text, color, font=..., align=...)`. Pass the bounding width to `draw(surface, x, y, width)` instead of `bounding_rect`; the `TextAlignment` enum is removed (use `"left"`, `"center"`, `"right"`). Labels no longer add 10 px of padding: draw at `(x + 10, y + 10)` for the old placement. `label.render_text()` and `label.text_surface` → `label.surface` (re-rendered automatically).
- `utils.make_surface_darker` is removed; use `utils.shade` for colors.
- Slider: `slider.get_value()` and `slider.current_value` → `slider.value`; `change_value_to(v)` → `slider.value = v` (no callback). `connect(f)` → `on_change=f` (or assign `slider.on_change`); the callback now receives the new value. `font_color` → `text_color`. `step` defaults to 1, and options after `step` are keyword-only. `draw(win, x, y, w=100, h=5)` → `draw(surface, x, y, width=200, height=6)`. `circle_radius`, `draw_bar`, `draw_circle` and `draw_value` are removed.
- TextInput: `text` is now the first positional argument. `fixed_width=None` (auto-grow) → `width=200` (always fixed). `font_color` → `text_color`. `active` → `focused`. `get_text()`/`set_text(t)` → the `text` property (setting it moves the caret to the end). `add_letter`, `remove_letter`, `handle_key`, `draw_placeholder`, `color` and `bg_trans` are removed. Enter and Escape are now handled (submit and unfocus).
- `PygameUIKit.combobox` → `PygameUIKit.dropdown`; `ComboBox(elements, font=..., font_color=...)` → `Dropdown(options, font=..., text_color=...)`. `elements` → `options` (a tuple), `selected_index` → `index`, `get_value()` → `value`, `change_selected(i)` → `index = i` (no callback). `add_action(index, f)` → one `on_change(value)` callback for every selection. `toggle_open()` and `change_hovered()` are removed; `hovered_index` → `highlighted`. The `combo_box_arrow.png` asset is removed.
- BarChart: `labels`, `max_value`, `font` and `ui_group` are keyword-only. `labels_color` → `label_color`. `change_value(index, value)` → `set_value(index, value)`. `values` is read as a tuple; assign a new sequence instead of mutating it. `CHANGE_SPEED` → the `speed` argument.

## 0.2.0

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,17 @@ while running:
pygame.quit()
```

Add widgets with `ui_group=ui`, forward each event once, and call `ui.update(dt)` with seconds. `ui.handle_event(event)` returns `True` when a widget consumed the event (for example a button click or typing into a focused text input), so you can skip your own handling for it. The group also sets the mouse cursor for the hovered widget. Draw each widget at your chosen position. Sliders accept `draw(screen, x, y, width, height)`; charts use the same layout arguments. Draw dropdowns last so their menus appear above other widgets.
Add widgets with `ui_group=ui`, forward each event once, and call `ui.update(dt)` with seconds. `ui.handle_event(event)` returns `True` when a widget consumed the event (for example a button click or typing into a focused text input), so you can skip your own handling for it. The group also sets the mouse cursor for the hovered widget. Draw each widget at your chosen position; sliders and charts also take `width` and `height`. Draw dropdowns last so their menus appear above other widgets. Callbacks (`on_click`, `on_change`, `on_submit`) run only for user interaction, never when you set `value` or `text` yourself.

- `Button("Play", on_click, color="seagreen")`: rounded text button sized to its text; set `text` to relabel it. Options: `width` and `align`, `outline_color`, `border_radius`, `padding`, `text_color` (black or white for contrast by default).
- `ImageButton(image, on_click, hover_image=None, text="")`: an image, optionally with centered text.
- `IconButton(icon, on_click)`: an icon with a translucent backdrop on hover.
- `IconToggle(icon_off, icon_on, on_change)`: switches icons on click and calls `on_change(value)`; read or set `value`.
- `slider.Slider(min, max, step)`: `get_value()` and `connect(callback)`.
- `combobox.ComboBox(["One", "Two"])`: `get_value()` and `add_action(index, callback)`.
- `text_input.TextInput(placeholder="Name")`: `get_text()` and `set_text(text)`.
- `Slider(0, 100, 5, value=50, on_change=callback, show_value=True)`: drag, click or use the arrow keys while hovering; read or set `value`.
- `TextInput(placeholder="Name", width=200, on_change=callback, on_submit=callback)`: Enter calls `on_submit(text)`, Escape unfocuses; read or set `text` and `focused`.
- `Dropdown(["One", "Two"], on_change=callback)`: pick with the mouse or Up/Down and Enter; read or set `value` (the option) or `index`.
- `Label("Hello", "white")`: draws at exactly (x, y); `draw(screen, x, y, width)` aligns it within `width` using `align="left" | "center" | "right"`. Set `text` to change it.
- `barchart.BarChart([10, 20, 30])`: `change_value(index, value)`; accepts non-negative values.
- `BarChart([10, 20, 30], labels=["A", "B", "C"], max_value=50)`: animated bars; `set_value(index, value)` or set `values`. Values must be non-negative.

Text inputs use Pygame's `TEXTINPUT` events for Unicode text and `KEYDOWN` for editing. Forward both through `Group`; don't also forward the same events separately to the input. Pass a custom `pygame.font.Font` if you need a particular font or character set.

Expand Down
8 changes: 4 additions & 4 deletions src/PygameUIKit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Composable Pygame UI widgets with explicit drawing and event dispatch."""

from . import barchart, button, combobox, label, slider, text_input, utils, widget
from . import barchart, button, dropdown, label, slider, text_input, utils, widget
from .barchart import BarChart
from .button import Button, IconButton, IconToggle, ImageButton
from .combobox import ComboBox
from .dropdown import Dropdown
from .label import Label
from .slider import Slider
from .text_input import TextInput
Expand All @@ -12,7 +12,7 @@
__all__ = [
"BarChart",
"Button",
"ComboBox",
"Dropdown",
"Group",
"IconButton",
"IconToggle",
Expand All @@ -23,7 +23,7 @@
"Widget",
"barchart",
"button",
"combobox",
"dropdown",
"label",
"slider",
"text_input",
Expand Down
Binary file removed src/PygameUIKit/assets/combo_box_arrow.png
Binary file not shown.
185 changes: 93 additions & 92 deletions src/PygameUIKit/barchart.py
Original file line number Diff line number Diff line change
@@ -1,114 +1,115 @@
from collections.abc import Sequence
from colorsys import hsv_to_rgb
from math import isfinite
from math import copysign, isfinite

import pygame as pg
from pygame import Color, Rect
from pygame.typing import ColorLike

from .utils import draw_transparent_rect_with_border_radius, get_font
from .widget import Widget
from . import utils
from .widget import Group, Widget

CHANGE_SPEED = 200
SPACE_BETWEEN_BARS = 2
BAR_GAP = 4
LABEL_GAP = 4


def color_from_float(colors):
new_colors = []
for color in colors:
new_colors.append(Color(int(color[0] * 255), int(color[1] * 255), int(color[2] * 255)))
return new_colors
def _check_values(values: Sequence[float]) -> list[float]:
if any(not isfinite(value) or value < 0 for value in values):
raise ValueError("Bar values must be finite and non-negative")
return list(values)


def _palette(count: int) -> list[pg.Color]:
return [
pg.Color([round(c * 255) for c in hsv_to_rgb(i / count, 0.55, 0.85)]) for i in range(count)
]


class BarChart(Widget):
"""Animated bars: ``BarChart([10, 20]).draw(surface, x, y, width, height)``."""
"""Bars that grow and shrink toward their values, with a label under each bar.

``chart = BarChart([3, 5, 2], labels=["A", "B", "C"], max_value=10, ui_group=ui)``; call
``chart.set_value(1, 8)`` or set ``chart.values``, ``ui.update(dt)`` to animate and
``chart.draw(screen, x, y, width=300, height=100)`` every frame. Without ``labels`` the
bars show their values; without ``max_value`` the tallest bar fills the height.
"""

def __init__(
self,
values: list[int],
labels: list[str] = None,
max_value=None,
ui_group=None,
font=None,
labels_color=(0, 0, 0),
values: Sequence[float],
*,
labels: Sequence[str] | None = None,
max_value: float | None = None,
colors: Sequence[ColorLike] | None = None,
font: pg.font.Font | None = None,
label_color: ColorLike = "black",
speed: float = 200,
ui_group: Group | None = None,
):
super().__init__(ui_group=ui_group)
if any(not isfinite(v) or v < 0 for v in values):
raise ValueError("Bar values must be finite and non-negative")
if max_value is not None and (not isfinite(max_value) or max_value <= 0):
raise ValueError("max_value must be finite and positive")
values = _check_values(values)
if labels is not None and len(labels) != len(values):
raise ValueError("labels must have the same length as values")
self.values = values[:]
self.max_value = max_value
self.rect = pg.Rect(0, 0, 0, 0)
self.labels_color = labels_color
if colors is not None and len(colors) == 0:
raise ValueError("colors must not be empty")
if max_value is not None and (not isfinite(max_value) or max_value <= 0):
raise ValueError("max_value must be finite and positive")
if not isfinite(speed) or speed <= 0:
raise ValueError("speed must be finite and positive")
self._values = values
self.values_displayed = values[:]
self.labels = labels
self.values_displayed = self.values[:]

self.font = font if font is not None else get_font(15)
self.colors = [hsv_to_rgb(i / len(values), 0.55, 0.85) for i in range(len(values))]
self.colors = color_from_float(self.colors)

def change_value(self, index, value):
if not isfinite(value) or value < 0:
raise ValueError("Bar values must be finite and non-negative")
self.values[index] = value

def update(self, dt):
dt = max(0, dt)
for i, value in enumerate(self.values):
if abs(self.values_displayed[i] - value) < CHANGE_SPEED * dt:
self.values_displayed[i] = value
continue
if self.values_displayed[i] < value:
self.values_displayed[i] += CHANGE_SPEED * dt
else:
self.values_displayed[i] -= CHANGE_SPEED * dt

def draw_bar(self, screen, index, color=(0, 0, 0)):
x, y, width, height = self.rect
bar_width = width / len(self.values)
if self.max_value is None:
max_height = max(1, *self.values, *self.values_displayed)
else:
max_height = self.max_value
fraction = min(1, self.values_displayed[index] / max_height)
bar_height = fraction * height
bar_x = x + index * bar_width
bar_y = y + height - bar_height

alpha = int(max(255 * fraction, 50))
draw_transparent_rect_with_border_radius(
screen,
color,
Rect(
bar_x + SPACE_BETWEEN_BARS,
bar_y,
max(0, bar_width - 2 * SPACE_BETWEEN_BARS),
bar_height,
),
0,
alpha,
)

def draw_labels(self, screen):
if not self.values:
return
x, y, width, height = self.rect
bar_width = width / len(self.values)
font = self.font
self.max_value = max_value
self._auto_colors = colors is None
self.colors = _palette(len(values)) if colors is None else [pg.Color(c) for c in colors]
self.font = font if font is not None else utils.get_font(18)
self.label_color = pg.Color(label_color)
self.speed = speed
super().__init__(ui_group=ui_group)

labels = self.labels
if labels is None:
labels = [f"{v:.0f}" for v in self.values_displayed]
for i, text in enumerate(labels):
botmid_bar = (x + i * bar_width + bar_width // 2, y + height)
text = font.render(text, True, self.labels_color)
screen.blit(text, text.get_rect(midtop=botmid_bar))
@property
def values(self) -> tuple[float, ...]:
return tuple(self._values)

@values.setter
def values(self, values: Sequence[float]) -> None:
values = _check_values(values)
shown = self.values_displayed[: len(values)]
self.values_displayed = shown + [0] * (len(values) - len(shown))
if self._auto_colors and len(values) != len(self._values):
self.colors = _palette(len(values))
self._values = values

def set_value(self, index: int, value: float) -> None:
self._values[index] = _check_values([value])[0]

def update(self, dt: float) -> None:
change = self.speed * max(0, dt)
for i, target in enumerate(self._values):
difference = target - self.values_displayed[i]
if abs(difference) <= change:
self.values_displayed[i] = target
else:
self.values_displayed[i] += copysign(change, difference)

def draw(self, screen, x, y, width=300, height=100):
def draw(
self, surface: pg.Surface, x: int, y: int, width: int = 300, height: int = 100
) -> None:
if width < 0 or height < 0:
raise ValueError("Chart dimensions must be non-negative")
self.rect = pg.Rect(x, y, width, height)
for i in range(len(self.values_displayed)):
self.draw_bar(screen, i, color=self.colors[i])
self.draw_labels(screen)
if not self._values:
return
scale = self.max_value or max(1, *self._values, *self.values_displayed)
slot = width / len(self._values)
labels = self.labels
if labels is None:
labels = [f"{value:.0f}" for value in self.values_displayed]
for i, shown in enumerate(self.values_displayed):
bar_height = round(min(1, shown / scale) * height)
left = round(x + i * slot + BAR_GAP / 2)
bar = pg.Rect(left, y + height - bar_height, max(0, round(slot - BAR_GAP)), bar_height)
color = self.colors[i % len(self.colors)]
pg.draw.rect(surface, color, bar, border_top_left_radius=3, border_top_right_radius=3)
if i < len(labels):
text = self.font.render(labels[i], True, self.label_color)
center = round(x + (i + 0.5) * slot)
surface.blit(text, text.get_rect(midtop=(center, y + height + LABEL_GAP)))
Loading
Loading