from colorsys import hsv_to_rgb
-from math import asin, nan, sin, sqrt, tau
+from contextlib import contextmanager
+from functools import partial
+from math import asin, floor, nan, sin, sqrt, tau
from time import time
import pygame
# todo:
+# - [ ] textinput place cursor next to the mouse
# - [ ] spinner
# - [ ] modal dialogs
# - modal dialog will always replace all children, if you want persistent
self.dirty = False
self.clock = pygame.time.Clock()
self.children = []
+ self.cursor = None
def handle_quit(self, _):
self.running = False
def handle_keydown(self, ev):
if ev.key == pygame.K_ESCAPE:
- self.running = False
+ if self.cursor is not None:
+ self.cursor.remove()
+ else:
+ self.running = False
def handle_event(self, ev):
super().handle_event(ev)
value_color = "lime" if self.is_active else "gray"
frame_color = value_color
else:
- pygame.draw.rect(self.parent.surf, "darkgray", self.rect)
+ pygame.draw.rect(self.surf, "darkgray", self.rect)
frame_color = "lightgray"
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 = self.font.render(self.value, True, value_color)
+ pygame.draw.rect(self.surf, frame_color, self.rect, 8)
fs_size = fs.get_size()
center = self.rect.center
- self.parent.surf.blit(
+ 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.parent.dirty = 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.parent.dirty = True
+ 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.parent.dirty = True
+ self.dirty = True
class Slider(UIChild):
self.value = value
if self.callback:
self.callback(value)
- self.parent.dirty = True
+ self.dirty = True
def handle_mousebuttondown(self, ev):
if ev.button != 1 or not self.rect.collidepoint(ev.pos):
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))
+ 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
self.value = value
def draw(self):
- fs = self.parent.font.render(self.value, True, "gray")
- self.parent.surf.blit(
+ fs = self.font.render(self.value, True, "gray")
+ self.surf.blit(
fs,
(self.rect.left + 16, self.rect.centery - fs.get_height() // 2)
)
self.flip_again = False
def draw(self):
- pygame.draw.rect(self.parent.surf, "gray", self.rect, 8)
+ pygame.draw.rect(self.surf, "gray", self.rect, 8)
t = time()
if t > self.moving_since + self.MOVE_FOR_SEC:
self.callback(self.value)
args = (1 / 3, 2 * normalized_current, 0.5 + normalized_current * 0.5)
rgb = hsv_to_rgb(*args)
pygame.draw.circle(
- self.parent.surf,
+ 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.parent.dirty = True
+ self.dirty = True
def handle_mousebuttondown(self, ev):
if ev.button == 1 and self.rect.collidepoint(ev.pos):
self.value = bool(self.value) ^ True
offset = self.MOVE_FOR_SEC / 2 if self.value is None else 0
self.moving_since = time() - offset
+
+
+class Cursor:
+ DELAY_MS = 500
+ REPEAT_MS = 100
+
+ def __init__(self, child, pos):
+ self.child = child
+ self.key_callback = None
+ self.key = None
+ self.repeat_ts = None
+ self.pos = pos
+
+ MODS = (pygame.KMOD_CTRL, pygame.KMOD_ALT, pygame.KMOD_META, pygame.KMOD_SHIFT)
+
+ @classmethod
+ def get_mods(cls, mod):
+ mods = set()
+ for mask in cls.MODS:
+ if mod & mask:
+ mods.add(mask)
+ return frozenset(mods)
+
+ @contextmanager
+ def check_dirty(self):
+ old = self.pos, self.value
+ yield
+ if (self.pos, self.value) != old:
+ self.child.dirty = True
+
+ def press(self, key, mod, unicode):
+ method = self.METHODS_PER_MODS.get(self.get_mods(mod), {}).get(key)
+ if method is not None:
+ self.key_callback = partial(method, self)
+ elif unicode and unicode.isprintable():
+ self.key_callback = partial(self.key_printable_unicode, unicode)
+ else:
+ return
+ with self.check_dirty():
+ self.key_callback()
+ self.key = key
+ self.repeat_ts = time() + self.DELAY_MS / 1000
+
+ def release(self):
+ self.key_callback = None
+ self.key = None
+ self.repeat_ts = None
+
+ @property
+ def value(self):
+ return self.child.value
+
+ @value.setter
+ def value(self, value):
+ self.child.value = value
+
+ 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 = f"{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
+ value = f"{value[:self.pos]}{unicode}{value[self.pos:]}"
+ if self.child.value_filter:
+ try:
+ value = self.child.value_filter(value)
+ except Exception:
+ value = None
+ if not isinstance(value, str):
+ return
+ self.value = value
+ self.pos += len(unicode)
+
+ def remove(self):
+ self.child.remove_cursor()
+
+ METHODS_PER_MODS = {
+ 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: remove,
+ pygame.K_RETURN: remove,
+ },
+ frozenset({pygame.KMOD_CTRL}): {
+ pygame.K_LEFT: key_prev_word,
+ pygame.K_RIGHT: key_next_word,
+ },
+ }
+
+
+class TextInput(UIChild):
+ def __init__(self, parent, rect, callback, value="", value_filter=None):
+ super().__init__(parent)
+ self.rect = rect
+ self.callback = callback
+ self.value = value
+ self.value_filter = value_filter
+ self.cursor = None
+
+ @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, fs_size, 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:
+ offset = offset_right
+ if offset == 0:
+ return fs, fs_size, x
+ fs = fs.subsurface(pygame.Rect((offset, 0), (width, min(height, fs_size[1]))))
+ return (fs, (width, height), 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, fs_size, x = self.maybe_scroll_font_surface(
+ self.font,
+ self.value[:cursor.pos],
+ fs,
+ self.rect.width - 24,
+ self.rect.height,
+ )
+ 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()
+ 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 update(self):
+ if self.cursor is not None:
+ self.cursor.update()
+
+ def remove_cursor(self):
+ self.callback(self.value)
+ self.cursor = self.parent.cursor = None
+ self.dirty = True
+
+ def handle_mousebuttondown(self, ev):
+ if ev.button == 1:
+ if self.rect.collidepoint(ev.pos):
+ if self.parent.cursor is not None:
+ self.parent.cursor.remove()
+ self.cursor = self.parent.cursor = Cursor(self, len(self.value))
+ self.dirty = True
+ elif self.cursor is not None:
+ self.remove_cursor()
+
+ def handle_keydown(self, ev):
+ if self.cursor is None:
+ return
+ self.cursor.press(ev.key, ev.mod, ev.unicode)
+
+ def handle_keyup(self, ev):
+ if self.cursor is None:
+ return
+ if ev.key == self.cursor.key:
+ self.cursor.release()