]> git.mar77i.info Git - zenbook_gui/commitdiff
split up ui module
authormar77i <mar77i@protonmail.ch>
Tue, 4 Feb 2025 23:17:18 +0000 (00:17 +0100)
committermar77i <mar77i@protonmail.ch>
Tue, 4 Feb 2025 23:17:18 +0000 (00:17 +0100)
16 files changed:
ui/__init__.py
ui/base.py [new file with mode: 0644]
ui/button.py [new file with mode: 0644]
ui/drop_down.py [new file with mode: 0644]
ui/fps_widget.py [new file with mode: 0644]
ui/icon.py [new file with mode: 0644]
ui/icon_button.py [new file with mode: 0644]
ui/label.py [new file with mode: 0644]
ui/message_box.py [new file with mode: 0644]
ui/modal.py [new file with mode: 0644]
ui/slider.py [new file with mode: 0644]
ui/spinner.py [new file with mode: 0644]
ui/switch.py [new file with mode: 0644]
ui/tab_bar.py [new file with mode: 0644]
ui/text_input.py [new file with mode: 0644]
ui/ui.py [deleted file]

index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..c07599a8f61e148c883a3f3c1f202496284bcb6d 100644 (file)
@@ -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 (file)
index 0000000..6fe79da
--- /dev/null
@@ -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 (file)
index 0000000..9c2c14a
--- /dev/null
@@ -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 (file)
index 0000000..d644679
--- /dev/null
@@ -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 (file)
index 0000000..472efa8
--- /dev/null
@@ -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 (file)
index 0000000..35ec633
--- /dev/null
@@ -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 (file)
index 0000000..ebb077a
--- /dev/null
@@ -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 (file)
index 0000000..d8cd65c
--- /dev/null
@@ -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 (file)
index 0000000..2483aa0
--- /dev/null
@@ -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 (file)
index 0000000..2e97f59
--- /dev/null
@@ -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 (file)
index 0000000..aa516d1
--- /dev/null
@@ -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 (file)
index 0000000..066af4b
--- /dev/null
@@ -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 (file)
index 0000000..69e5d6d
--- /dev/null
@@ -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 (file)
index 0000000..8fafa30
--- /dev/null
@@ -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 (file)
index 0000000..4e3a08a
--- /dev/null
@@ -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 (file)
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