From 2a320bdb9605a86db4e3d52fba398655df7e3b61 Mon Sep 17 00:00:00 2001 From: mar77i Date: Tue, 28 Jan 2025 14:17:00 +0100 Subject: [PATCH] add spinner. clean up class hierarchy, move cursor rendering to Cursor. --- ui/ui.py | 456 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 285 insertions(+), 171 deletions(-) diff --git a/ui/ui.py b/ui/ui.py index 1f76a46..10ed74e 100644 --- a/ui/ui.py +++ b/ui/ui.py @@ -1,3 +1,4 @@ +import re from colorsys import hsv_to_rgb from contextlib import contextmanager from functools import partial @@ -7,10 +8,6 @@ from time import time import pygame -# todo: -# - [ ] spinner - - class EventMethodDispatcher: MODS = (pygame.KMOD_CTRL, pygame.KMOD_ALT, pygame.KMOD_META, pygame.KMOD_SHIFT) KEY_METHODS = {} @@ -38,7 +35,83 @@ class EventMethodDispatcher: getattr(self, method_name)(ev) -class UIParent(EventMethodDispatcher): +class Parent(EventMethodDispatcher): + def __init__(self): + self.children = [] + + def handle_event(self, ev): + super().handle_event(ev) + for child in 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 CursorParent(Parent): + def __init__(self): + super().__init__() + self.cursor: Cursor | None = None + + def handle_event(self, ev): + super().handle_event(ev) + if self.cursor is not None: + self.cursor.handle_event(ev) + + def update(self): + super().update() + if self.cursor is not None: + self.cursor.update() + + +class CursorChild(Child): + @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 + + +class Root(CursorParent): BACKGROUND_COLOR: pygame.Color def __init__(self, surf, font=None): @@ -48,8 +121,6 @@ class UIParent(EventMethodDispatcher): self.running = True self.dirty = False self.clock = pygame.time.Clock() - self.children = [] - self.cursor: Cursor | None = None def handle_quit(self, _): self.running = False @@ -65,28 +136,10 @@ class UIParent(EventMethodDispatcher): KEY_METHODS = {frozenset(set()): {pygame.K_ESCAPE: key_escape}} - def handle_event(self, ev): - children = ( - super(), - *((self.cursor,) if self.cursor is not None else ()), - *self.children, - ) - for child in children: - child.handle_event(ev) - if not self.running: - return - - def update(self): - if self.cursor is not None: - self.cursor.update() - for child in self.children: - child.update() - def draw(self): if hasattr(self, "BACKGROUND_COLOR"): self.surf.fill(self.BACKGROUND_COLOR) - for child in self.children: - child.draw() + super().draw() def run(self): while True: @@ -102,49 +155,9 @@ class UIParent(EventMethodDispatcher): self.clock.tick(60) -class UIChild(EventMethodDispatcher): - parent: "UIParent" - - def __init__(self, parent): - self.parent = parent - - @property - def dirty(self): - return self.parent.dirty - - @dirty.setter - def dirty(self, value): - self.parent.dirty = value - - @property - def font(self): - return self.parent.font - - @property - def surf(self): - return self.parent.surf - - @property - def cursor(self): - cursor = self.parent.cursor - if cursor is not None and cursor.text_input is self: - return cursor - return None - - @cursor.setter - def cursor(self, value): - self.parent.cursor = value - - def draw(self): - pass - - def update(self): - pass - - -class Button(UIChild): - def __init__(self, parent, rect, value, callback, is_active=False): - super().__init__(parent) +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 @@ -186,12 +199,12 @@ class Button(UIChild): self.dirty = True -class Slider(UIChild): +class Slider(Child): HORIZONTAL = 0 VERTICAL = 1 - def __init__(self, parent, rect, direction, value=0, callback=None): - super().__init__(parent) + 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] @@ -250,9 +263,9 @@ class Slider(UIChild): pygame.draw.line(subsurf, color, start_pos, end_pos, 8) -class Label(UIChild): - def __init__(self, parent, rect, value): - super().__init__(parent) +class Label(Child): + def __init__(self, root, rect, value): + super().__init__(root) self.rect = rect self.value = value @@ -287,12 +300,12 @@ class EaseInOutElastic: ) -class Switch(UIChild): +class Switch(Child): MOVE_FOR_SEC = 1 EASE = staticmethod(EaseInOutElastic((sqrt(5) - 1) / 2)) - def __init__(self, parent, rect, callback, value=False): - super().__init__(parent) + 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): @@ -360,12 +373,12 @@ class Switch(UIChild): self.set_value(bool(self.value) ^ True) -class Cursor(UIChild, EventMethodDispatcher): +class Cursor(Child): DELAY_MS = 500 REPEAT_MS = 100 def __init__(self, text_input, x_offset): - super().__init__(text_input.parent) + super().__init__(text_input.root) self.text_input = text_input self.old_value = text_input.value self.key_callback = None @@ -423,6 +436,47 @@ class Cursor(UIChild, EventMethodDispatcher): 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 @@ -496,7 +550,7 @@ class Cursor(UIChild, EventMethodDispatcher): if self.text_input.value_filter: try: result = self.text_input.value_filter(value) - except Exception: + except ValueError: result = None if isinstance(result, str): value = result @@ -533,62 +587,21 @@ class Cursor(UIChild, EventMethodDispatcher): } -class TextInput(UIChild): - def __init__(self, parent, rect, callback, value="", value_filter=None): - super().__init__(parent) +class TextInput(CursorChild): + 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 - @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: - return fs, 0, x - fs = fs.subsurface(pygame.Rect((offset, 0), (width, min(height, fs_size[1])))) - return fs, offset, x - offset - - def get_font_surface(self): - fs = self.font.render(self.value, True, "gray") - cursor = self.cursor - if cursor is None: - return fs - if cursor.pos > len(self.value): - cursor.pos = len(self.value) - if self.value: - fs, self.offset, x = self.maybe_scroll_font_surface( - self.font, - self.value[:cursor.pos], - fs, - self.rect.width - 24, - self.rect.height, - ) - fs_size = fs.get_size() - else: - x = 1 - fs_size = (x, self.font.size("W")[1]) - fs = pygame.Surface(fs_size, pygame.SRCALPHA, 32) - if x == fs_size[0]: - x -= 1 - pygame.draw.line(fs, "orange", (x, 0), (x, fs_size[1])) - return fs - def draw(self): pygame.draw.rect(self.surf, "black", self.rect) - fs = self.get_font_surface() + 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) ) @@ -615,8 +628,9 @@ class TextInput(UIChild): self.dirty = True if restore: self.value = old_value - else: + elif self.value != old_value: self.callback(self.value) + self.offset = 0 def handle_mousebuttondown(self, ev): if ev.button == 1: @@ -626,12 +640,12 @@ class TextInput(UIChild): self.blur(True) -class FPSWidget(UIChild): +class FPSWidget(Child): FPS_COLOR = "yellow" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.clock = self.parent.clock + def __init__(self, root): + super().__init__(root) + self.clock = self.root.clock self.current_fps = None def update(self): @@ -651,9 +665,9 @@ class FPSWidget(UIChild): ) -class Icon(UIChild): - def __init__(self, parent, shape): - super().__init__(parent) +class Icon(Child): + def __init__(self, root, shape): + super().__init__(root) self.shape = shape def draw(self): @@ -661,32 +675,32 @@ class Icon(UIChild): class IconButton(Button): - def __init__(self, parent, shape, *args, **kwargs): - super().__init__(parent, *args, **kwargs) + 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.parent.surf, "honeydew4", self.rect) - self.shape.draw(self.parent.surf, "black" if self.pushed else "white") + 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.parent.surf, color, self.rect, 8) + pygame.draw.rect(self.surf, color, self.rect, 8) -class TabBar(UIChild): - def __init__(self, parent, rect, labels, groups, active): - super().__init__(parent) +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( - parent, + root, pygame.Rect( (rect.left + rect.width * i // num_names, rect.top), (rect.width // num_names, rect.height), @@ -697,8 +711,8 @@ class TabBar(UIChild): ) for i in range(len(groups)) ] - parent.children.extend(self.buttons) - parent.children.extend(self.groups[active]) + root.children.extend(self.buttons) + root.children.extend(self.groups[active]) self.active = active def update_children(self, name): @@ -711,25 +725,25 @@ class TabBar(UIChild): button.is_active = is_group_active self.dirty = True for item in group: - is_child_active = item in self.parent.children + is_child_active = item in self.root.children if is_group_active == is_child_active: continue if is_group_active: - self.parent.children.append(item) + self.root.children.append(item) elif is_child_active: - self.parent.children.remove(item) + self.root.children.remove(item) self.dirty = True -class Modal(UIChild): - def __init__(self, parent): - super().__init__(parent) - self.backsurf = parent.surf.copy() +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 = parent.children.copy() - parent.children.clear() - parent.children.append(self) + self.children = root.children.copy() + root.children.clear() + root.children.append(self) self.dirty = True def draw(self): @@ -737,26 +751,26 @@ class Modal(UIChild): self.surf.blit(self.tintsurf, (0, 0)) def deactivate(self): - self.parent.children.clear() - self.parent.children.extend(self.children) + self.root.children.clear() + self.root.children.extend(self.children) self.dirty = True class DropDown(Button): - def __init__(self, parent, rect, value, entries, callback): - super().__init__(parent, rect, value, partial(self.DropDownMenu, self), False) + 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): - parent = drop_down.parent - super().__init__(parent) + root = drop_down.root + super().__init__(root) self.callback = drop_down.dropdown_callback rect = drop_down.rect self.buttons = [ Button( - parent, + root, pygame.Rect( (rect.left, rect.bottom + i * rect.height), rect.size, ), @@ -765,7 +779,7 @@ class DropDown(Button): ) for i, entry in enumerate(drop_down.entries) ] - parent.children.extend(self.buttons) + root.children.extend(self.buttons) def choose(self, i=None): self.deactivate() @@ -779,18 +793,18 @@ class DropDown(Button): class MessageBox(Modal): - def __init__(self, parent, rect, message): - super().__init__(parent) + 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), ) - parent.children.extend( + root.children.extend( ( Label( - parent, + root, pygame.Rect( (label_rect.centerx - fs_size[0] // 2, label_rect.centery - fs_size[1] // 2), fs_size, @@ -798,7 +812,7 @@ class MessageBox(Modal): message, ), Button( - parent, + root, pygame.Rect( (rect.left + rect.width // 3, rect.top + rect.height * 6 // 8), (rect.width // 3, rect.height // 8), @@ -812,3 +826,103 @@ class MessageBox(Modal): 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 -- 2.51.0