+import re
from colorsys import hsv_to_rgb
from contextlib import contextmanager
from functools import partial
import pygame
-# todo:
-# - [ ] spinner
-
-
class EventMethodDispatcher:
MODS = (pygame.KMOD_CTRL, pygame.KMOD_ALT, pygame.KMOD_META, pygame.KMOD_SHIFT)
KEY_METHODS = {}
getattr(self, method_name)(ev)
-class UIParent(EventMethodDispatcher):
+class Parent(EventMethodDispatcher):
+ def __init__(self):
+ self.children = []
+
+ def handle_event(self, ev):
+ super().handle_event(ev)
+ for child in self.children:
+ child.handle_event(ev)
+
+ def update(self):
+ for child in self.children:
+ child.update()
+
+ def draw(self):
+ for child in self.children:
+ child.draw()
+
+
+class Child(EventMethodDispatcher):
+ root: "Root"
+
+ def __init__(self, root):
+ self.root = root
+
+ @property
+ def dirty(self):
+ return self.root.dirty
+
+ @dirty.setter
+ def dirty(self, value):
+ self.root.dirty = value
+
+ @property
+ def font(self):
+ return self.root.font
+
+ @property
+ def surf(self):
+ return self.root.surf
+
+ def draw(self):
+ pass
+
+ def update(self):
+ pass
+
+
+class CursorParent(Parent):
+ def __init__(self):
+ super().__init__()
+ self.cursor: Cursor | None = None
+
+ def handle_event(self, ev):
+ super().handle_event(ev)
+ if self.cursor is not None:
+ self.cursor.handle_event(ev)
+
+ def update(self):
+ super().update()
+ if self.cursor is not None:
+ self.cursor.update()
+
+
+class CursorChild(Child):
+ @property
+ def cursor(self):
+ cursor = self.root.cursor
+ if cursor is not None and cursor.text_input is self:
+ return cursor
+ return None
+
+ @cursor.setter
+ def cursor(self, value):
+ self.root.cursor = value
+
+
+class Root(CursorParent):
BACKGROUND_COLOR: pygame.Color
def __init__(self, surf, font=None):
self.running = True
self.dirty = False
self.clock = pygame.time.Clock()
- self.children = []
- self.cursor: Cursor | None = None
def handle_quit(self, _):
self.running = False
KEY_METHODS = {frozenset(set()): {pygame.K_ESCAPE: key_escape}}
- def handle_event(self, ev):
- children = (
- super(),
- *((self.cursor,) if self.cursor is not None else ()),
- *self.children,
- )
- for child in children:
- child.handle_event(ev)
- if not self.running:
- return
-
- def update(self):
- if self.cursor is not None:
- self.cursor.update()
- for child in self.children:
- child.update()
-
def draw(self):
if hasattr(self, "BACKGROUND_COLOR"):
self.surf.fill(self.BACKGROUND_COLOR)
- for child in self.children:
- child.draw()
+ super().draw()
def run(self):
while True:
self.clock.tick(60)
-class UIChild(EventMethodDispatcher):
- parent: "UIParent"
-
- def __init__(self, parent):
- self.parent = parent
-
- @property
- def dirty(self):
- return self.parent.dirty
-
- @dirty.setter
- def dirty(self, value):
- self.parent.dirty = value
-
- @property
- def font(self):
- return self.parent.font
-
- @property
- def surf(self):
- return self.parent.surf
-
- @property
- def cursor(self):
- cursor = self.parent.cursor
- if cursor is not None and cursor.text_input is self:
- return cursor
- return None
-
- @cursor.setter
- def cursor(self, value):
- self.parent.cursor = value
-
- def draw(self):
- pass
-
- def update(self):
- pass
-
-
-class Button(UIChild):
- def __init__(self, parent, rect, value, callback, is_active=False):
- super().__init__(parent)
+class Button(Child):
+ def __init__(self, root, rect, value, callback, is_active=False):
+ super().__init__(root)
self.rect = rect
self.value = value
self.callback = callback
self.dirty = True
-class Slider(UIChild):
+class Slider(Child):
HORIZONTAL = 0
VERTICAL = 1
- def __init__(self, parent, rect, direction, value=0, callback=None):
- super().__init__(parent)
+ def __init__(self, root, rect, direction, value=0, callback=None):
+ super().__init__(root)
self.rect = rect
self.direction = direction
self.extent = (self.rect.width - 1, self.rect.height - 1)[direction]
pygame.draw.line(subsurf, color, start_pos, end_pos, 8)
-class Label(UIChild):
- def __init__(self, parent, rect, value):
- super().__init__(parent)
+class Label(Child):
+ def __init__(self, root, rect, value):
+ super().__init__(root)
self.rect = rect
self.value = value
)
-class Switch(UIChild):
+class Switch(Child):
MOVE_FOR_SEC = 1
EASE = staticmethod(EaseInOutElastic((sqrt(5) - 1) / 2))
- def __init__(self, parent, rect, callback, value=False):
- super().__init__(parent)
+ def __init__(self, root, rect, callback, value=False):
+ super().__init__(root)
self.rect = rect
self.callback = callback
if value is not None and not isinstance(value, bool):
self.set_value(bool(self.value) ^ True)
-class Cursor(UIChild, EventMethodDispatcher):
+class Cursor(Child):
DELAY_MS = 500
REPEAT_MS = 100
def __init__(self, text_input, x_offset):
- super().__init__(text_input.parent)
+ super().__init__(text_input.root)
self.text_input = text_input
self.old_value = text_input.value
self.key_callback = None
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
if self.text_input.value_filter:
try:
result = self.text_input.value_filter(value)
- except Exception:
+ except ValueError:
result = None
if isinstance(result, str):
value = result
}
-class TextInput(UIChild):
- def __init__(self, parent, rect, callback, value="", value_filter=None):
- super().__init__(parent)
+class TextInput(CursorChild):
+ def __init__(self, root, rect, callback, value="", value_filter=None):
+ super().__init__(root)
self.rect = rect
self.callback = callback
self.value = value
self.value_filter = value_filter
self.offset = 0
- @staticmethod
- def maybe_scroll_font_surface(font, value_to_cursor, fs, width, height):
- x = font.size(value_to_cursor)[0]
- fs_size = fs.get_size()
- if fs_size[0] < width:
- return fs, 0, x
- offset = 0
- offset_centered = max(x - width // 2, 0)
- offset_right = max(fs_size[0] - width, 0)
- if offset_centered < offset_right:
- offset = offset_centered
- width = min(width, fs_size[0] - offset)
- elif offset_right > 0:
- offset = offset_right
- if offset == 0:
- return fs, 0, x
- fs = fs.subsurface(pygame.Rect((offset, 0), (width, min(height, fs_size[1]))))
- return fs, offset, x - offset
-
- def get_font_surface(self):
- fs = self.font.render(self.value, True, "gray")
- cursor = self.cursor
- if cursor is None:
- return fs
- if cursor.pos > len(self.value):
- cursor.pos = len(self.value)
- if self.value:
- fs, self.offset, x = self.maybe_scroll_font_surface(
- self.font,
- self.value[:cursor.pos],
- fs,
- self.rect.width - 24,
- self.rect.height,
- )
- fs_size = fs.get_size()
- else:
- x = 1
- fs_size = (x, self.font.size("W")[1])
- fs = pygame.Surface(fs_size, pygame.SRCALPHA, 32)
- if x == fs_size[0]:
- x -= 1
- pygame.draw.line(fs, "orange", (x, 0), (x, fs_size[1]))
- return fs
-
def draw(self):
pygame.draw.rect(self.surf, "black", self.rect)
- fs = self.get_font_surface()
+ if self.cursor is not None:
+ fs = self.cursor.get_font_surface()
+ else:
+ fs = self.font.render(self.value, True, "gray")
self.surf.subsurface(self.rect).blit(
fs, (16, (self.rect.height - fs.get_height()) // 2)
)
self.dirty = True
if restore:
self.value = old_value
- else:
+ elif self.value != old_value:
self.callback(self.value)
+ self.offset = 0
def handle_mousebuttondown(self, ev):
if ev.button == 1:
self.blur(True)
-class FPSWidget(UIChild):
+class FPSWidget(Child):
FPS_COLOR = "yellow"
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.clock = self.parent.clock
+ def __init__(self, root):
+ super().__init__(root)
+ self.clock = self.root.clock
self.current_fps = None
def update(self):
)
-class Icon(UIChild):
- def __init__(self, parent, shape):
- super().__init__(parent)
+class Icon(Child):
+ def __init__(self, root, shape):
+ super().__init__(root)
self.shape = shape
def draw(self):
class IconButton(Button):
- def __init__(self, parent, shape, *args, **kwargs):
- super().__init__(parent, *args, **kwargs)
+ def __init__(self, root, shape, *args, **kwargs):
+ super().__init__(root, *args, **kwargs)
self.shape = shape
def draw(self):
if self.pushed:
- pygame.draw.rect(self.parent.surf, "honeydew4", self.rect)
- self.shape.draw(self.parent.surf, "black" if self.pushed else "white")
+ pygame.draw.rect(self.surf, "honeydew4", self.rect)
+ self.shape.draw(self.surf, "black" if self.pushed else "white")
if self.is_active:
color = "lime"
elif self.pushed:
color = "red"
else:
color = "gray"
- pygame.draw.rect(self.parent.surf, color, self.rect, 8)
+ pygame.draw.rect(self.surf, color, self.rect, 8)
-class TabBar(UIChild):
- def __init__(self, parent, rect, labels, groups, active):
- super().__init__(parent)
+class TabBar(Child):
+ def __init__(self, root, rect, labels, groups, active):
+ super().__init__(root)
self.labels = labels
self.groups = groups
num_names = len(groups)
self.buttons = [
Button(
- parent,
+ root,
pygame.Rect(
(rect.left + rect.width * i // num_names, rect.top),
(rect.width // num_names, rect.height),
)
for i in range(len(groups))
]
- parent.children.extend(self.buttons)
- parent.children.extend(self.groups[active])
+ root.children.extend(self.buttons)
+ root.children.extend(self.groups[active])
self.active = active
def update_children(self, name):
button.is_active = is_group_active
self.dirty = True
for item in group:
- is_child_active = item in self.parent.children
+ is_child_active = item in self.root.children
if is_group_active == is_child_active:
continue
if is_group_active:
- self.parent.children.append(item)
+ self.root.children.append(item)
elif is_child_active:
- self.parent.children.remove(item)
+ self.root.children.remove(item)
self.dirty = True
-class Modal(UIChild):
- def __init__(self, parent):
- super().__init__(parent)
- self.backsurf = parent.surf.copy()
+class Modal(Child):
+ def __init__(self, root):
+ super().__init__(root)
+ self.backsurf = root.surf.copy()
self.tintsurf = pygame.Surface(self.backsurf.get_size(), pygame.SRCALPHA, 32)
self.tintsurf.fill(pygame.Color(0x00000080))
- self.children = parent.children.copy()
- parent.children.clear()
- parent.children.append(self)
+ self.children = root.children.copy()
+ root.children.clear()
+ root.children.append(self)
self.dirty = True
def draw(self):
self.surf.blit(self.tintsurf, (0, 0))
def deactivate(self):
- self.parent.children.clear()
- self.parent.children.extend(self.children)
+ self.root.children.clear()
+ self.root.children.extend(self.children)
self.dirty = True
class DropDown(Button):
- def __init__(self, parent, rect, value, entries, callback):
- super().__init__(parent, rect, value, partial(self.DropDownMenu, self), False)
+ def __init__(self, root, rect, value, entries, callback):
+ super().__init__(root, rect, value, partial(self.DropDownMenu, self), False)
self.entries = entries
self.dropdown_callback = callback
class DropDownMenu(Modal):
def __init__(self, drop_down):
- parent = drop_down.parent
- super().__init__(parent)
+ root = drop_down.root
+ super().__init__(root)
self.callback = drop_down.dropdown_callback
rect = drop_down.rect
self.buttons = [
Button(
- parent,
+ root,
pygame.Rect(
(rect.left, rect.bottom + i * rect.height), rect.size,
),
)
for i, entry in enumerate(drop_down.entries)
]
- parent.children.extend(self.buttons)
+ root.children.extend(self.buttons)
def choose(self, i=None):
self.deactivate()
class MessageBox(Modal):
- def __init__(self, parent, rect, message):
- super().__init__(parent)
+ def __init__(self, root, rect, message):
+ super().__init__(root)
self.rect = rect
fs_size = self.font.size(message)
label_rect = pygame.Rect(
rect.topleft,
(rect.width, rect.height * 3 // 4),
)
- parent.children.extend(
+ root.children.extend(
(
Label(
- parent,
+ root,
pygame.Rect(
(label_rect.centerx - fs_size[0] // 2, label_rect.centery - fs_size[1] // 2),
fs_size,
message,
),
Button(
- parent,
+ root,
pygame.Rect(
(rect.left + rect.width // 3, rect.top + rect.height * 6 // 8),
(rect.width // 3, rect.height // 8),
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