+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
--- /dev/null
+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)
--- /dev/null
+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
--- /dev/null
+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)
--- /dev/null
+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)
+ )
--- /dev/null
+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")
--- /dev/null
+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)
--- /dev/null
+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)
+ )
--- /dev/null
+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,
+ )
--- /dev/null
+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
--- /dev/null
+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)
--- /dev/null
+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
--- /dev/null
+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)
--- /dev/null
+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
--- /dev/null
+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)
+++ /dev/null
-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