]> git.mar77i.info Git - zenbook_gui/commitdiff
add spinner. clean up class hierarchy, move cursor rendering to Cursor.
authormar77i <mar77i@protonmail.ch>
Tue, 28 Jan 2025 13:17:00 +0000 (14:17 +0100)
committermar77i <mar77i@protonmail.ch>
Tue, 28 Jan 2025 13:17:00 +0000 (14:17 +0100)
ui/ui.py

index 1f76a4670a1ad124d50fc5b3971bf66ed3ff7eaf..10ed74e0790bf3e2f1ff63a00e3011560c413607 100644 (file)
--- 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