From: mar77i Date: Tue, 21 Jan 2025 14:55:56 +0000 (+0100) Subject: add TextInput and Cursor. X-Git-Url: https://git.mar77i.info/?a=commitdiff_plain;h=72c31fee2d19d00d8dbf747b61a307d57673027a;p=zenbook_gui add TextInput and Cursor. --- diff --git a/ui/ui.py b/ui/ui.py index 8f9e6df..c7b391f 100644 --- a/ui/ui.py +++ b/ui/ui.py @@ -1,11 +1,14 @@ 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 @@ -34,6 +37,7 @@ class UIParent(EventMethodDispatcher): self.dirty = False self.clock = pygame.time.Clock() self.children = [] + self.cursor = None def handle_quit(self, _): self.running = False @@ -45,7 +49,10 @@ class UIParent(EventMethodDispatcher): 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) @@ -123,34 +130,34 @@ class Button(UIChild): 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): @@ -174,7 +181,7 @@ 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): @@ -196,9 +203,9 @@ class Slider(UIChild): 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 @@ -224,8 +231,8 @@ class Label(UIChild): 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) ) @@ -269,7 +276,7 @@ class Switch(UIChild): 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) @@ -300,7 +307,7 @@ class Switch(UIChild): 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 @@ -308,7 +315,7 @@ class Switch(UIChild): 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): @@ -318,3 +325,248 @@ class Switch(UIChild): 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()