]> git.mar77i.info Git - zenbook_gui/commitdiff
add TextInput and Cursor.
authormar77i <mar77i@protonmail.ch>
Tue, 21 Jan 2025 14:55:56 +0000 (15:55 +0100)
committermar77i <mar77i@protonmail.ch>
Tue, 21 Jan 2025 14:55:56 +0000 (15:55 +0100)
ui/ui.py

index 8f9e6dfe2cd485ce9d8c51d67f50aa66ef54f88e..c7b391fe225a8b70fdbb2a728e1e5a2d89b7da2f 100644 (file)
--- a/ui/ui.py
+++ b/ui/ui.py
@@ -1,11 +1,14 @@
 from colorsys import hsv_to_rgb
-from math import asin, nan, sin, sqrt, tau
+from contextlib import contextmanager
+from functools import partial
+from math import asin, floor, nan, sin, sqrt, tau
 from time import time
 
 import pygame
 
 
 # todo:
+# - [ ] textinput place cursor next to the mouse
 # - [ ] spinner
 # - [ ] modal dialogs
 #      - modal dialog will always replace all children, if you want persistent
@@ -34,6 +37,7 @@ class UIParent(EventMethodDispatcher):
         self.dirty = False
         self.clock = pygame.time.Clock()
         self.children = []
+        self.cursor = None
 
     def handle_quit(self, _):
         self.running = False
@@ -45,7 +49,10 @@ class UIParent(EventMethodDispatcher):
 
     def handle_keydown(self, ev):
         if ev.key == pygame.K_ESCAPE:
-            self.running = False
+            if self.cursor is not None:
+                self.cursor.remove()
+            else:
+                self.running = False
 
     def handle_event(self, ev):
         super().handle_event(ev)
@@ -123,34 +130,34 @@ class Button(UIChild):
             value_color = "lime" if self.is_active else "gray"
             frame_color = value_color
         else:
-            pygame.draw.rect(self.parent.surf, "darkgray", self.rect)
+            pygame.draw.rect(self.surf, "darkgray", self.rect)
             frame_color = "lightgray"
             value_color = "black"
-        fs = self.parent.font.render(self.value, True, value_color)
-        pygame.draw.rect(self.parent.surf, frame_color, self.rect, 8)
+        fs = self.font.render(self.value, True, value_color)
+        pygame.draw.rect(self.surf, frame_color, self.rect, 8)
         fs_size = fs.get_size()
         center = self.rect.center
-        self.parent.surf.blit(
+        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.parent.dirty = 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.parent.dirty = True
+            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.parent.dirty = True
+            self.dirty = True
 
 
 class Slider(UIChild):
@@ -174,7 +181,7 @@ class Slider(UIChild):
         self.value = value
         if self.callback:
             self.callback(value)
-        self.parent.dirty = True
+        self.dirty = True
 
     def handle_mousebuttondown(self, ev):
         if ev.button != 1 or not self.rect.collidepoint(ev.pos):
@@ -196,9 +203,9 @@ class Slider(UIChild):
             self.pushed = False
 
     def draw(self):
-        pygame.draw.rect(self.parent.surf, "gray34", self.rect.inflate((8, 8)))
-        pygame.draw.rect(self.parent.surf, "black", self.rect)
-        self.draw_cursor(self.parent.surf.subsurface(self.rect))
+        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
@@ -224,8 +231,8 @@ class Label(UIChild):
         self.value = value
 
     def draw(self):
-        fs = self.parent.font.render(self.value, True, "gray")
-        self.parent.surf.blit(
+        fs = self.font.render(self.value, True, "gray")
+        self.surf.blit(
             fs,
             (self.rect.left + 16, self.rect.centery - fs.get_height() // 2)
         )
@@ -269,7 +276,7 @@ class Switch(UIChild):
         self.flip_again = False
 
     def draw(self):
-        pygame.draw.rect(self.parent.surf, "gray", self.rect, 8)
+        pygame.draw.rect(self.surf, "gray", self.rect, 8)
         t = time()
         if t > self.moving_since + self.MOVE_FOR_SEC:
             self.callback(self.value)
@@ -300,7 +307,7 @@ class Switch(UIChild):
             args = (1 / 3, 2 * normalized_current, 0.5 + normalized_current * 0.5)
         rgb = hsv_to_rgb(*args)
         pygame.draw.circle(
-            self.parent.surf,
+            self.surf,
             pygame.Color(*(int(x * 255) for x in rgb)),
             (base_left + eased_current * movement_width, self.rect.top + base_radius),
             base_radius
@@ -308,7 +315,7 @@ class Switch(UIChild):
 
     def update(self):
         if self.moving_since is not nan:
-            self.parent.dirty = True
+            self.dirty = True
 
     def handle_mousebuttondown(self, ev):
         if ev.button == 1 and self.rect.collidepoint(ev.pos):
@@ -318,3 +325,248 @@ class Switch(UIChild):
             self.value = bool(self.value) ^ True
             offset = self.MOVE_FOR_SEC / 2 if self.value is None else 0
             self.moving_since = time() - offset
+
+
+class Cursor:
+    DELAY_MS = 500
+    REPEAT_MS = 100
+
+    def __init__(self, child, pos):
+        self.child = child
+        self.key_callback = None
+        self.key = None
+        self.repeat_ts = None
+        self.pos = pos
+
+    MODS = (pygame.KMOD_CTRL, pygame.KMOD_ALT, pygame.KMOD_META, pygame.KMOD_SHIFT)
+
+    @classmethod
+    def get_mods(cls, mod):
+        mods = set()
+        for mask in cls.MODS:
+            if mod & mask:
+                mods.add(mask)
+        return frozenset(mods)
+
+    @contextmanager
+    def check_dirty(self):
+        old = self.pos, self.value
+        yield
+        if (self.pos, self.value) != old:
+            self.child.dirty = True
+
+    def press(self, key, mod, unicode):
+        method = self.METHODS_PER_MODS.get(self.get_mods(mod), {}).get(key)
+        if method is not None:
+            self.key_callback = partial(method, self)
+        elif unicode and unicode.isprintable():
+            self.key_callback = partial(self.key_printable_unicode, unicode)
+        else:
+            return
+        with self.check_dirty():
+            self.key_callback()
+        self.key = key
+        self.repeat_ts = time() + self.DELAY_MS / 1000
+
+    def release(self):
+        self.key_callback = None
+        self.key = None
+        self.repeat_ts = None
+
+    @property
+    def value(self):
+        return self.child.value
+
+    @value.setter
+    def value(self, value):
+        self.child.value = value
+
+    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 = f"{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
+        value = f"{value[:self.pos]}{unicode}{value[self.pos:]}"
+        if self.child.value_filter:
+            try:
+                value = self.child.value_filter(value)
+            except Exception:
+                value = None
+            if not isinstance(value, str):
+                return
+        self.value = value
+        self.pos += len(unicode)
+
+    def remove(self):
+        self.child.remove_cursor()
+
+    METHODS_PER_MODS = {
+        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: remove,
+            pygame.K_RETURN: remove,
+        },
+        frozenset({pygame.KMOD_CTRL}): {
+            pygame.K_LEFT: key_prev_word,
+            pygame.K_RIGHT: key_next_word,
+        },
+    }
+
+
+class TextInput(UIChild):
+    def __init__(self, parent, rect, callback, value="", value_filter=None):
+        super().__init__(parent)
+        self.rect = rect
+        self.callback = callback
+        self.value = value
+        self.value_filter = value_filter
+        self.cursor = None
+
+    @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, fs_size, 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:
+            offset = offset_right
+        if offset == 0:
+            return fs, fs_size, x
+        fs = fs.subsurface(pygame.Rect((offset, 0), (width, min(height, fs_size[1]))))
+        return (fs, (width, height), 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, fs_size, x = self.maybe_scroll_font_surface(
+                self.font,
+                self.value[:cursor.pos],
+                fs,
+                self.rect.width - 24,
+                self.rect.height,
+            )
+        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()
+        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 update(self):
+        if self.cursor is not None:
+            self.cursor.update()
+
+    def remove_cursor(self):
+        self.callback(self.value)
+        self.cursor = self.parent.cursor = None
+        self.dirty = True
+
+    def handle_mousebuttondown(self, ev):
+        if ev.button == 1:
+            if self.rect.collidepoint(ev.pos):
+                if self.parent.cursor is not None:
+                    self.parent.cursor.remove()
+                self.cursor = self.parent.cursor = Cursor(self, len(self.value))
+                self.dirty = True
+            elif self.cursor is not None:
+                self.remove_cursor()
+
+    def handle_keydown(self, ev):
+        if self.cursor is None:
+            return
+        self.cursor.press(ev.key, ev.mod, ev.unicode)
+
+    def handle_keyup(self, ev):
+        if self.cursor is None:
+            return
+        if ev.key == self.cursor.key:
+            self.cursor.release()