]> git.mar77i.info Git - zenbook_gui/commitdiff
use focus stack for modal and cursor
authormar77i <mar77i@protonmail.ch>
Tue, 11 Feb 2025 01:09:07 +0000 (02:09 +0100)
committermar77i <mar77i@protonmail.ch>
Tue, 11 Feb 2025 01:09:07 +0000 (02:09 +0100)
ui/base.py
ui/drop_down.py
ui/focus.py [new file with mode: 0644]
ui/message_box.py
ui/modal.py
ui/scroll.py
ui/spinner.py
ui/tab_bar.py
ui/text_input.py
zenbook_conf/zenbook_conf.py

index fc86575bbe6101f788e1df0d02fc32058634be23..91d4b5f2ff43a6b2f084df884aaa0aece1767399 100644 (file)
@@ -2,6 +2,8 @@ from functools import cached_property, partial
 
 import pygame
 
+from .focus import FocusRootMixin
+
 
 class EventMethodDispatcher:
     MODS = (pygame.KMOD_CTRL, pygame.KMOD_ALT, pygame.KMOD_META, pygame.KMOD_SHIFT)
@@ -29,36 +31,65 @@ class EventMethodDispatcher:
         if hasattr(self, method_name):
             getattr(self, method_name)(ev)
 
+    def update(self):
+        pass
+
+    def draw(self):
+        pass
+
 
 class Parent(EventMethodDispatcher):
-    def __init__(self):
+    root: "Root"
+    running: bool
+    stop_event: bool
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
         self.children = []
 
     def handle_event(self, ev):
-        Child.handle_event(self, ev)
-        self.handle_event_children(ev)
-
-    def handle_event_children(self, ev):
-        root = self.root
-        if not root.running or root.stop_event:
+        super().handle_event(ev)
+        if not self.running or self.stop_event:
             return
         for child in self.children:
             child.handle_event(ev)
-            if not root.running or root.stop_event:
+            if not self.running or self.stop_event:
                 break
 
     def update(self):
+        super().update()
         for child in self.children:
             child.update()
 
     def draw(self):
+        super().draw()
         for child in self.children:
             child.draw()
 
 
 class Child(EventMethodDispatcher):
     def __init__(self, parent):
-        self.parent = parent
+        self._parent = parent
+        if parent is None:
+            return
+        for parent in (parent, getattr(parent, "parent", None)):
+            if isinstance(parent, Parent):
+                parent.children.append(self)
+                break
+
+    @property
+    def parent(self):
+        return self._parent
+
+    @parent.setter
+    def parent(self, parent):
+        if self._parent is not None:
+            self._parent.children.remove(self)
+        self._parent = parent
+        if parent is not None:
+            parent.children.append(self)
+        if "root" in self.__dict__:
+            self.__dict__.pop("root")
 
     @cached_property
     def root(self):
@@ -88,20 +119,16 @@ class Child(EventMethodDispatcher):
             parent = parent.root or parent.parent
         return parent.surf
 
-    def draw(self):
-        pass
-
-    def update(self):
-        pass
-
+    @property
+    def running(self):
+        return self.root.running
 
-class ChildAndParent(Parent, Child):
-    def __init__(self, parent):
-        Parent.__init__(self)
-        Child.__init__(self, parent)
+    @property
+    def stop_event(self):
+        return self.root.stop_event
 
 
-class Root(Parent):
+class Root(FocusRootMixin, Parent):
     BACKGROUND_COLOR: pygame.Color
 
     def __init__(self, surf, font=None):
@@ -111,7 +138,6 @@ class Root(Parent):
         self.dirty = False
         self.surf = surf
         self.clock = pygame.time.Clock()
-        self.cursor = None
         self.stop_event = False
         self.root = self
 
@@ -124,7 +150,7 @@ class Root(Parent):
     handle_activeevent = handle_windowexposed
 
     def key_escape(self):
-        if self.cursor is None:
+        if self.active:
             self.running = False
 
     KEY_METHODS = {frozenset(set()): {pygame.K_ESCAPE: key_escape}}
@@ -134,11 +160,6 @@ class Root(Parent):
             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():
index b09ec680dc2f0b521a5d85ecd59f7964f12e1197..2673d3b2afb67b25cfbe063268027947d74745ef 100644 (file)
@@ -2,6 +2,7 @@ from functools import partial
 
 import pygame
 
+from .base import Parent
 from .button import Button
 from .modal import Modal
 
@@ -10,19 +11,15 @@ class DropDownMenu(Modal):
     def __init__(self, parent, rect, entries, callback):
         super().__init__(parent)
         self.callback = callback
-        self.children.extend(
-            (
-                Button(
-                    self,
-                    pygame.Rect(
-                        (rect.left, rect.bottom + i * rect.height), rect.size,
-                    ),
-                    entry,
-                    partial(self.choose, i),
-                )
-                for i, entry in enumerate(entries)
+        for i, entry in enumerate(entries):
+            Button(
+                self,
+                pygame.Rect(
+                    (rect.left, rect.bottom + i * rect.height), rect.size,
+                ),
+                entry,
+                partial(self.choose, i),
             )
-        )
         self.buttons_rect = pygame.Rect(
             rect.bottomleft, (rect.width, rect.height * len(entries))
         )
@@ -38,7 +35,10 @@ class DropDownMenu(Modal):
             self.deactivate()
 
 
-class DropDown(Button):
+class DropDown(Parent, Button):
     def __init__(self, parent, rect, value, entries, callback, is_active=False):
-        self.dropdown_menu = DropDownMenu(parent, rect, entries, callback)
-        super().__init__(parent, rect, value, self.dropdown_menu.activate, is_active)
+        super().__init__(parent, rect, value, self.activate, is_active)
+        self.dropdown_menu = DropDownMenu(self, rect, entries, callback)
+
+    def activate(self):
+        self.dropdown_menu.activate()
diff --git a/ui/focus.py b/ui/focus.py
new file mode 100644 (file)
index 0000000..07fe6d1
--- /dev/null
@@ -0,0 +1,37 @@
+import pygame
+
+
+class FocusRootMixin:
+    surf: pygame.Surface
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.focus_stack: list[FocusableMixin | FocusRootMixin] = [self]
+
+    @property
+    def active(self):
+        return self.focus_stack[-1] is self
+
+    def handle_event(self, ev):
+        focused = self.focus_stack[-1]
+        (super() if focused is self else focused).handle_event(ev)
+
+
+class FocusableMixin:
+    root: FocusRootMixin
+
+    @property
+    def active(self):
+        return self.root.focus_stack[-1] is self
+
+    def activate(self):
+        focus_stack = self.root.focus_stack
+        assert self not in focus_stack
+        focus_stack.append(self)
+        self.dirty = True
+
+    def deactivate(self):
+        focus_stack = self.root.focus_stack
+        assert self.active
+        focus_stack.pop()
+        self.dirty = True
index 3c431527bc413809fb015b09221f89cd56b96eff..5b481b2f45f5a2cd80022cf7767dcdc69f2c453b 100644 (file)
@@ -9,14 +9,14 @@ class MessageBox(Modal):
     def __init__(self, parent, rect, message):
         super().__init__(parent)
         self.rect = rect
-        self.label = Label(self.parent, pygame.Rect(rect.center, (10, 10)), "")
-        self.children.extend((self.label, *self.get_buttons()))
+        self.label = Label(self, pygame.Rect(rect.center, (10, 10)), "")
+        self.add_buttons()
         self.message = message
 
-    def get_buttons(self):
+    def add_buttons(self):
         rect = self.rect
-        yield Button(
-            self.parent,
+        Button(
+            self,
             pygame.Rect(
                 (rect.left + rect.width // 3, rect.centery + 64),
                 (rect.width // 3, 128),
@@ -26,9 +26,9 @@ class MessageBox(Modal):
         )
 
     def draw(self):
-        super().draw()
         pygame.draw.rect(self.surf, "black", self.rect)
         pygame.draw.rect(self.surf, "gray", self.rect, 1)
+        super().draw()
 
     @property
     def message(self):
index 0a1b4ef07b052714a56cc1b560df17e6ae64aab0..f92e3c89f204207266446935a11a91f97eb609a2 100644 (file)
@@ -1,15 +1,13 @@
 import pygame
 
-from .base import Child
+from .base import Child,Parent
+from .focus import FocusableMixin
 
 
-class Modal(Child):
+class Modal(FocusableMixin, Parent, Child):
     def __init__(self, parent):
         super().__init__(parent)
-        self.backsurf = None
-        self.children = []
-        self.active = False
-        self.draw = self._check_active(self.draw)
+        self.draw = self._check_active(self._wrap_draw(self.draw))
         self.update = self._check_active(self.update)
         self.handle_event = self._check_active(self.handle_event)
         self.activate = self._check_active(self.activate, False)
@@ -22,35 +20,23 @@ class Modal(Child):
             return None
         return inner
 
-    def draw(self):
-        self.surf.blit(self.backsurf, (0, 0))
-
-    @staticmethod
-    def tinted_copy(surf):
-        surf = surf.copy()
-        tintsurf = pygame.Surface(surf.get_size(), pygame.SRCALPHA, 32)
-        tintsurf.fill(pygame.Color(0x00000080))
-        surf.blit(tintsurf, (0, 0))
-        return surf
-
-    def swap_children(self):
-        children = self.children.copy()
-        self.children.clear()
-        self.children.extend(self.root.children)
-        self.root.children.clear()
-        self.root.children.extend(children)
+    def _wrap_draw(self, method):
+        def inner():
+            tintsurf = pygame.Surface(self.surf.get_size(), pygame.SRCALPHA, 32)
+            tintsurf.fill(pygame.Color(0x80))
+            self.surf.blit(tintsurf, (0, 0))
+            return method()
+        return inner
 
     def activate(self):
-        self.backsurf = self.tinted_copy(self.surf)
-        self.swap_children()
-        self.root.children.insert(0, self)
-        self.active = True
+        super().activate()
         self.root.stop_event = True
         self.dirty = True
 
     def deactivate(self):
-        self.backsurf = None
-        self.swap_children()
-        self.active = False
+        super().deactivate()
         self.root.stop_event = True
         self.dirty = True
+
+    def draw(self):
+        super().draw()
index 22c7614f732d595289c4a81c0c6baf7c8d6f07f3..292b689a5ac30337277a2a5f9a957652bfdc3d62 100644 (file)
@@ -1,13 +1,13 @@
 import pygame
 
-from .base import ChildAndParent
+from .base import ChildParent
 
 
-class Scroll(ChildAndParent):
+class Scroll(Parent, Child):
     def __init__(self, parent, rect, surf_size):
         super().__init__(parent)
         self.rect = rect
-        self.surf = pygame.Surface(surf_size, pygame.SRCALPHA, 32)
+        self.__dict__["surf"] = pygame.Surface(surf_size, pygame.SRCALPHA, 32)
         self.scroll_x = 0
         self.scroll_y = 0
 
@@ -28,7 +28,7 @@ class Scroll(ChildAndParent):
         pygame.draw.rect(self.parent.surf, "gray", self.rect)
         self.root.surf.blit(self.get_subsurf(), self.rect.topleft)
 
-    def handle_event_children(self, ev):
+    def handle_event(self, ev):
         if hasattr(ev, "pos"):
             if not self.rect.collidepoint(ev.pos):
                 return
@@ -42,4 +42,4 @@ class Scroll(ChildAndParent):
                     )
                 },
             )
-        super().handle_event_children(ev)
+        super().handle_event(ev)
index 95740010a5a7b47a2f23b65fb865b3c489a3975f..8154829fa77f883ab7c6a86d00664729ed284253 100644 (file)
@@ -5,7 +5,7 @@ from time import time
 
 import pygame
 
-from .base import ChildAndParent
+from .base import ChildParent
 from .button import Button
 from .text_input import TextInput
 
@@ -49,42 +49,38 @@ class RepeatButton(Button):
             self.dirty = True
 
 
-class Spinner(ChildAndParent):
+class Spinner(Parent, Child):
     def __init__(self, parent, rect, callback, value=0):
         super().__init__(parent)
         self.callback = callback
         self.value = value
         button_size = (rect.height // 2, rect.height // 2)
-        self.children.extend(
-            (
-                TextInput(
-                    self,
-                    pygame.Rect(
-                        rect.topleft, (rect.width - button_size[0], rect.height)
-                    ),
-                    self.call_callback,
-                    str(value),
-                    re.compile(r"[-+]?\d*").fullmatch,
-                ),
-                RepeatButton(
-                    self,
-                    pygame.Rect(
-                        (rect.right - button_size[0], rect.top),
-                        button_size,
-                    ),
-                    "^",
-                    partial(self.spin_callback, 1),
-                ),
-                RepeatButton(
-                    self,
-                    pygame.Rect(
-                        (rect.right - button_size[0], rect.top + button_size[1]),
-                        button_size,
-                    ),
-                    "v",
-                    partial(self.spin_callback, -1),
-                ),
-            )
+        self.text_input = TextInput(
+            self,
+            pygame.Rect(
+                rect.topleft, (rect.width - button_size[0], rect.height)
+            ),
+            self.call_callback,
+            str(value),
+            re.compile(r"[-+]?\d*").fullmatch,
+        )
+        RepeatButton(
+            self,
+            pygame.Rect(
+                (rect.right - button_size[0], rect.top),
+                button_size,
+            ),
+            "^",
+            partial(self.spin_callback, 1),
+        )
+        RepeatButton(
+            self,
+            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):
@@ -96,14 +92,13 @@ class Spinner(ChildAndParent):
             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)
+        if str_value != self.text_input.value:
+            self.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.text_input.value = str(self.value)
         self.callback(self.value)
         self.root.dirty = True
index 180785c8902033e4206e505c92ae4f75097488c0..7ff721d47dde4da230cb15cd1650959c183df7d8 100644 (file)
@@ -2,37 +2,41 @@ from functools import partial
 
 import pygame
 
-from .base import ChildAndParent
+from .base import ChildParent
 from .button import Button
 
 
-class TabBar(ChildAndParent):
+class TabBar(Parent, Child):
     def __init__(self, parent, rect, labels, groups, active):
         super().__init__(parent)
         self.labels = labels
+        # ...why do we have to reparent these items?
+        for group in groups:
+            for item in group:
+                item.parent = self
         self.groups = groups
-        num_names = len(groups)
+        self.active = active
+        num_labels = len(labels)
+        self.children.clear()
         self.buttons = [
             Button(
                 self,
                 pygame.Rect(
-                    (rect.left + rect.width * i // num_names, rect.top),
-                    (rect.width // num_names, rect.height),
+                    (rect.left + rect.width * i // num_labels, rect.top),
+                    (rect.width // num_labels, rect.height),
                 ),
                 labels[i],
                 partial(self.update_children, i),
-                i == active,
+                False,
             )
-            for i in range(len(groups))
+            for i in range(num_labels)
         ]
-        self.children.extend(self.buttons)
-        self.active = active
         self.children.extend(self.groups[active])
 
-    def update_children(self, name):
-        if self.active == name:
+    def update_children(self, i):
+        if self.active == i:
             return
-        self.active = name
+        self.active = i
         for i, (button, group) in enumerate(zip(self.buttons, self.groups)):
             is_group_active = i == self.active
             if button.is_active != is_group_active:
@@ -47,3 +51,4 @@ class TabBar(ChildAndParent):
                 elif is_child_active:
                     self.children.remove(item)
                 self.dirty = True
+        assert len(self.children) == len(set(self.children))
index 6481b5133dc7d85ee2a46f1f84bd4f3a18517337..ed2017169fe1a6a62857a8ad36ac88ac758997dc 100644 (file)
@@ -1,84 +1,78 @@
 from contextlib import contextmanager
+from dataclasses import dataclass
 from functools import partial
 from math import floor
 from time import time
+from typing import Optional, Callable
 
 import pygame
 
 from .base import Child
+from .focus import FocusableMixin
 
 
-class Cursor(Child):
+@dataclass
+class Cursor:
     DELAY_MS = 500
     REPEAT_MS = 100
 
-    def __init__(self, text_input, x_offset):
-        super().__init__(text_input.parent)
-        self.text_input = text_input
-        self.old_value = text_input.value
+    old_value: str
+    pos: Optional[int] = None
+    key_callback: Optional[Callable] = None
+    key: Optional[int] = None
+    repeat_ts: Optional[float] = None
+
+    def press_key(self, key_callback, key):
+        self.key_callback = key_callback
+        self.key = key
+        self.repeat_ts = time() + self.DELAY_MS / 1000
+
+    def release_key(self):
         self.key_callback = None
         self.key = None
         self.repeat_ts = None
-        self.pos = self.pos_from_offset(x_offset)
-        # I would rather have one cursor object on the current focus that activates
-        # itself by appending self to the current_focus stack.
-        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 repeat_key_callback(self):
+        repeat_offset = floor((time() - self.repeat_ts) * 1000 / self.REPEAT_MS) + 1
+        if repeat_offset < 1:
+            return
+        for _ in range(repeat_offset):
+            self.key_callback()
+        self.repeat_ts += self.REPEAT_MS * repeat_offset / 1000
 
-    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 search_same_isspace_backward(value, pos):
+    if pos == 0:
+        return 0
+    pos -= 1
+    isspace = value[pos].isspace()
+    while pos > 0:
+        pos -= 1
+        if value[pos].isspace() != isspace:
+            return pos + 1
+    return 0
 
-    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()
+def search_same_isspace_forward(value, pos):
+    len_value = len(value)
+    if pos == len_value:
+        return len_value
+    isspace = value[pos].isspace()
+    pos += 1
+    while pos < len_value and value[pos].isspace() == isspace:
+        pos += 1
+    return pos
 
-    @property
-    def value(self):
-        return self.text_input.value
 
-    @value.setter
-    def value(self, value):
-        self.text_input.value = value
+class TextInput(FocusableMixin, Child):
+    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.offset = 0
+        self.cursor = None
 
     @staticmethod
     def maybe_scroll_font_surface(font, value_to_cursor, fs, width, height):
@@ -101,14 +95,14 @@ class Cursor(Child):
         return fs, offset, x - offset
 
     def get_font_surface(self):
-        if self.pos > len(self.value):
-            self.pos = len(self.value)
+        if self.cursor.pos > len(self.value):
+            self.cursor.pos = len(self.value)
         fs = self.font.render(self.value, True, "gray")
-        rect = self.text_input.rect
+        rect = self.rect
         if self.value:
-            fs, self.text_input.offset, x = self.maybe_scroll_font_surface(
+            fs, self.offset, x = self.maybe_scroll_font_surface(
                 self.font,
-                self.value[:self.pos],
+                self.value[:self.cursor.pos],
                 fs,
                 rect.width - 24,
                 rect.height,
@@ -122,95 +116,127 @@ class Cursor(Child):
         return fs
 
     def update(self):
-        if self.key_callback is None:
+        if getattr(self.cursor, "key_callback", None) 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
+            self.cursor.repeat_key_callback()
 
-    def key_backspace(self):
-        if self.pos > 0:
-            self.pos -= 1
-            self.key_delete()
+    def draw(self):
+        pygame.draw.rect(self.surf, "black", self.rect)
+        if self.active:
+            fs = self.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 key_delete(self):
+    def pos_from_offset(self, x_offset):
         value = self.value
-        if self.pos < len(value):
-            self.value = "".join((value[:self.pos], value[self.pos + 1:]))
+        if x_offset is None:
+            return len(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
 
-    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 handle_mousebuttondown(self, ev):
+        if ev.button != 1:
+            return
+        if self.rect.collidepoint(ev.pos):
+            self.activate()
+            with self.check_dirty():
+                self.cursor.pos = self.pos_from_offset(
+                    ev.pos[0] - self.rect.left - 16 + self.offset
+                )
+        elif self.active:
+            self.deactivate(True)
+
+    @contextmanager
+    def check_dirty(self):
+        old = getattr(self.cursor, "pos", -1), self.value
+        yield
+        if (getattr(self.cursor, "pos", -2), self.value) != old:
+            self.dirty = True
 
     def key_left(self):
-        self.pos = max(self.pos - 1, 0)
+        self.cursor.pos = max(self.cursor.pos - 1, 0)
 
     def key_right(self):
-        self.pos = min(self.pos + 1, len(self.value))
+        self.cursor.pos = min(self.cursor.pos + 1, len(self.value))
 
     def key_home(self):
-        self.pos = 0
+        self.cursor.pos = 0
 
     def key_end(self):
-        self.pos = len(self.value)
+        self.cursor.pos = len(self.value)
 
-    def key_printable_unicode(self, unicode):
+    def key_backspace(self):
+        if self.cursor.pos > 0:
+            self.cursor.pos -= 1
+            self.key_delete()
+
+    def key_delete(self):
         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
+        if self.cursor.pos < len(value):
+            self.value = f"{value[:self.cursor.pos]}{value[self.cursor.pos + 1:]}"
+
+    def filter_value(self, value):
+        if not self.value_filter:
+            return value
+        try:
+            return self.value_filter(value)
+        except ValueError:
+            return None
+
+    def key_printable_unicode(self, unicode):
+        len_old_value = len(self.value)
+        value = f"{self.value[:self.cursor.pos]}{unicode}{self.value[self.cursor.pos:]}"
+        result = self.filter_value(value)
+        if isinstance(result, str):
+            value = result
+        elif not value:
+            return
         self.value = value
-        if len(value) > len_old_value:
-            self.pos += len(unicode)
+        len_value = len(self.value)
+        if len_value > len_old_value:
+            self.cursor.pos += len_value - len_old_value
 
-    def key_release(self):
-        self.key_callback = None
-        self.key = None
-        self.repeat_ts = None
+    def key_skip_word(self, func):
+        pos = self.cursor.pos
+        for _ in range(2):
+            pos = func(self.value, pos)
+        if pos != self.cursor.pos:
+            self.cursor.pos = pos
 
-    def key_blur(self, restore=False):
-        self.text_input.blur(restore)
+    def activate(self):
+        if self.active:
+            return
+        super().activate()
+        self.cursor = Cursor(self.value)
+
+    def deactivate(self, restore=False):
+        if not self.active:
+            return
+        if restore:
+            self.value = self.cursor.old_value
+        else:
+            self.callback(self.value)
+        self.cursor = None
+        super().deactivate()
 
     KEY_METHODS = {
         frozenset(set()): {
@@ -220,74 +246,30 @@ class Cursor(Child):
             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),
+            pygame.K_KP_ENTER: deactivate,
+            pygame.K_RETURN: deactivate,
+            pygame.K_ESCAPE: partial(deactivate, restore=True),
         },
         frozenset({pygame.KMOD_CTRL}): {
-            pygame.K_LEFT: key_prev_word,
-            pygame.K_RIGHT: key_next_word,
+            pygame.K_LEFT: partial(key_skip_word, func=search_same_isspace_backward),
+            pygame.K_RIGHT: partial(key_skip_word, func=search_same_isspace_forward),
         },
     }
 
-
-class TextInput(Child):
-    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.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()
+    def handle_keydown(self, ev):
+        if not self.active:
+            return
+        if (key_method := self.get_key_method(ev.key, ev.mod)) is not None:
+            key_callback = key_method
+        elif ev.unicode and ev.unicode.isprintable():
+            key_callback = partial(self.key_printable_unicode, ev.unicode)
         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
+            return
+        with self.check_dirty():
+            key_callback()
+        if self.active:
+            self.cursor.press_key(key_callback, ev.key)
 
-    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)
+    def handle_keyup(self, ev):
+        if ev.key == getattr(self.cursor, "key", None):
+            self.cursor.release_key()
index b9500631ce41be035bdac83eebc33587f864dbf2..a0ec61a5d2ee1ab8a8c1d92cc8c6ca693f955627 100644 (file)
@@ -85,70 +85,60 @@ class ZenbookConf(Root):
             ),
             self.xrandr_conf.is_active("vertical"),
         )
-        self.children.extend(
-            (
-                FPSWidget(self),
-                self.single_button,
-                self.double_button,
-                self.vertical_button,
-                bt_switch,
-                Icon(
-                    self,
-                    bluetooth.fit(
-                        (bt_switch.rect.right + 68, bt_switch.rect.centery - 88),
-                        (8, 8),
-                    ),
-                ),
-                touch_switch,
-                Icon(
-                    self,
-                    touchscreen.fit(
-                        (touch_switch.rect.right + 68, touch_switch.rect.centery - 88),
-                        (8, 8),
-                    ),
-                ),
-                stylus_switch,
-                Icon(
-                    self,
-                    stylus.fit(
-                        (
-                            stylus_switch.rect.right + 68,
-                            stylus_switch.rect.centery - 88,
-                        ),
-                        (8, 8),
-                    ),
-                ),
-                Button(
-                    self,
-                    pygame.Rect((784, touch_switch.rect.top), (384, 128)),
-                    "Re-apply",
-                    partial(self.xinput_conf.reapply_by_type, "touchpad")
-                ),
-                Button(
-                    self,
-                    pygame.Rect((784, stylus_switch.rect.top), (384, 128)),
-                    "Re-apply",
-                    partial(self.xinput_conf.reapply_by_type, "stylus")
-                ),
-                Button(
-                    self,
-                    pygame.Rect((window_size[0] - 128, 0), (128, 96)),
-                    "×",
-                    self.quit,
-                ),
-                Button(
-                    self,
-                    pygame.Rect((window_size[0] - 256, 0), (128, 96)),
-                    "_",
-                    self.iconify,
-                ),
-                Button(
-                    self,
-                    pygame.Rect((window_size[0] - 384, 0), (128, 96)),
-                    "»",
-                    self.next_display,
+        FPSWidget(self)
+        Icon(
+            self,
+            bluetooth.fit(
+                (bt_switch.rect.right + 68, bt_switch.rect.centery - 88),
+                (8, 8),
+            ),
+        )
+        Icon(
+            self,
+            touchscreen.fit(
+                (touch_switch.rect.right + 68, touch_switch.rect.centery - 88),
+                (8, 8),
+            ),
+        )
+        Icon(
+            self,
+            stylus.fit(
+                (
+                    stylus_switch.rect.right + 68,
+                    stylus_switch.rect.centery - 88,
                 ),
-            )
+                (8, 8),
+            ),
+        )
+        Button(
+            self,
+            pygame.Rect((784, touch_switch.rect.top), (384, 128)),
+            "Re-apply",
+            partial(self.xinput_conf.reapply_by_type, "touchpad")
+        )
+        Button(
+            self,
+            pygame.Rect((784, stylus_switch.rect.top), (384, 128)),
+            "Re-apply",
+            partial(self.xinput_conf.reapply_by_type, "stylus")
+        )
+        Button(
+            self,
+            pygame.Rect((window_size[0] - 128, 0), (128, 96)),
+            "×",
+            self.quit,
+        )
+        Button(
+            self,
+            pygame.Rect((window_size[0] - 256, 0), (128, 96)),
+            "_",
+            self.iconify,
+        )
+        Button(
+            self,
+            pygame.Rect((window_size[0] - 384, 0), (128, 96)),
+            "»",
+            self.next_display,
         )
 
     def get_icon(self):