+from colorsys import hsv_to_rgb
+from math import asin, nan, sin, sqrt, tau
+from time import time
+
import pygame
+# todo:
+# - [ ] spinner
+# - [ ] modal dialogs
+# - modal dialog will always replace all children, if you want persistent
+# controls, you can use a custom list
+# - [ ] tab bar
+# - [ ] vector label
+# - [ ] vector button
+# - [ ]
+
+
class EventMethodDispatcher:
def handle_event(self, ev):
method_name = f"handle_{pygame.event.event_name(ev.type).lower()}"
def handle_keydown(self, ev):
if ev.key == pygame.K_ESCAPE:
self.running = False
- return
def handle_event(self, ev):
super().handle_event(ev)
class Button(UIChild):
- def __init__(self, parent, rect, label, callback, is_active=None):
+ def __init__(self, parent, rect, value, callback, is_active=False):
super().__init__(parent)
self.rect = rect
- self.label = label
+ self.value = value
self.callback = callback
self.is_active = is_active
self.pushed = False
def draw(self):
if not self.pushed:
- label_color = (
- "lime" if callable(self.is_active) and self.is_active() else "gray"
- )
- frame_color = label_color
+ value_color = "lime" if self.is_active else "gray"
+ frame_color = value_color
else:
pygame.draw.rect(self.parent.surf, "darkgray", self.rect)
frame_color = "lightgray"
- label_color = "black"
- label = self.label
- if callable(label):
- label = label()
- fs = self.parent.font.render(label, True, label_color)
+ value_color = "black"
+ fs = self.parent.font.render(self.value, True, value_color)
pygame.draw.rect(self.parent.surf, frame_color, self.rect, 8)
fs_size = fs.get_size()
center = self.rect.center
if ev.buttons[0] and not self.rect.collidepoint(ev.pos):
self.pushed = False
self.parent.dirty = True
+
+
+class Slider(UIChild):
+ HORIZONTAL = 0
+ VERTICAL = 1
+
+ def __init__(self, parent, rect, direction, value=0, callback=None):
+ super().__init__(parent)
+ 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.parent.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.parent.surf, "gray34", self.rect.inflate((8, 8)))
+ pygame.draw.rect(self.parent.surf, "black", self.rect)
+ self.draw_cursor(self.parent.surf.subsurface(self.rect))
+
+ 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(UIChild):
+ def __init__(self, parent, rect, value):
+ super().__init__(parent)
+ self.rect = rect
+ self.value = value
+
+ def draw(self):
+ fs = self.parent.font.render(self.value, True, "gray")
+ self.parent.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(UIChild):
+ MOVE_FOR_SEC = 1
+ EASE = staticmethod(EaseInOutElastic((sqrt(5) - 1) / 2))
+
+ def __init__(self, parent, rect, callback, value=False):
+ super().__init__(parent)
+ 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.parent.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.parent.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.parent.dirty = True
+
+ 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.value = bool(self.value) ^ True
+ offset = self.MOVE_FOR_SEC / 2 if self.value is None else 0
+ self.moving_since = time() - offset