diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aadbb6..7eaf425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - Remove `Group.draw`, which drew every widget at the same position. - Replace the eight button classes with four: `Button` (rounded text button), `ImageButton`, `IconButton` and `IconToggle`. All click on a left press and release inside the button, cancel on window focus loss, and expose `pressed`. - `Button` sizes itself to its text (or a fixed `width` with `align`), derives hover and pressed shades from `color`, picks black or white text for contrast unless `text_color` is given, and defaults to `border_radius=6` and `padding=(16, 8)`. `outline_color` draws a 2 px outline. `button.text` is a property that resizes the button. -- `Label(text, color="white", *, font=None, align="left")` draws exactly at (x, y); `draw(surface, x, y, width=None)` aligns the text within `width`. Setting `text` or `color` re-renders only when the value changes. +- `Label(text, color="black", *, font=None, align="left")` draws exactly at (x, y) (the color is now optional); `draw(surface, x, y, width=None)` aligns the text within `width`. Setting `text` or `color` re-renders only when the value changes. - `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. @@ -21,6 +21,8 @@ - `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. +- 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 8a29033..f1f02ca 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Small UI widgets for Pygame: buttons, sliders, dropdowns, text inputs, labels, and animated bar charts. You control the layout; the kit handles interaction and drawing. +![The PygameUIKit demo: cards showing every widget and an event log](https://raw.githubusercontent.com/Times0/PygameUIKit/master/images/img.png) + Requires Python 3.11+ (tested through 3.15). Uses [pygame-ce](https://pyga.me/), which provides the familiar `import pygame` API. Use a fresh environment when upgrading from classic `pygame`; the two packages share the same import name. ## Install @@ -22,19 +24,29 @@ cd PygameUIKit uv run pygameuikit-demo ``` -uv installs Python and dependencies automatically. The demo is also included in the installed package: `pygameuikit-demo` or `python -m PygameUIKit.demo`. +uv installs Python and dependencies automatically. The demo is also included in the installed package: `pygameuikit-demo` or `python -m PygameUIKit.demo`. It shows every widget with its main options, and an event log shows each callback as it fires. Its source, [`demo.py`](https://github.com/Times0/PygameUIKit/blob/master/src/PygameUIKit/demo.py), is meant to be copied from. `--screenshot demo.png` saves one frame without opening a window. ## Use it ```python import pygame -from PygameUIKit import Button, Group +from PygameUIKit import Button, Dropdown, Group, Label, Slider pygame.init() screen = pygame.display.set_mode((640, 360)) clock = pygame.time.Clock() + ui = Group() -hello = Button("Say hello", lambda: print("Hello!"), color=(85, 145, 92), ui_group=ui) +title = Label("Volume") +volume = Slider(0, 100, value=50, show_value=True, on_change=print, ui_group=ui) +quality = Dropdown(["Low", "Medium", "High"], on_change=print, ui_group=ui) + + +def mute(): + volume.value = 0 # setting a value never calls on_change + + +mute_button = Button("Mute", mute, ui_group=ui) running = True while running: @@ -42,31 +54,38 @@ while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False - else: - ui.handle_event(event) - if not running: - break + elif not ui.handle_event(event): + pass # no widget used this event: handle your own keys and clicks here ui.update(dt) - screen.fill((30, 32, 36)) - hello.draw(screen, 240, 150) + + screen.fill("white") + title.draw(screen, 20, 20) + volume.draw(screen, 20, 60, width=240) + mute_button.draw(screen, 340, 45) + quality.draw(screen, 20, 100) # dropdowns last, so their menu covers other widgets pygame.display.flip() 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 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. +- **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. +- **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. +- **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 -- `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(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([10, 20, 30], labels=["A", "B", "C"], max_value=50)`: animated bars; `set_value(index, value)` or set `values`. Values must be non-negative. +Options after `*` are keyword-only, and every widget also takes `ui_group`. -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. +- `Label(text, color="black", *, font=None, align="left")`. Draws at exactly (x, y). `draw(surface, x, y, width=None)` aligns the text within `width` (`"left"`, `"center"` or `"right"`). Properties: `text`, `color`. +- `Button(text, on_click=None, *, color=(59, 130, 246), text_color=None, outline_color=None, border_radius=6, padding=(16, 8), font=None, width=None, align="center")`. Sized to its text unless `width` is set; `align` places the text within `width`. Hover and pressed shades come from `color`, and the text is black or white for contrast unless `text_color` is set. Properties: `text` (resizes the button), `hovered`, `pressed`. Callback: `on_click()`. +- `ImageButton(image, on_click=None, *, hover_image=None, text="", text_color="white", font=None)`. An image, swapped for `hover_image` while hovered or pressed, with optional centered `text`. +- `IconButton(image, on_click=None, *, hover_color=(128, 128, 128), hover_alpha=128, padding=6, border_radius=6)`. An icon with a translucent backdrop while hovered. The clickable area is the icon plus `padding`, starting at (x, y). +- `IconToggle(image_off, image_on, on_change=None, *, value=False, ...)`. Like `IconButton`, but each click switches `value` and the icon. Callback: `on_change(value)` with the new bool. +- `Slider(min, max, step=1, *, value=None, on_change=None, show_value=False, font=None, fill_color, track_color, knob_color, text_color)`. `draw(surface, x, y, width=200, height=6)`. Drag or click the track; Left/Right, Home and End work while it is hovered. `value` is clamped and snapped to `step`, and `show_value` prints it to the right of the track. Callback: `on_change(value)`. +- `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)`. A fixed-width, single-line field. Click it to focus it; Enter calls `on_submit(text)` and Escape unfocuses. Properties: `text`, `focused`. Callbacks: `on_change(text)` after each edit, `on_submit(text)`. It reads `TEXTINPUT` events for Unicode text and `KEYDOWN` for editing, so forward both, once, through the group. +- `Dropdown(options, *, index=0, on_change=None, width=None, font=None, color, text_color, highlight_color, outline_color, border_radius=6)`. Click to open the menu, then pick with the mouse or with Up/Down and Enter. Escape or a click elsewhere closes it. Properties: `value` (the option), `index`, `is_open`. Callback: `on_change(value)` when the user picks a different option. +- `BarChart(values, *, labels=None, max_value=None, colors=None, font=None, label_color="black", speed=200)`. `draw(surface, x, y, width=300, height=100)`. Bars move toward their values at `speed` units per second during `ui.update(dt)`. `set_value(index, value)` changes one bar; setting `values` replaces them all. Values must be non-negative. Without `labels`, each bar shows its value; without `max_value`, the tallest bar fills the height. `colors` cycle. ## Develop diff --git a/images/img.png b/images/img.png index da69fbb..d8fa3c2 100644 Binary files a/images/img.png and b/images/img.png differ diff --git a/images/logo.jpeg b/images/logo.jpeg deleted file mode 100644 index 40c6375..0000000 Binary files a/images/logo.jpeg and /dev/null differ diff --git a/src/PygameUIKit/demo.py b/src/PygameUIKit/demo.py index ff66dd3..cbc5097 100644 --- a/src/PygameUIKit/demo.py +++ b/src/PygameUIKit/demo.py @@ -1,127 +1,413 @@ +"""A tour of every PygameUIKit widget: run ``pygameuikit-demo``. + +Each card shows one widget family, its main options and a one-line summary of its API. +Every callback writes to the event log card. To add a card, write a ``draw_`` method +and add one ``Card`` to ``Demo.cards``. +""" + import argparse import os import random +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +import pygame as pg + +from PygameUIKit import ( + BarChart, + Button, + Dropdown, + Group, + IconButton, + IconToggle, + ImageButton, + Label, + Slider, + TextInput, +) + +SIZE = (1100, 720) +ASSETS = Path(__file__).parent / "assets" + +BACKGROUND = (241, 243, 246) +CARD = (255, 255, 255) +BORDER = (222, 226, 232) +INK = (17, 24, 39) +MUTED = (107, 114, 128) +CODE = (79, 70, 229) +BLUE = (59, 130, 246) +GREEN = (22, 163, 74) +RED = (220, 38, 38) + +MARGIN, GAP, PAD = 24, 16, 16 +HEADER = 60 +COLUMNS = 3 +ROWS = (202, 158, 244) # card heights, top to bottom -import pygame -from pygame import Color +PALETTES = { + "Ocean": ["#0e7490", "#0891b2", "#06b6d4", "#22d3ee", "#67e8f9", "#0369a1"], + "Forest": ["#15803d", "#16a34a", "#4ade80", "#65a30d", "#a3e635", "#047857"], + "Sunset": ["#be185d", "#e11d48", "#f97316", "#f59e0b", "#facc15", "#db2777"], + "Mono": ["#374151", "#4b5563", "#6b7280", "#9ca3af", "#d1d5db", "#1f2937"], +} -from PygameUIKit import BarChart, Dropdown, Group, Slider, TextInput, button -# -# -# This is a file to showcase every widget -# -# +def cell(column: int, row: int, columns: int = 1) -> pg.Rect: + """The rectangle of the card at ``column``, ``row`` spanning ``columns`` grid columns.""" + width = (SIZE[0] - 2 * MARGIN - (COLUMNS - 1) * GAP) // COLUMNS + top = HEADER + sum(ROWS[:row]) + row * GAP + return pg.Rect( + MARGIN + column * (width + GAP), top, columns * width + (columns - 1) * GAP, ROWS[row] + ) -RED = (255, 0, 0) -BTN_GREEN = (0, 169, 0) -BTN_BLUE = (83, 131, 232) + +@dataclass +class Card: + """A titled panel. ``draw_content(surface, area)`` draws the widgets inside ``area``.""" + + title: str + caption: str + rect: pg.Rect + draw_content: Callable[[pg.Surface, pg.Rect], None] + + def draw(self, surface: pg.Surface, title_font: pg.font.Font, code_font: pg.font.Font): + pg.draw.rect(surface, CARD, self.rect, border_radius=10) + pg.draw.rect(surface, BORDER, self.rect, 1, border_radius=10) + x, y = self.rect.x + PAD, self.rect.y + PAD + surface.blit(title_font.render(self.title, True, INK), (x, y)) + y += title_font.get_height() + 4 + surface.blit(code_font.render(self.caption, True, CODE), (x, y)) + y += code_font.get_height() + 14 + self.draw_content(surface, pg.Rect(x, y, self.rect.w - 2 * PAD, self.rect.bottom - y - PAD)) + + +def badge(size: tuple[int, int], top: pg.typing.ColorLike, bottom: pg.typing.ColorLike): + """A rounded gradient image. Normally you would ``pg.image.load`` one instead.""" + gradient = pg.Surface((1, 2), pg.SRCALPHA) + gradient.set_at((0, 0), top) + gradient.set_at((0, 1), bottom) + image = pg.transform.smoothscale(gradient, size) + mask = pg.Surface(size, pg.SRCALPHA) + pg.draw.rect(mask, "white", mask.get_rect(), border_radius=size[1] // 2) + image.blit(mask, (0, 0), special_flags=pg.BLEND_RGBA_MIN) + return image class Demo: def __init__(self): + self.screen = pg.display.set_mode(SIZE) + pg.display.set_caption("PygameUIKit demo") + self.clock = pg.time.Clock() self.done = False - def_font = pygame.font.Font(None, 24) - cwd = os.path.dirname(__file__) - img_play = pygame.image.load(os.path.join(cwd, "assets/play.png")) - img_stop = pygame.image.load(os.path.join(cwd, "assets/stop.png")) - self.screen = pygame.display.set_mode((500, 500)) - pygame.display.set_caption("PygameUIKit") - self.clock = pygame.time.Clock() - - self.easy_objects = Group() - self.text_input = TextInput( - placeholder="This is a placeholder text", - width=240, - ui_group=self.easy_objects, - font=def_font, + self.events: deque[str] = deque(maxlen=8) + + self.card_font = pg.font.Font(None, 28) + self.code_font = pg.font.SysFont("consolas,menlo,dejavusansmono,monospace", 15) + self.font = pg.font.Font(None, 22) + self.small_font = pg.font.Font(None, 20) + play = pg.image.load(ASSETS / "play.png") + pause = pg.image.load(ASSETS / "stop.png") + + self.title = Label("PygameUIKit", INK, font=pg.font.Font(None, 40)) + self.subtitle = Label( + "Every widget and its main options. Callbacks appear in the event log.", + MUTED, + font=self.font, ) - self.btn_pause = button.IconToggle( - img_play, img_stop, self.set_dancing, ui_group=self.easy_objects + + # Every widget joins one Group, which routes events and sets the mouse cursor. + self.ui = ui = Group() + font = self.font + + # Buttons + self.color_buttons = [ + Button(name, self.on_click(name), color=color, font=font, ui_group=ui) + for name, color in (("Primary", BLUE), ("Success", GREEN), ("Danger", RED)) + ] + self.outline_button = Button( + "Outline", + self.on_click("Outline"), + color="white", + outline_color=BORDER, + font=font, + ui_group=ui, ) - self.btn_png = button.IconButton(img_play, self.change_values, ui_group=self.easy_objects) - self.btn_shuffle = button.Button( - "Shuffle", self.change_values, color=BTN_BLUE, font=def_font, ui_group=self.easy_objects + self.clicks = 0 + self.counter = Button("Clicked 0 times", self.count, color=INK, font=font, ui_group=ui) + self.aligned_buttons = [ + Button(align, self.on_click(align), width=96, align=align, font=font, ui_group=ui) + for align in ("left", "center", "right") + ] + + # Icons + self.icon_button = IconButton(play, self.on_icon_click, ui_group=ui) + self.animate = IconToggle(play, pause, self.on_animate, ui_group=ui) + self.image_button = ImageButton( + badge((120, 36), (129, 140, 248), (79, 70, 229)), + lambda: self.log("ImageButton.on_click()"), + hover_image=badge((120, 36), (165, 180, 252), (99, 102, 241)), + text="Image", + font=font, + ui_group=ui, + ) + self.icon_captions = [ + Label(text, MUTED, font=self.small_font, align="center") + for text in ("IconButton", "IconToggle", "ImageButton") + ] + self.icon_hint = Label("The IconToggle animates the chart", MUTED, font=self.small_font) + + # Label + self.aligned_labels = [ + Label(f'align="{align}"', INK, font=font, align=align) + for align in ("left", "center", "right") + ] + self.label_hint = Label( + "draw(surface, x, y, width) aligns in width", MUTED, font=self.small_font ) - self.slider = Slider( + # Slider + self.int_slider = Slider( 0, 100, - on_change=lambda value: self.change_values(), + value=40, show_value=True, - ui_group=self.easy_objects, - font=def_font, + on_change=lambda value: self.log(f"Slider.on_change({value})", replace=True), + ui_group=ui, ) + self.float_slider = Slider( + 0, + 1, + 0.05, + value=0.5, + show_value=True, + fill_color=GREEN, + on_change=lambda value: self.log(f"Slider.on_change({value})", replace=True), + ui_group=ui, + ) + self.slider_labels = [Label(text, INK, font=font) for text in ("Integer", "Step 0.05")] + self.slider_hint = Label("Hover, then Left/Right or Home/End", MUTED, font=self.small_font) - self.dropdown = Dropdown( - ["Hello", "World", "And", "You"], - on_change=self.set_text, - ui_group=self.easy_objects, - font=def_font, + # TextInput + self.text_input = TextInput( + placeholder="Type, then press Enter", + on_change=lambda text: self.log(f"TextInput.on_change({text!r})", replace=True), + on_submit=self.on_submit, + width=cell(1, 1).w - 2 * PAD, + ui_group=ui, ) + self.submitted = Label("Submitted: (nothing yet)", INK, font=font) + self.text_hint = Label("Click to focus, Escape to unfocus", MUTED, font=self.small_font) + + # Dropdown + self.palette = Dropdown(list(PALETTES), on_change=self.on_palette, ui_group=ui) + self.palette_label = Label("Chart colors", INK, font=font) + self.palette_value = Label("", MUTED, font=self.small_font) - data = [10, 20, 30, 40, 50] - self.chart = BarChart(data, ui_group=self.easy_objects, max_value=100, font=def_font) + # BarChart + self.chart = BarChart( + [40, 75, 30, 90, 55, 65], + labels=["Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + max_value=100, + colors=PALETTES[self.palette.value], + font=self.small_font, + label_color=MUTED, + ui_group=ui, + ) + self.randomize = Button("Randomize", self.on_randomize, font=font, ui_group=ui) + self.chart_hints = [ + Label(text, MUTED, font=self.small_font) + for text in ( + "Press R (ignored while typing)", + "Animate with the IconToggle", + "Recolor with the Dropdown", + ) + ] + self.animation_timer = 0.0 - self.dancing = False + self.log_empty = Label("Interact with a widget...", MUTED, font=self.code_font) - def run(self, frames=None): + # The layout lives here. Cards are drawn in this order, so the Dropdown card comes + # last: its open menu then covers the cards below it. + self.cards = [ + Card("Buttons", 'Button("OK", on_click, color=...)', cell(0, 0), self.draw_buttons), + Card("Icons", "IconToggle(off, on, on_change=...)", cell(1, 0), self.draw_icons), + Card("Label", 'Label("Hi", align="center")', cell(2, 0), self.draw_labels), + Card("Slider", "Slider(0, 100, on_change=...)", cell(0, 1), self.draw_sliders), + Card("TextInput", "TextInput(on_submit=...)", cell(1, 1), self.draw_text_input), + Card( + "BarChart", + "BarChart(values, labels=..., max_value=100)", + cell(0, 2, 2), + self.draw_chart, + ), + Card("Event log", "on_click() / on_change(value)", cell(2, 2), self.draw_log), + Card("Dropdown", "Dropdown(options, on_change=...)", cell(2, 1), self.draw_dropdown), + ] + + # Callbacks + + def log(self, event: str, replace: bool = False) -> None: + """Add ``event`` to the event log; ``replace`` overwrites a run of the same callback.""" + if replace and self.events and self.events[-1].split("(")[0] == event.split("(")[0]: + self.events[-1] = event + else: + self.events.append(event) + + def on_click(self, name: str) -> Callable[[], None]: + return lambda: self.log(f'Button("{name}").on_click()') + + def count(self) -> None: + self.clicks += 1 + self.counter.text = f"Clicked {self.clicks} time{'s' * (self.clicks != 1)}" + self.log(f"Button.on_click() # click {self.clicks}") + + def on_icon_click(self) -> None: + self.log("IconButton.on_click()") + + def on_animate(self, value: bool) -> None: + self.log(f"IconToggle.on_change({value})") + + def on_submit(self, text: str) -> None: + self.submitted.text = f"Submitted: {text}" + self.log(f"TextInput.on_submit({text!r})") + + def on_palette(self, value: str) -> None: + self.chart.colors = PALETTES[value] + self.log(f"Dropdown.on_change({value!r})") + + def on_randomize(self) -> None: + self.randomize_chart() + self.log('Button("Randomize").on_click()') + + def randomize_chart(self) -> None: + self.chart.values = [random.randint(5, 100) for _ in self.chart.values] + + # Main loop + + def run(self, frames: int | None = None) -> None: + self.draw() count = 0 - self.draw(self.screen) while not self.done and (frames is None or count < frames): dt = self.clock.tick(60) / 1000 - self.events() - if self.done: - break + for event in pg.event.get(): + self.handle_event(event) self.update(dt) - self.draw(self.screen) + self.draw() count += 1 - def events(self): - events = pygame.event.get() - for event in events: - self.easy_objects.handle_event(event) - if event.type == pygame.QUIT: - self.done = True - - def update(self, dt): - self.easy_objects.update(dt) - if self.dancing: - self.change_values() - - def draw(self, win): - W, H = self.screen.get_size() - win.fill(Color(224, 224, 224)) - self.text_input.draw(win, W // 2 - self.text_input.rect.w // 2, H - 200) - self.btn_pause.draw(win, 100, 200) - self.btn_png.draw(win, 100, 300) - self.btn_shuffle.draw(win, 100, 430) - self.slider.draw(win, 100, 400, 300) - self.chart.draw(win, 159, 164, 300, 100) - self.dropdown.draw(win, 100, 100) - pygame.display.flip() - - def change_values(self): - i = random.randint(0, len(self.chart.values) - 1) - new_value = random.randint(0, 100) - self.chart.set_value(i, new_value) - - def set_text(self, text): - self.text_input.text = text - - def set_dancing(self, dancing): - self.dancing = dancing - - -def main(): + def handle_event(self, event: pg.event.Event) -> None: + if event.type == pg.QUIT: + self.done = True + # handle_event returns True when a widget used the event, e.g. a key typed into the + # focused TextInput. Only unused events reach the demo's own shortcuts. + elif not self.ui.handle_event(event): + if event.type == pg.KEYDOWN and event.key == pg.K_r: + self.randomize_chart() + + def update(self, dt: float) -> None: + self.ui.update(dt) # animates the chart and repeats held keys in the text input + if self.animate.value: + self.animation_timer += dt + if self.animation_timer >= 0.2: + self.animation_timer = 0 + self.chart.set_value(random.randrange(6), random.randint(5, 100)) + + def draw(self) -> None: + self.screen.fill(BACKGROUND) + self.title.draw(self.screen, MARGIN, 20) + self.subtitle.draw(self.screen, self.title.rect.right + 16, self.title.rect.bottom - 20) + for card in self.cards: + card.draw(self.screen, self.card_font, self.code_font) + pg.display.flip() + + # Cards: each draws its widgets inside ``area``, the card without its title and caption. + + def draw_buttons(self, surface: pg.Surface, area: pg.Rect) -> None: + x, y = area.topleft + for btn in self.color_buttons: + btn.draw(surface, x, y) + x = btn.rect.right + 8 + x, y = area.x, btn.rect.bottom + 10 + self.outline_button.draw(surface, x, y) + self.counter.draw(surface, self.outline_button.rect.right + 8, y) + x, y = area.x, self.counter.rect.bottom + 10 + for btn in self.aligned_buttons: + btn.draw(surface, x, y) + x = btn.rect.right + 8 + + def draw_icons(self, surface: pg.Surface, area: pg.Rect) -> None: + widgets = (self.icon_button, self.animate, self.image_button) + slot = area.w // len(widgets) + for i, (widget, caption) in enumerate(zip(widgets, self.icon_captions, strict=True)): + center = area.x + i * slot + slot // 2 + widget.draw(surface, center - widget.rect.w // 2, area.y + 20 - widget.rect.h // 2) + caption.draw(surface, area.x + i * slot, area.y + 52, slot) + self.icon_hint.draw(surface, area.x, area.y + 82) + + def draw_labels(self, surface: pg.Surface, area: pg.Rect) -> None: + box = pg.Rect(area.x, area.y, area.w, 3 * 26 + 8) + pg.draw.rect(surface, BACKGROUND, box, border_radius=6) + for i, label in enumerate(self.aligned_labels): + label.draw(surface, box.x + 10, box.y + 10 + i * 26, box.w - 20) + self.label_hint.draw(surface, area.x, box.bottom + 10) + + def draw_sliders(self, surface: pg.Surface, area: pg.Rect) -> None: + for i, (label, bar) in enumerate( + zip(self.slider_labels, (self.int_slider, self.float_slider), strict=True) + ): + y = area.y + 8 + i * 32 + label.draw(surface, area.x, y - label.rect.h // 2 + 3) + bar.draw(surface, area.x + 98, y, width=160) + self.slider_hint.draw(surface, area.x, area.y + 66) + + def draw_text_input(self, surface: pg.Surface, area: pg.Rect) -> None: + self.text_input.draw(surface, area.x, area.y) + self.submitted.draw(surface, area.x, self.text_input.rect.bottom + 12) + self.text_hint.draw(surface, area.x, self.submitted.rect.bottom + 10) + + def draw_dropdown(self, surface: pg.Surface, area: pg.Rect) -> None: + self.palette_label.draw(surface, area.x, area.y + 10) + self.palette_value.text = f"value={self.palette.value!r} index={self.palette.index}" + self.palette_value.draw(surface, area.x, area.y + 52) + self.palette.draw(surface, area.x + 120, area.y) + + def draw_chart(self, surface: pg.Surface, area: pg.Rect) -> None: + chart_width = area.w - 240 + self.chart.draw(surface, area.x, area.y, chart_width, area.h - 24) + x = area.x + chart_width + 24 + self.randomize.draw(surface, x, area.y) + for i, hint in enumerate(self.chart_hints): + hint.draw(surface, x, self.randomize.rect.bottom + 14 + i * 22) + + def draw_log(self, surface: pg.Surface, area: pg.Rect) -> None: + if not self.events: + self.log_empty.draw(surface, area.x, area.y) + surface.set_clip(area) + for i, event in enumerate(self.events): + text = self.code_font.render(event, True, INK) + surface.blit(text, (area.x, area.y + i * (self.code_font.get_height() + 1))) + surface.set_clip(None) + + +def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser(description="Explore the PygameUIKit widgets") - parser.add_argument("--frames", type=int, help="Exit after this many frames (for smoke tests)") - args = parser.parse_args() - pygame.init() + parser.add_argument("--frames", type=int, help="exit after this many frames") + parser.add_argument("--screenshot", metavar="PATH", help="save the last frame as a PNG, exit") + args = parser.parse_args(argv) + frames = args.frames + if args.screenshot: + os.environ.setdefault("SDL_VIDEODRIVER", "dummy") # no window needed + frames = 1 if frames is None else frames + pg.init() try: - Demo().run(frames=args.frames) + demo = Demo() + demo.run(frames) + if args.screenshot: + pg.image.save(demo.screen, args.screenshot) finally: - pygame.quit() + pg.quit() if __name__ == "__main__": diff --git a/src/PygameUIKit/label.py b/src/PygameUIKit/label.py index 69aab93..48dd36d 100644 --- a/src/PygameUIKit/label.py +++ b/src/PygameUIKit/label.py @@ -23,7 +23,7 @@ class Label(Widget): def __init__( self, text: str, - color: ColorLike = "white", + color: ColorLike = "black", *, font: pg.font.Font | None = None, align: str = "left", diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000..388bba7 --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,39 @@ +import pygame as pg + +from PygameUIKit import demo + + +def click(app, widget): + for kind in (pg.MOUSEBUTTONDOWN, pg.MOUSEBUTTONUP): + app.handle_event(pg.event.Event(kind, pos=widget.rect.center, button=1)) + + +def test_demo_runs_a_few_frames(): + app = demo.Demo() + app.run(frames=3) + assert app.screen.get_size() == demo.SIZE + + +def test_demo_logs_callbacks_and_routes_unused_keys(): + app = demo.Demo() + app.draw() + click(app, app.randomize) + click(app, app.animate) + assert list(app.events) == ['Button("Randomize").on_click()', "IconToggle.on_change(True)"] + + values = app.chart.values + click(app, app.text_input) + app.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_r, mod=0, unicode="r")) + app.handle_event(pg.event.Event(pg.TEXTINPUT, text="r")) + assert app.text_input.text == "r" and app.chart.values == values + assert app.events[-1] == "TextInput.on_change('r')" + + app.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_ESCAPE, mod=0, unicode="")) + app.handle_event(pg.event.Event(pg.KEYDOWN, key=pg.K_r, mod=0, unicode="r")) + assert app.chart.values != values + + +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