From: mar77i Date: Tue, 4 Feb 2025 23:17:18 +0000 (+0100) Subject: split up ui module X-Git-Url: https://git.mar77i.info/?a=commitdiff_plain;h=d8b8dba712bb46344ce220f02956b0987f9a7545;p=zenbook_gui split up ui module --- diff --git a/ui/__init__.py b/ui/__init__.py index e69de29..c07599a 100644 --- a/ui/__init__.py +++ b/ui/__init__.py @@ -0,0 +1,14 @@ +from .base import Child, Parent, Root +from .button import Button +from .drop_down import DropDown +from .fps_widget import FPSWidget +from .icon import Icon +from .icon_button import IconButton +from .label import Label +from .message_box import MessageBox +from .modal import Modal +from .slider import Slider +from .spinner import Spinner +from .switch import Switch +from .tab_bar import TabBar +from .text_input import TextInput diff --git a/ui/base.py b/ui/base.py new file mode 100644 index 0000000..6fe79da --- /dev/null +++ b/ui/base.py @@ -0,0 +1,136 @@ +from functools import partial + +import pygame + + +class EventMethodDispatcher: + MODS = (pygame.KMOD_CTRL, pygame.KMOD_ALT, pygame.KMOD_META, pygame.KMOD_SHIFT) + KEY_METHODS = {} + + def get_key_method(self, key, mod): + mods = set() + for mask in self.MODS: + if mod & mask: + mods.add(mask) + method = self.KEY_METHODS.get(frozenset(mods), {}).get(key) + if method is not None: + return partial(method, self) + return None + + def handle_keydown(self, ev): + if not self.KEY_METHODS: + return + key_method = self.get_key_method(ev.key, ev.mod) + if key_method is not None: + key_method() + + def handle_event(self, ev): + method_name = f"handle_{pygame.event.event_name(ev.type).lower()}" + if hasattr(self, method_name): + getattr(self, method_name)(ev) + + +class Parent(EventMethodDispatcher): + def __init__(self): + self.children = [] + + def handle_event(self, ev): + for child in (super(), *self.children): + child.handle_event(ev) + + def update(self): + for child in self.children: + child.update() + + def draw(self): + for child in self.children: + child.draw() + + +class Child(EventMethodDispatcher): + root: "Root" + + def __init__(self, root): + self.root = root + + @property + def dirty(self): + return self.root.dirty + + @dirty.setter + def dirty(self, value): + self.root.dirty = value + + @property + def font(self): + return self.root.font + + @property + def surf(self): + return self.root.surf + + def draw(self): + pass + + def update(self): + pass + + +class Root(Parent): + BACKGROUND_COLOR: pygame.Color + + def __init__(self, surf, font=None): + super().__init__() + self.surf = surf + self.font = font + self.running = True + self.dirty = False + self.clock = pygame.time.Clock() + self.cursor = None + self.stop_event = False + + def handle_quit(self, _): + self.running = False + + def handle_windowexposed(self, _): + self.dirty = True + + handle_activeevent = handle_windowexposed + + def key_escape(self): + if self.cursor is None: + self.running = False + + KEY_METHODS = {frozenset(set()): {pygame.K_ESCAPE: key_escape}} + + def handle_event(self, ev): + for child in (super(Parent, self), *self.children): + child.handle_event(ev) + if not self.running or self.stop_event: + break + + def draw(self): + if hasattr(self, "BACKGROUND_COLOR"): + self.surf.fill(self.BACKGROUND_COLOR) + super().draw() + + def update(self): + super().update() + if self.cursor is not None: + self.cursor.update() + + def run(self): + while True: + for ev in pygame.event.get(): + self.stop_event = False + self.handle_event(ev) + if not self.running: + break + if not self.running: + break + self.update() + if self.dirty: + self.draw() + pygame.display.update() + self.dirty = False + self.clock.tick(60) diff --git a/ui/button.py b/ui/button.py new file mode 100644 index 0000000..9c2c14a --- /dev/null +++ b/ui/button.py @@ -0,0 +1,44 @@ +import pygame + +from .base import Child + + +class Button(Child): + def __init__(self, root, rect, value, callback, is_active=False): + super().__init__(root) + self.rect = rect + self.value = value + self.callback = callback + self.is_active = is_active + self.pushed = False + + def draw(self): + if not self.pushed: + value_color = "lime" if self.is_active else "gray" + colors = ("black", value_color, value_color) + else: + colors = ("darkgray", "lightgray", "black") + pygame.draw.rect(self.surf, colors[0], self.rect) + pygame.draw.rect(self.surf, colors[1], self.rect, 8) + fs = self.font.render(self.value, True, colors[2]) + fs_size = fs.get_size() + center = self.rect.center + self.surf.blit(fs, (center[0] - fs_size[0] // 2, center[1] - fs_size[1] // 2)) + + def handle_mousebuttondown(self, ev): + if ev.button == 1 and self.rect.collidepoint(ev.pos): + self.pushed = True + self.dirty = True + + def handle_mousebuttonup(self, ev): + if ev.button == 1 and self.pushed and self.rect.collidepoint(ev.pos): + self.pushed = False + self.callback() + self.dirty = True + + def handle_mousemotion(self, ev): + if not self.pushed: + return + if ev.buttons[0] and not self.rect.collidepoint(ev.pos): + self.pushed = False + self.dirty = True diff --git a/ui/drop_down.py b/ui/drop_down.py new file mode 100644 index 0000000..d644679 --- /dev/null +++ b/ui/drop_down.py @@ -0,0 +1,44 @@ +from functools import partial + +import pygame + +from .button import Button +from .modal import Modal + + +class DropDownMenu(Modal): + def __init__(self, root, rect, entries, callback): + super().__init__(root) + self.callback = callback + self.children.extend( + ( + Button( + root, + pygame.Rect( + (rect.left, rect.bottom + i * rect.height), rect.size, + ), + entry, + partial(self.choose, i), + ) + for i, entry in enumerate(entries) + ) + ) + self.buttons_rect = pygame.Rect( + rect.bottomleft, (rect.width, rect.height * len(entries)) + ) + + def choose(self, i=None): + self.deactivate() + self.callback(i) + + def handle_mousebuttondown(self, ev): + if ev.button != 1: + return + elif not self.buttons_rect.collidepoint(ev.pos): + self.deactivate() + + +class DropDown(Button): + def __init__(self, root, rect, value, entries, callback, is_active=False): + self.dropdown_menu = DropDownMenu(root, rect, entries, callback) + super().__init__(root, rect, value, self.dropdown_menu.activate, is_active) diff --git a/ui/fps_widget.py b/ui/fps_widget.py new file mode 100644 index 0000000..472efa8 --- /dev/null +++ b/ui/fps_widget.py @@ -0,0 +1,26 @@ +from .base import Child + + +class FPSWidget(Child): + FPS_COLOR = "yellow" + + def __init__(self, root): + super().__init__(root) + self.clock = self.root.clock + self.current_fps = None + + def update(self): + new_fps = int(self.clock.get_fps()) + if self.current_fps != new_fps: + self.current_fps = new_fps + self.dirty = True + + def draw(self): + surf_size = self.surf.get_size() + fs = self.font.render( + f"{int(self.current_fps)} FPS", True, self.FPS_COLOR + ) + fs_size = fs.get_size() + self.surf.blit( + fs, (surf_size[0] - fs_size[0] - 7, surf_size[1] - fs_size[1] - 7) + ) diff --git a/ui/icon.py b/ui/icon.py new file mode 100644 index 0000000..35ec633 --- /dev/null +++ b/ui/icon.py @@ -0,0 +1,10 @@ +from .base import Child + + +class Icon(Child): + def __init__(self, root, shape): + super().__init__(root) + self.shape = shape + + def draw(self): + self.shape.draw(self.surf, "gray") diff --git a/ui/icon_button.py b/ui/icon_button.py new file mode 100644 index 0000000..ebb077a --- /dev/null +++ b/ui/icon_button.py @@ -0,0 +1,21 @@ +import pygame + +from .button import Button + + +class IconButton(Button): + def __init__(self, root, shape, *args, **kwargs): + super().__init__(root, *args, **kwargs) + self.shape = shape + + def draw(self): + if self.pushed: + pygame.draw.rect(self.surf, "honeydew4", self.rect) + self.shape.draw(self.surf, "black" if self.pushed else "white") + if self.is_active: + color = "lime" + elif self.pushed: + color = "red" + else: + color = "gray" + pygame.draw.rect(self.surf, color, self.rect, 8) diff --git a/ui/label.py b/ui/label.py new file mode 100644 index 0000000..d8cd65c --- /dev/null +++ b/ui/label.py @@ -0,0 +1,15 @@ +from .base import Child + + +class Label(Child): + def __init__(self, root, rect, value): + super().__init__(root) + self.rect = rect + self.value = value + + def draw(self): + fs = self.font.render(self.value, True, "gray") + self.surf.blit( + fs, + (self.rect.left + 16, self.rect.centery - fs.get_height() // 2) + ) diff --git a/ui/message_box.py b/ui/message_box.py new file mode 100644 index 0000000..2483aa0 --- /dev/null +++ b/ui/message_box.py @@ -0,0 +1,50 @@ +import pygame + +from .button import Button +from .label import Label +from .modal import Modal + + +class MessageBox(Modal): + def __init__(self, root, rect, message): + super().__init__(root) + self.rect = rect + self.message = message + self.label = Label(root, pygame.Rect(rect.center, (10, 10)), "") + self.children.extend((self.label, *self.get_buttons())) + + def get_buttons(self): + rect = self.rect + yield Button( + self.root, + pygame.Rect( + (rect.left + rect.width // 3, rect.centery + 64), + (rect.width // 3, 128), + ), + "OK", + self.deactivate, + ) + + def draw(self): + super().draw() + pygame.draw.rect(self.surf, "black", self.rect) + pygame.draw.rect(self.surf, "gray", self.rect, 1) + + def activate(self): + self.update_message() + super().activate() + + def update_message(self): + if self.message == self.label.value: + return + rect = self.rect + self.label.value = self.message + fs_size = self.font.size(self.message) + label_rect = pygame.Rect( + rect.topleft, + (rect.width, rect.height * 3 // 4), + ) + self.label.rect = pygame.Rect( + (label_rect.centerx - fs_size[0] // 2, label_rect.centery - 96), + fs_size, + ) diff --git a/ui/modal.py b/ui/modal.py new file mode 100644 index 0000000..2e97f59 --- /dev/null +++ b/ui/modal.py @@ -0,0 +1,39 @@ +import pygame + +from .base import Child + + +class Modal(Child): + def __init__(self, root): + super().__init__(root) + self.backsurf = None + self.tint = True + self.children = [] + + def draw(self): + self.surf.blit(self.backsurf, (0, 0)) + if not self.tint: + return + tintsurf = pygame.Surface(self.surf.get_size(), pygame.SRCALPHA, 32) + tintsurf.fill(pygame.Color(0x00000080)) + self.surf.blit(tintsurf, (0, 0)) + + def activate(self): + self.backsurf = self.root.surf.copy() + children = self.children.copy() + self.children.clear() + self.children.extend(self.root.children) + self.root.children.clear() + self.root.children.append(self) + self.root.children.extend(children) + self.root.stop_event = True + self.dirty = True + + def deactivate(self): + children = self.root.children.copy() + self.root.children.clear() + self.root.children.extend(self.children) + self.children.clear() + self.children.extend(children) + self.root.stop_event = True + self.dirty = True diff --git a/ui/slider.py b/ui/slider.py new file mode 100644 index 0000000..aa516d1 --- /dev/null +++ b/ui/slider.py @@ -0,0 +1,67 @@ +import pygame + +from .base import Child + + +class Slider(Child): + HORIZONTAL = 0 + VERTICAL = 1 + + def __init__(self, root, rect, direction, value=0, callback=None): + super().__init__(root) + self.rect = rect + self.direction = direction + self.extent = (self.rect.width - 1, self.rect.height - 1)[direction] + self.value = value + self.callback = callback + self.pushed = False + + def update_value(self, value): + if self.direction == self.HORIZONTAL: + value -= self.rect.left + else: # self.direction == self.VERTICAL + value = self.extent - (value - self.rect.top) + self.value = value + if self.callback: + self.callback(value) + self.dirty = True + + def handle_mousebuttondown(self, ev): + if ev.button != 1 or not self.rect.collidepoint(ev.pos): + return + self.update_value(ev.pos[self.direction]) + self.pushed = True + + def handle_mousemotion(self, ev): + if not self.pushed: + return + if not ev.buttons[0]: + self.pushed = False + else: + self.update_value(ev.pos[self.direction]) + + def handle_mousebuttonup(self, ev): + if ev.button == 1 and self.pushed: + self.update_value(ev.pos[self.direction]) + self.pushed = False + + def draw(self): + pygame.draw.rect(self.surf, "gray34", self.rect.inflate((8, 8))) + pygame.draw.rect(self.surf, "black", self.rect) + self.draw_cursor(self.surf.subsurface(self.rect)) + + def draw_cursor(self, subsurf): + value = self.value + color = "gray" + if value < 0: + color = "dimgray" + value = -value + if value > self.extent: + value = int(self.extent * (self.extent / value)) + color = "dimgray" + if self.direction == self.HORIZONTAL: + start_pos, end_pos = (value, 0), (value, self.rect.height) + else: + value = self.extent - value + start_pos, end_pos = (0, value), (self.rect.width, value) + pygame.draw.line(subsurf, color, start_pos, end_pos, 8) diff --git a/ui/spinner.py b/ui/spinner.py new file mode 100644 index 0000000..066af4b --- /dev/null +++ b/ui/spinner.py @@ -0,0 +1,110 @@ +import re +from functools import partial +from math import floor +from time import time + +import pygame + +from .base import Child, Parent +from .button import Button +from .text_input import TextInput + + +class RepeatButton(Button): + DELAY_MS = 500 + REPEAT_MS = 100 + + def __init__(self, root, rect, value, callback, is_active=False): + self._pushed = False + super().__init__(root, rect, value, callback, is_active) + self.repeat_ts = None + + @property + def pushed(self): + return self._pushed + + @pushed.setter + def pushed(self, value): + self._pushed = value + if value: + self.repeat_ts = time() + self.DELAY_MS / 1000 + self.callback() + else: + self.repeat_ts = None + + def update(self): + if self.callback is None or not self.pushed: + return + repeat_offset = floor((time() - self.repeat_ts) * 1000 / self.REPEAT_MS) + if repeat_offset < 0: + return + repeat_offset += 1 + for _ in range(repeat_offset): + self.callback() + self.repeat_ts += self.REPEAT_MS * repeat_offset / 1000 + + def handle_mousebuttonup(self, ev): + if ev.button == 1 and self.pushed and self.rect.collidepoint(ev.pos): + self.pushed = False + self.dirty = True + + +class Spinner(Parent, Child): + def __init__(self, root, rect, callback, value=0): + Parent.__init__(self) + Child.__init__(self, root) + self.callback = callback + self.value = value + button_size = (rect.height // 2, rect.height // 2) + self.children.extend( + ( + TextInput( + root, + pygame.Rect( + rect.topleft, (rect.width - button_size[0], rect.height) + ), + self.call_callback, + str(value), + re.compile(r"[-+]?\d*").fullmatch, + ), + RepeatButton( + root, + pygame.Rect( + (rect.right - button_size[0], rect.top), + button_size, + ), + "^", + partial(self.spin_callback, 1), + ), + RepeatButton( + root, + pygame.Rect( + (rect.right - button_size[0], rect.top + button_size[1]), + button_size, + ), + "v", + partial(self.spin_callback, -1), + ), + ) + ) + + def call_callback(self, value): + try: + int_value = int(value) + except ValueError: + pass + else: + if int_value != self.value: + self.value = int_value + self.callback(int_value) + text_input = self.children[0] + str_value = str(self.value) + if str_value != text_input.value: + text_input.value = str(self.value) + self.root.dirty = True + + def spin_callback(self, value): + self.value += value + self.children[0].value = str(self.value) + self.callback(self.value) + self.root.dirty = True diff --git a/ui/switch.py b/ui/switch.py new file mode 100644 index 0000000..69e5d6d --- /dev/null +++ b/ui/switch.py @@ -0,0 +1,103 @@ +from math import asin, nan, sin, sqrt, tau + +import pygame +from colorsys import hsv_to_rgb +from time import time + +from .base import Child + + +class EaseInOutElastic: + def __init__(self, magnitude): + self.p = 1 - magnitude + self.s = self.p / tau * asin(1) + + def __call__(self, x): + if x == 0: + return 0 + elif x == 1: + return 1 + elif x < 0 or x > 1: + raise ValueError(f"x must be between 0 and 1: got {x}") + st = x * 2 + st1 = st - 1 + sgn = (st >= 1) * 2 - 1 + return ( + 2 ** (-sgn * 10 * st1 - 1) + * sin((st1 - self.s) * tau / self.p) + * sgn + + (sgn > 0) + ) + + +class Switch(Child): + MOVE_FOR_SEC = 1 + EASE = staticmethod(EaseInOutElastic((sqrt(5) - 1) / 2)) + + def __init__(self, root, rect, callback, value=False): + super().__init__(root) + self.rect = rect + self.callback = callback + if value is not None and not isinstance(value, bool): + value = bool(value) + self.value = value + self.moving_since = nan + self.flip_again = False + + def draw(self): + pygame.draw.rect(self.surf, "gray", self.rect, 8) + t = time() + if t > self.moving_since + self.MOVE_FOR_SEC: + self.callback(self.value) + if self.flip_again: + self.value = bool(self.value) ^ True + self.moving_since = t + self.flip_again = False + else: + self.moving_since = nan + if self.moving_since is nan: + if self.value is None: + current = 0.5 + else: + current = min(max(int(self.value), 0), 1) + else: + current = (t - self.moving_since) / self.MOVE_FOR_SEC + if not self.value: + current = 1 - current + eased_current = self.EASE(current) + base_radius = min(self.rect.height, self.rect.width / 4) + base_left = self.rect.left + base_radius + movement_width = self.rect.width - 2 * base_radius + normalized_current = min(max(current, 0), 1) + if current < 0.5: + args = (0, 1 - 2 * normalized_current, 1 - normalized_current) + else: + normalized_current -= 0.5 + args = (1 / 3, 2 * normalized_current, 0.5 + normalized_current * 0.5) + rgb = hsv_to_rgb(*args) + pygame.draw.circle( + self.surf, + pygame.Color(*(int(x * 255) for x in rgb)), + (base_left + eased_current * movement_width, self.rect.top + base_radius), + base_radius + ) + + def update(self): + if self.moving_since is not nan: + self.dirty = True + + def set_value(self, value): + if value == self.value: + return + if None in (value, self.value): + self.moving_since = time() - self.MOVE_FOR_SEC / 2 + else: + self.moving_since = time() + self.value = value + + def handle_mousebuttondown(self, ev): + if ev.button == 1 and self.rect.collidepoint(ev.pos): + if self.moving_since is not nan: + self.flip_again = True + return + self.set_value(bool(self.value) ^ True) diff --git a/ui/tab_bar.py b/ui/tab_bar.py new file mode 100644 index 0000000..8fafa30 --- /dev/null +++ b/ui/tab_bar.py @@ -0,0 +1,49 @@ +from functools import partial + +import pygame + +from .base import Child +from .button import Button + + +class TabBar(Child): + def __init__(self, root, rect, labels, groups, active): + super().__init__(root) + self.labels = labels + self.groups = groups + num_names = len(groups) + self.buttons = [ + Button( + root, + pygame.Rect( + (rect.left + rect.width * i // num_names, rect.top), + (rect.width // num_names, rect.height), + ), + labels[i], + partial(self.update_children, i), + i == active, + ) + for i in range(len(groups)) + ] + root.children.extend(self.buttons) + root.children.extend(self.groups[active]) + self.active = active + + def update_children(self, name): + if self.active == name: + return + self.active = name + for i, (button, group) in enumerate(zip(self.buttons, self.groups)): + is_group_active = i == self.active + if button.is_active != is_group_active: + button.is_active = is_group_active + self.dirty = True + for item in group: + is_child_active = item in self.root.children + if is_group_active == is_child_active: + continue + if is_group_active: + self.root.children.append(item) + elif is_child_active: + self.root.children.remove(item) + self.dirty = True diff --git a/ui/text_input.py b/ui/text_input.py new file mode 100644 index 0000000..4e3a08a --- /dev/null +++ b/ui/text_input.py @@ -0,0 +1,291 @@ +from contextlib import contextmanager +from functools import partial +from math import floor +from time import time + +import pygame + +from .base import Child + + +class Cursor(Child): + DELAY_MS = 500 + REPEAT_MS = 100 + + def __init__(self, text_input, x_offset): + super().__init__(text_input.root) + self.text_input = text_input + self.old_value = text_input.value + self.key_callback = None + self.key = None + self.repeat_ts = None + self.pos = self.pos_from_offset(x_offset) + self.root.children.append(self) + + def remove(self): + self.root.children.remove(self) + self.text_input.cursor = None + self.dirty = True + return self.old_value + + def pos_from_offset(self, x_offset): + value = self.text_input.value + a, a_x = 0, 0 + b, b_x = len(value), self.font.size(value)[0] + if x_offset <= a_x: + return a + elif x_offset >= b_x: + return b + while b - a > 1: + c = a + (b - a) // 2 + c_x = self.font.size(value[:c])[0] + if c_x < x_offset: + a, a_x = c, c_x + else: + b, b_x = c, c_x + if abs(a_x - x_offset) < abs(b_x - x_offset): + return a + return b + + @contextmanager + def check_dirty(self): + old = self.pos, self.value + yield + if (self.pos, self.value) != old: + self.text_input.dirty = True + + def handle_keydown(self, ev): + if (key_method := self.get_key_method(ev.key, ev.mod)) is not None: + self.key_callback = key_method + elif ev.unicode and ev.unicode.isprintable(): + self.key_callback = partial(self.key_printable_unicode, ev.unicode) + else: + return + with self.check_dirty(): + self.key_callback() + self.key = ev.key + self.repeat_ts = time() + self.DELAY_MS / 1000 + + def handle_keyup(self, ev): + if ev.key == self.key: + self.key_release() + + @property + def value(self): + return self.text_input.value + + @value.setter + def value(self, value): + self.text_input.value = value + + @staticmethod + def maybe_scroll_font_surface(font, value_to_cursor, fs, width, height): + x = font.size(value_to_cursor)[0] + fs_size = fs.get_size() + if fs_size[0] < width: + return fs, 0, x + offset = 0 + offset_centered = max(x - width // 2, 0) + offset_right = max(fs_size[0] - width, 0) + if offset_centered < offset_right: + offset = offset_centered + width = min(width, fs_size[0] - offset) + elif offset_right > 0: + offset = offset_right + if offset > 0: + fs = fs.subsurface( + pygame.Rect((offset, 0), (width, min(height, fs_size[1]))) + ) + return fs, offset, x - offset + + def get_font_surface(self): + if self.pos > len(self.value): + self.pos = len(self.value) + fs = self.font.render(self.value, True, "gray") + rect = self.text_input.rect + if self.value: + fs, self.text_input.offset, x = self.maybe_scroll_font_surface( + self.font, + self.value[:self.pos], + fs, + rect.width - 24, + rect.height, + ) + if x == fs.get_width(): + x -= 1 + else: + x = 0 + fs = pygame.Surface((1, self.font.size("W")[1]), pygame.SRCALPHA, 32) + pygame.draw.line(fs, "orange", (x, 0), (x, fs.get_height())) + return fs + + def update(self): + if self.key_callback is None: + return + repeat_offset = floor((time() - self.repeat_ts) * 1000 / self.REPEAT_MS) + if repeat_offset < 0: + return + repeat_offset += 1 + with self.check_dirty(): + for _ in range(repeat_offset): + self.key_callback() + self.repeat_ts += self.REPEAT_MS * repeat_offset / 1000 + + def key_backspace(self): + if self.pos > 0: + self.pos -= 1 + self.key_delete() + + def key_delete(self): + value = self.value + if self.pos < len(value): + self.value = "".join((value[:self.pos], value[self.pos + 1:])) + + def key_prev_word(self): + value = self.value + pos = self.pos + for _ in range(2): + if pos == 0: + continue + n = 1 + isspace = value[pos - n].isspace() + while n < pos: + n += 1 + if value[pos - n].isspace() != isspace: + n -= 1 + break + pos -= n + if pos != self.pos: + self.pos = pos + + def key_next_word(self): + value = self.value + value_len = len(value) + pos = self.pos + for _ in range(2): + if pos == value_len: + continue + n = 0 + isspace = value[pos].isspace() + while pos + n < value_len and value[pos + n].isspace() == isspace: + n += 1 + pos += n + if pos != self.pos: + self.pos = pos + + def key_left(self): + self.pos = max(self.pos - 1, 0) + + def key_right(self): + self.pos = min(self.pos + 1, len(self.value)) + + def key_home(self): + self.pos = 0 + + def key_end(self): + self.pos = len(self.value) + + def key_printable_unicode(self, unicode): + value = self.value + len_old_value = len(value) + value = "".join((value[:self.pos], unicode, value[self.pos:])) + if self.text_input.value_filter: + try: + result = self.text_input.value_filter(value) + except ValueError: + result = None + if isinstance(result, str): + value = result + elif not result: + return + self.value = value + if len(value) > len_old_value: + self.pos += len(unicode) + + def key_release(self): + self.key_callback = None + self.key = None + self.repeat_ts = None + + def key_blur(self, restore=False): + self.text_input.blur(restore) + + KEY_METHODS = { + frozenset(set()): { + pygame.K_LEFT: key_left, + pygame.K_RIGHT: key_right, + pygame.K_HOME: key_home, + pygame.K_END: key_end, + pygame.K_BACKSPACE: key_backspace, + pygame.K_DELETE: key_delete, + pygame.K_KP_ENTER: key_blur, + pygame.K_RETURN: key_blur, + pygame.K_ESCAPE: partial(key_blur, restore=True), + }, + frozenset({pygame.KMOD_CTRL}): { + pygame.K_LEFT: key_prev_word, + pygame.K_RIGHT: key_next_word, + }, + } + + +class TextInput(Child): + def __init__(self, root, rect, callback, value="", value_filter=None): + super().__init__(root) + self.rect = rect + self.callback = callback + self.value = value + self.value_filter = value_filter + self.offset = 0 + + @property + def cursor(self): + cursor = self.root.cursor + if cursor is not None and cursor.text_input is self: + return cursor + return None + + @cursor.setter + def cursor(self, value): + self.root.cursor = value + + def draw(self): + pygame.draw.rect(self.surf, "black", self.rect) + if self.cursor is not None: + fs = self.cursor.get_font_surface() + else: + fs = self.font.render(self.value, True, "gray") + self.surf.subsurface(self.rect).blit( + fs, (16, (self.rect.height - fs.get_height()) // 2) + ) + pygame.draw.rect(self.surf, "gray", self.rect, 1) + + def focus(self, x_offset): + cursor = self.cursor + x_offset = x_offset - self.rect.left - 16 + self.offset + if cursor is not None: + if cursor.text_input is self: + new_pos = cursor.pos_from_offset(x_offset) + if new_pos != cursor.pos: + cursor.pos = new_pos + self.dirty = True + return + cursor.text_input.blur(True) + self.dirty = True + self.cursor = Cursor(self, x_offset) + + def blur(self, restore=False): + if self.cursor is not None: + old_value = self.cursor.remove() + if restore: + self.value = old_value + elif self.value != old_value: + self.callback(self.value) + self.offset = 0 + + def handle_mousebuttondown(self, ev): + if ev.button == 1: + if self.rect.collidepoint(ev.pos): + self.focus(ev.pos[0]) + elif self.cursor is not None: + self.blur(True) diff --git a/ui/ui.py b/ui/ui.py deleted file mode 100644 index 14b3948..0000000 --- a/ui/ui.py +++ /dev/null @@ -1,929 +0,0 @@ -import re -from colorsys import hsv_to_rgb -from contextlib import contextmanager -from functools import partial -from math import asin, floor, nan, sin, sqrt, tau -from time import time - -import pygame - - -class EventMethodDispatcher: - MODS = (pygame.KMOD_CTRL, pygame.KMOD_ALT, pygame.KMOD_META, pygame.KMOD_SHIFT) - KEY_METHODS = {} - - def get_key_method(self, key, mod): - mods = set() - for mask in self.MODS: - if mod & mask: - mods.add(mask) - method = self.KEY_METHODS.get(frozenset(mods), {}).get(key) - if method is not None: - return partial(method, self) - return None - - def handle_keydown(self, ev): - if not self.KEY_METHODS: - return - key_method = self.get_key_method(ev.key, ev.mod) - if key_method is not None: - key_method() - - def handle_event(self, ev): - method_name = f"handle_{pygame.event.event_name(ev.type).lower()}" - if hasattr(self, method_name): - getattr(self, method_name)(ev) - - -class Parent(EventMethodDispatcher): - def __init__(self): - self.children = [] - - def handle_event(self, ev): - for child in (super(), *self.children): - child.handle_event(ev) - - def update(self): - for child in self.children: - child.update() - - def draw(self): - for child in self.children: - child.draw() - - -class Child(EventMethodDispatcher): - root: "Root" - - def __init__(self, root): - self.root = root - - @property - def dirty(self): - return self.root.dirty - - @dirty.setter - def dirty(self, value): - self.root.dirty = value - - @property - def font(self): - return self.root.font - - @property - def surf(self): - return self.root.surf - - def draw(self): - pass - - def update(self): - pass - - -class Root(Parent): - BACKGROUND_COLOR: pygame.Color - - def __init__(self, surf, font=None): - super().__init__() - self.surf = surf - self.font = font - self.running = True - self.dirty = False - self.clock = pygame.time.Clock() - self.cursor: Cursor | None = None - self.stop_event = False - - def handle_quit(self, _): - self.running = False - - def handle_windowexposed(self, _): - self.dirty = True - - handle_activeevent = handle_windowexposed - - def key_escape(self): - if self.cursor is None: - self.running = False - - KEY_METHODS = {frozenset(set()): {pygame.K_ESCAPE: key_escape}} - - def handle_event(self, ev): - for child in (super(Parent, self), *self.children): - child.handle_event(ev) - if not self.running or self.stop_event: - break - - def draw(self): - if hasattr(self, "BACKGROUND_COLOR"): - self.surf.fill(self.BACKGROUND_COLOR) - super().draw() - - def update(self): - super().update() - if self.cursor is not None: - self.cursor.update() - - def run(self): - while True: - for ev in pygame.event.get(): - self.stop_event = False - self.handle_event(ev) - if not self.running: - break - if not self.running: - break - self.update() - if self.dirty: - self.draw() - pygame.display.update() - self.dirty = False - self.clock.tick(60) - - -class Button(Child): - def __init__(self, root, rect, value, callback, is_active=False): - super().__init__(root) - self.rect = rect - self.value = value - self.callback = callback - self.is_active = is_active - self.pushed = False - - def draw(self): - if not self.pushed: - value_color = "lime" if self.is_active else "gray" - colors = ("black", value_color, value_color) - else: - colors = ("darkgray", "lightgray", "black") - pygame.draw.rect(self.surf, colors[0], self.rect) - pygame.draw.rect(self.surf, colors[1], self.rect, 8) - fs = self.font.render(self.value, True, colors[2]) - fs_size = fs.get_size() - center = self.rect.center - self.surf.blit(fs, (center[0] - fs_size[0] // 2, center[1] - fs_size[1] // 2)) - - def handle_mousebuttondown(self, ev): - if ev.button == 1 and self.rect.collidepoint(ev.pos): - self.pushed = True - self.dirty = True - - def handle_mousebuttonup(self, ev): - if ev.button == 1 and self.pushed and self.rect.collidepoint(ev.pos): - self.pushed = False - self.callback() - self.dirty = True - - def handle_mousemotion(self, ev): - if not self.pushed: - return - if ev.buttons[0] and not self.rect.collidepoint(ev.pos): - self.pushed = False - self.dirty = True - - -class Slider(Child): - HORIZONTAL = 0 - VERTICAL = 1 - - def __init__(self, root, rect, direction, value=0, callback=None): - super().__init__(root) - self.rect = rect - self.direction = direction - self.extent = (self.rect.width - 1, self.rect.height - 1)[direction] - self.value = value - self.callback = callback - self.pushed = False - - def update_value(self, value): - if self.direction == self.HORIZONTAL: - value -= self.rect.left - else: # self.direction == self.VERTICAL - value = self.extent - (value - self.rect.top) - self.value = value - if self.callback: - self.callback(value) - self.dirty = True - - def handle_mousebuttondown(self, ev): - if ev.button != 1 or not self.rect.collidepoint(ev.pos): - return - self.update_value(ev.pos[self.direction]) - self.pushed = True - - def handle_mousemotion(self, ev): - if not self.pushed: - return - if not ev.buttons[0]: - self.pushed = False - else: - self.update_value(ev.pos[self.direction]) - - def handle_mousebuttonup(self, ev): - if ev.button == 1 and self.pushed: - self.update_value(ev.pos[self.direction]) - self.pushed = False - - def draw(self): - pygame.draw.rect(self.surf, "gray34", self.rect.inflate((8, 8))) - pygame.draw.rect(self.surf, "black", self.rect) - self.draw_cursor(self.surf.subsurface(self.rect)) - - def draw_cursor(self, subsurf): - value = self.value - color = "gray" - if value < 0: - color = "dimgray" - value = -value - if value > self.extent: - value = int(self.extent * (self.extent / value)) - color = "dimgray" - if self.direction == self.HORIZONTAL: - start_pos, end_pos = (value, 0), (value, self.rect.height) - else: - value = self.extent - value - start_pos, end_pos = (0, value), (self.rect.width, value) - pygame.draw.line(subsurf, color, start_pos, end_pos, 8) - - -class Label(Child): - def __init__(self, root, rect, value): - super().__init__(root) - self.rect = rect - self.value = value - - def draw(self): - fs = self.font.render(self.value, True, "gray") - self.surf.blit( - fs, - (self.rect.left + 16, self.rect.centery - fs.get_height() // 2) - ) - - -class EaseInOutElastic: - def __init__(self, magnitude): - self.p = 1 - magnitude - self.s = self.p / tau * asin(1) - - def __call__(self, x): - if x == 0: - return 0 - elif x == 1: - return 1 - elif x < 0 or x > 1: - raise ValueError(f"x must be between 0 and 1: got {x}") - st = x * 2 - st1 = st - 1 - sgn = (st >= 1) * 2 - 1 - return ( - 2 ** (-sgn * 10 * st1 - 1) - * sin((st1 - self.s) * tau / self.p) - * sgn - + (sgn > 0) - ) - - -class Switch(Child): - MOVE_FOR_SEC = 1 - EASE = staticmethod(EaseInOutElastic((sqrt(5) - 1) / 2)) - - def __init__(self, root, rect, callback, value=False): - super().__init__(root) - self.rect = rect - self.callback = callback - if value is not None and not isinstance(value, bool): - value = bool(value) - self.value = value - self.moving_since = nan - self.flip_again = False - - def draw(self): - pygame.draw.rect(self.surf, "gray", self.rect, 8) - t = time() - if t > self.moving_since + self.MOVE_FOR_SEC: - self.callback(self.value) - if self.flip_again: - self.value = bool(self.value) ^ True - self.moving_since = t - self.flip_again = False - else: - self.moving_since = nan - if self.moving_since is nan: - if self.value is None: - current = 0.5 - else: - current = min(max(int(self.value), 0), 1) - else: - current = (t - self.moving_since) / self.MOVE_FOR_SEC - if not self.value: - current = 1 - current - eased_current = self.EASE(current) - base_radius = min(self.rect.height, self.rect.width / 4) - base_left = self.rect.left + base_radius - movement_width = self.rect.width - 2 * base_radius - normalized_current = min(max(current, 0), 1) - if current < 0.5: - args = (0, 1 - 2 * normalized_current, 1 - normalized_current) - else: - normalized_current -= 0.5 - args = (1 / 3, 2 * normalized_current, 0.5 + normalized_current * 0.5) - rgb = hsv_to_rgb(*args) - pygame.draw.circle( - self.surf, - pygame.Color(*(int(x * 255) for x in rgb)), - (base_left + eased_current * movement_width, self.rect.top + base_radius), - base_radius - ) - - def update(self): - if self.moving_since is not nan: - self.dirty = True - - def set_value(self, value): - if value == self.value: - return - if None in (value, self.value): - self.moving_since = time() - self.MOVE_FOR_SEC / 2 - else: - self.moving_since = time() - self.value = value - - def handle_mousebuttondown(self, ev): - if ev.button == 1 and self.rect.collidepoint(ev.pos): - if self.moving_since is not nan: - self.flip_again = True - return - self.set_value(bool(self.value) ^ True) - - -class Cursor(Child): - DELAY_MS = 500 - REPEAT_MS = 100 - - def __init__(self, text_input, x_offset): - super().__init__(text_input.root) - self.text_input = text_input - self.old_value = text_input.value - self.key_callback = None - self.key = None - self.repeat_ts = None - self.pos = self.pos_from_offset(x_offset) - self.root.children.append(self) - - def remove(self): - self.root.children.remove(self) - self.text_input.cursor = None - self.dirty = True - return self.old_value - - def pos_from_offset(self, x_offset): - value = self.text_input.value - a, a_x = 0, 0 - b, b_x = len(value), self.font.size(value)[0] - if x_offset <= a_x: - return a - elif x_offset >= b_x: - return b - while b - a > 1: - c = a + (b - a) // 2 - c_x = self.font.size(value[:c])[0] - if c_x < x_offset: - a, a_x = c, c_x - else: - b, b_x = c, c_x - if abs(a_x - x_offset) < abs(b_x - x_offset): - return a - return b - - @contextmanager - def check_dirty(self): - old = self.pos, self.value - yield - if (self.pos, self.value) != old: - self.text_input.dirty = True - - def handle_keydown(self, ev): - if (key_method := self.get_key_method(ev.key, ev.mod)) is not None: - self.key_callback = key_method - elif ev.unicode and ev.unicode.isprintable(): - self.key_callback = partial(self.key_printable_unicode, ev.unicode) - else: - return - with self.check_dirty(): - self.key_callback() - self.key = ev.key - self.repeat_ts = time() + self.DELAY_MS / 1000 - - def handle_keyup(self, ev): - if ev.key == self.key: - self.key_release() - - @property - def value(self): - return self.text_input.value - - @value.setter - def value(self, value): - self.text_input.value = value - - @staticmethod - def maybe_scroll_font_surface(font, value_to_cursor, fs, width, height): - x = font.size(value_to_cursor)[0] - fs_size = fs.get_size() - if fs_size[0] < width: - return fs, 0, x - offset = 0 - offset_centered = max(x - width // 2, 0) - offset_right = max(fs_size[0] - width, 0) - if offset_centered < offset_right: - offset = offset_centered - width = min(width, fs_size[0] - offset) - elif offset_right > 0: - offset = offset_right - if offset > 0: - fs = fs.subsurface( - pygame.Rect((offset, 0), (width, min(height, fs_size[1]))) - ) - return fs, offset, x - offset - - def get_font_surface(self): - if self.pos > len(self.value): - self.pos = len(self.value) - fs = self.font.render(self.value, True, "gray") - rect = self.text_input.rect - if self.value: - fs, self.text_input.offset, x = self.maybe_scroll_font_surface( - self.font, - self.value[:self.pos], - fs, - rect.width - 24, - rect.height, - ) - if x == fs.get_width(): - x -= 1 - else: - x = 0 - fs = pygame.Surface((1, self.font.size("W")[1]), pygame.SRCALPHA, 32) - pygame.draw.line(fs, "orange", (x, 0), (x, fs.get_height())) - return fs - - def update(self): - if self.key_callback is None: - return - repeat_offset = floor((time() - self.repeat_ts) * 1000 / self.REPEAT_MS) - if repeat_offset < 0: - return - repeat_offset += 1 - with self.check_dirty(): - for _ in range(repeat_offset): - self.key_callback() - self.repeat_ts += self.REPEAT_MS * repeat_offset / 1000 - - def key_backspace(self): - if self.pos > 0: - self.pos -= 1 - self.key_delete() - - def key_delete(self): - value = self.value - if self.pos < len(value): - self.value = "".join((value[:self.pos], value[self.pos + 1:])) - - def key_prev_word(self): - value = self.value - pos = self.pos - for _ in range(2): - if pos == 0: - continue - n = 1 - isspace = value[pos - n].isspace() - while n < pos: - n += 1 - if value[pos - n].isspace() != isspace: - n -= 1 - break - pos -= n - if pos != self.pos: - self.pos = pos - - def key_next_word(self): - value = self.value - value_len = len(value) - pos = self.pos - for _ in range(2): - if pos == value_len: - continue - n = 0 - isspace = value[pos].isspace() - while pos + n < value_len and value[pos + n].isspace() == isspace: - n += 1 - pos += n - if pos != self.pos: - self.pos = pos - - def key_left(self): - self.pos = max(self.pos - 1, 0) - - def key_right(self): - self.pos = min(self.pos + 1, len(self.value)) - - def key_home(self): - self.pos = 0 - - def key_end(self): - self.pos = len(self.value) - - def key_printable_unicode(self, unicode): - value = self.value - len_old_value = len(value) - value = "".join((value[:self.pos], unicode, value[self.pos:])) - if self.text_input.value_filter: - try: - result = self.text_input.value_filter(value) - except ValueError: - result = None - if isinstance(result, str): - value = result - elif not result: - return - self.value = value - if len(value) > len_old_value: - self.pos += len(unicode) - - def key_release(self): - self.key_callback = None - self.key = None - self.repeat_ts = None - - def key_blur(self, restore=False): - self.text_input.blur(restore) - - KEY_METHODS = { - frozenset(set()): { - pygame.K_LEFT: key_left, - pygame.K_RIGHT: key_right, - pygame.K_HOME: key_home, - pygame.K_END: key_end, - pygame.K_BACKSPACE: key_backspace, - pygame.K_DELETE: key_delete, - pygame.K_KP_ENTER: key_blur, - pygame.K_RETURN: key_blur, - pygame.K_ESCAPE: partial(key_blur, restore=True), - }, - frozenset({pygame.KMOD_CTRL}): { - pygame.K_LEFT: key_prev_word, - pygame.K_RIGHT: key_next_word, - }, - } - - -class TextInput(Child): - def __init__(self, root, rect, callback, value="", value_filter=None): - super().__init__(root) - self.rect = rect - self.callback = callback - self.value = value - self.value_filter = value_filter - self.offset = 0 - - @property - def cursor(self): - cursor = self.root.cursor - if cursor is not None and cursor.text_input is self: - return cursor - return None - - @cursor.setter - def cursor(self, value): - self.root.cursor = value - - def draw(self): - pygame.draw.rect(self.surf, "black", self.rect) - if self.cursor is not None: - fs = self.cursor.get_font_surface() - else: - fs = self.font.render(self.value, True, "gray") - self.surf.subsurface(self.rect).blit( - fs, (16, (self.rect.height - fs.get_height()) // 2) - ) - pygame.draw.rect(self.surf, "gray", self.rect, 1) - - def focus(self, x_offset): - cursor = self.cursor - x_offset = x_offset - self.rect.left - 16 + self.offset - if cursor is not None: - if cursor.text_input is self: - new_pos = cursor.pos_from_offset(x_offset) - if new_pos != cursor.pos: - cursor.pos = new_pos - self.dirty = True - return - cursor.text_input.blur(True) - self.dirty = True - self.cursor = Cursor(self, x_offset) - - def blur(self, restore=False): - if self.cursor is not None: - old_value = self.cursor.remove() - if restore: - self.value = old_value - elif self.value != old_value: - self.callback(self.value) - self.offset = 0 - - def handle_mousebuttondown(self, ev): - if ev.button == 1: - if self.rect.collidepoint(ev.pos): - self.focus(ev.pos[0]) - elif self.cursor is not None: - self.blur(True) - - -class FPSWidget(Child): - FPS_COLOR = "yellow" - - def __init__(self, root): - super().__init__(root) - self.clock = self.root.clock - self.current_fps = None - - def update(self): - new_fps = int(self.clock.get_fps()) - if self.current_fps != new_fps: - self.current_fps = new_fps - self.dirty = True - - def draw(self): - surf_size = self.surf.get_size() - fs = self.font.render( - f"{int(self.current_fps)} FPS", True, self.FPS_COLOR - ) - fs_size = fs.get_size() - self.surf.blit( - fs, (surf_size[0] - fs_size[0] - 7, surf_size[1] - fs_size[1] - 7) - ) - - -class Icon(Child): - def __init__(self, root, shape): - super().__init__(root) - self.shape = shape - - def draw(self): - self.shape.draw(self.surf, "gray") - - -class IconButton(Button): - def __init__(self, root, shape, *args, **kwargs): - super().__init__(root, *args, **kwargs) - self.shape = shape - - def draw(self): - if self.pushed: - pygame.draw.rect(self.surf, "honeydew4", self.rect) - self.shape.draw(self.surf, "black" if self.pushed else "white") - if self.is_active: - color = "lime" - elif self.pushed: - color = "red" - else: - color = "gray" - pygame.draw.rect(self.surf, color, self.rect, 8) - - -class TabBar(Child): - def __init__(self, root, rect, labels, groups, active): - super().__init__(root) - self.labels = labels - self.groups = groups - num_names = len(groups) - self.buttons = [ - Button( - root, - pygame.Rect( - (rect.left + rect.width * i // num_names, rect.top), - (rect.width // num_names, rect.height), - ), - labels[i], - partial(self.update_children, i), - i == active, - ) - for i in range(len(groups)) - ] - root.children.extend(self.buttons) - root.children.extend(self.groups[active]) - self.active = active - - def update_children(self, name): - if self.active == name: - return - self.active = name - for i, (button, group) in enumerate(zip(self.buttons, self.groups)): - is_group_active = i == self.active - if button.is_active != is_group_active: - button.is_active = is_group_active - self.dirty = True - for item in group: - is_child_active = item in self.root.children - if is_group_active == is_child_active: - continue - if is_group_active: - self.root.children.append(item) - elif is_child_active: - self.root.children.remove(item) - self.dirty = True - - -class Modal(Child): - def __init__(self, root): - super().__init__(root) - self.backsurf = root.surf.copy() - self.tintsurf = pygame.Surface(self.backsurf.get_size(), pygame.SRCALPHA, 32) - self.tintsurf.fill(pygame.Color(0x00000080)) - self.children = root.children.copy() - root.children.clear() - root.children.append(self) - root.stop_event = True - self.dirty = True - - def draw(self): - self.surf.blit(self.backsurf, (0, 0)) - if self.tintsurf: - self.surf.blit(self.tintsurf, (0, 0)) - - def deactivate(self): - self.root.children.clear() - self.root.children.extend(self.children) - self.dirty = True - - -class DropDown(Button): - def __init__(self, root, rect, value, entries, callback): - super().__init__(root, rect, value, partial(self.DropDownMenu, self), False) - self.entries = entries - self.dropdown_callback = callback - - class DropDownMenu(Modal): - def __init__(self, drop_down): - root = drop_down.root - super().__init__(root) - self.callback = drop_down.dropdown_callback - rect = drop_down.rect - self.buttons = [ - Button( - root, - pygame.Rect( - (rect.left, rect.bottom + i * rect.height), rect.size, - ), - entry, - partial(self.choose, i), - ) - for i, entry in enumerate(drop_down.entries) - ] - root.children.extend(self.buttons) - - def choose(self, i=None): - self.deactivate() - self.callback(i) - - def handle_mousebuttondown(self, ev): - if ev.button != 1: - return - elif not any(b.rect.collidepoint(ev.pos) for b in self.buttons): - self.deactivate() - - -class MessageBox(Modal): - def __init__(self, root, rect, message): - super().__init__(root) - self.rect = rect - fs_size = self.font.size(message) - label_rect = pygame.Rect( - rect.topleft, - (rect.width, rect.height * 3 // 4), - ) - root.children.extend( - ( - Label( - root, - pygame.Rect( - (label_rect.centerx - fs_size[0] // 2, label_rect.centery - fs_size[1] // 2), - fs_size, - ), - message, - ), - Button( - root, - pygame.Rect( - (rect.left + rect.width // 3, rect.top + rect.height * 6 // 8), - (rect.width // 3, rect.height // 8), - ), - "OK", - self.deactivate, - ), - ), - ) - - def draw(self): - super().draw() - pygame.draw.rect(self.surf, "black", self.rect) - - -class RepeatButton(Button): - DELAY_MS = 500 - REPEAT_MS = 100 - - def __init__(self, root, rect, value, callback, is_active=False): - self._pushed = False - super().__init__(root, rect, value, callback, is_active) - self.repeat_ts = None - - @property - def pushed(self): - return self._pushed - - @pushed.setter - def pushed(self, value): - self._pushed = value - if value: - self.repeat_ts = time() + self.DELAY_MS / 1000 - self.callback() - else: - self.repeat_ts = None - - def update(self): - if self.callback is None or not self.pushed: - return - repeat_offset = floor((time() - self.repeat_ts) * 1000 / self.REPEAT_MS) - if repeat_offset < 0: - return - repeat_offset += 1 - for _ in range(repeat_offset): - self.callback() - self.repeat_ts += self.REPEAT_MS * repeat_offset / 1000 - - def handle_mousebuttonup(self, ev): - if ev.button == 1 and self.pushed and self.rect.collidepoint(ev.pos): - self.pushed = False - self.dirty = True - - -class Spinner(Parent, Child): - def __init__(self, root, rect, callback, value=0): - Parent.__init__(self) - Child.__init__(self, root) - self.callback = callback - self.value = value - button_size = (rect.height // 2, rect.height // 2) - self.children.extend( - ( - TextInput( - root, - pygame.Rect( - rect.topleft, (rect.width - button_size[0], rect.height) - ), - self.call_callback, - str(value), - re.compile(r"[-+]?\d*").fullmatch, - ), - RepeatButton( - root, - pygame.Rect( - (rect.right - button_size[0], rect.top), - button_size, - ), - "^", - partial(self.spin_callback, 1), - ), - RepeatButton( - root, - pygame.Rect( - (rect.right - button_size[0], rect.top + button_size[1]), - button_size, - ), - "v", - partial(self.spin_callback, -1), - ), - ) - ) - - def call_callback(self, value): - try: - int_value = int(value) - except ValueError: - pass - else: - if int_value != self.value: - self.value = int_value - self.callback(int_value) - text_input = self.children[0] - str_value = str(self.value) - if str_value != text_input.value: - text_input.value = str(self.value) - self.root.dirty = True - - def spin_callback(self, value): - self.value += value - self.children[0].value = str(self.value) - self.callback(self.value) - self.root.dirty = True