From 2407f23cbe6a4929d15463729aac4a172b1579d6 Mon Sep 17 00:00:00 2001 From: mar77i Date: Mon, 1 Sep 2025 11:13:46 +0200 Subject: [PATCH] clean up launcher. rename focused to is_focused. and then some... --- bookpaint/bookpaint.py | 8 +- bookpaint/menu.py | 4 +- connect_four.py | 9 +- launch.py | 234 +++++++++++++++++++++++++---------- memory.py | 8 +- requirements.txt | 2 + rps/rps.py | 8 +- ui/__init__.py | 4 +- ui/child.py | 4 +- ui/focus.py | 8 +- ui/multitouch.py | 86 +++++++++++++ ui/root.py | 23 ++-- ui/spinner.py | 2 +- ui/text_input.py | 12 +- vs_memory.py | 13 +- zenbook_conf/zenbook_conf.py | 7 +- 16 files changed, 312 insertions(+), 120 deletions(-) create mode 100644 requirements.txt create mode 100755 ui/multitouch.py diff --git a/bookpaint/bookpaint.py b/bookpaint/bookpaint.py index 1912efd..2a4c169 100644 --- a/bookpaint/bookpaint.py +++ b/bookpaint/bookpaint.py @@ -1,9 +1,8 @@ from functools import partial from pathlib import Path -import pygame - -from ui import Root +from launch import pygame +from ui import BaseRoot from .book_manager import BookManager from .color_menu import ColorMenu @@ -13,7 +12,7 @@ from .menu import Menu from .page_menu import PageMenu -class BookPaint(Root): +class Root(BaseRoot): BACKGROUND_COLOR = "black" def setup_deactivate_keys(self): @@ -23,7 +22,6 @@ class BookPaint(Root): menu.key_methods[frozenset()][key] = menu.deactivate.__func__ def __init__(self): - pygame.init() super().__init__( pygame.display.set_mode((0, 0), pygame.FULLSCREEN), pygame.font.Font(None, size=96), diff --git a/bookpaint/menu.py b/bookpaint/menu.py index f471025..592a414 100644 --- a/bookpaint/menu.py +++ b/bookpaint/menu.py @@ -7,9 +7,9 @@ from ui import Button, Modal class Menu(Modal): def __init__(self, parent): - from .bookpaint import BookPaint + from .bookpaint import Root as BookPaintRoot - self.parent: BookPaint + self.parent: BookPaintRoot super().__init__(parent) self.suspended = False size = self.surf.get_size() diff --git a/connect_four.py b/connect_four.py index 92f4a29..7b2f426 100755 --- a/connect_four.py +++ b/connect_four.py @@ -4,8 +4,8 @@ from functools import partial from math import pi, sqrt from time import time -from launch import run, pygame -from ui import Button, MessageBox, Rect, Root +from launch import pygame +from ui import BaseRoot, Button, MessageBox, Rect from vectors import StrokeCircleSegment @@ -45,7 +45,7 @@ class ColorButton(Button): self.draw_value(colors[2]) -class ConnectFour(Root): +class Root(BaseRoot): FIELD_SIZE = (7, 6) FRAME_COLOR = "blue" PLAYER_COLORS = ("red", "green") @@ -54,7 +54,6 @@ class ConnectFour(Root): PULL_TIMEOUT = 1 def __init__(self): - pygame.init() super().__init__( pygame.display.set_mode((0, 0), pygame.FULLSCREEN, display=0), pygame.font.Font(None, size=96), @@ -284,4 +283,4 @@ class ConnectFour(Root): if __name__ == "__main__": - run() + Root().run() diff --git a/launch.py b/launch.py index 01a7c8e..7156e30 100755 --- a/launch.py +++ b/launch.py @@ -1,93 +1,199 @@ #!/usr/bin/env python3 import os -import shlex import sys from argparse import ArgumentParser -from contextlib import redirect_stdout +from contextlib import contextmanager, redirect_stdout +from http.server import BaseHTTPRequestHandler, HTTPServer from importlib import import_module -from io import StringIO from pathlib import Path -from shutil import rmtree +from queue import SimpleQueue +from shutil import which +from subprocess import PIPE, run +from threading import Thread from time import time +from traceback import format_exception +executable = Path(sys.executable).name -def setup_venv(): - venv_dir = (Path(__file__).parent / "venv").absolute() - install_venv = True - if venv_dir.is_dir(): - for p in ( - venv_dir, - *( - base / ent - for base, dirs, files in venv_dir.walk() - for ent in (*dirs, *files) - ) - ): - if p.stat().st_mtime >= time() - 86400: - install_venv = False - break + +def tty_connected(): + """ + Check if there is a terminal attached to this process. + + No need to mimic ps' major and minor device number decoding. + """ + with open("/proc/self/stat", "rb") as fh: + return int(next(fh).split(b") ")[1].split(b" ", 5)[4]) != 0 + + +class MessageRequestHandler(BaseHTTPRequestHandler): + queue = SimpleQueue() + msg_bytes: bytes + + def do_GET(self): + self.send_response(200) + self.send_header("Content-Length", str(len(self.msg_bytes))) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(self.msg_bytes) + thread = Thread(target=self.server.shutdown) + thread.start() + self.queue.put(thread) + + def log_message(self, *args): + pass + + +@contextmanager +def exception_wrapper(): + try: + yield + except Exception as exc: + if tty_connected() or not which("xdg-open"): + raise + MessageRequestHandler.msg_bytes = "".join(format_exception(exc)).encode() else: - if venv_dir.exists(): - rmtree(venv_dir) - assert not venv_dir.exists() - os.system(shlex.join((sys.executable, "-m", "venv", str(venv_dir)))) + return + s = HTTPServer(("127.0.0.1", 0), MessageRequestHandler) + run(("xdg-open", f"http://{':'.join(str(s) for s in s.socket.getsockname())}/")) + s.serve_forever() + MessageRequestHandler.queue.get().join() + + +def run_cmd(cmd, expect_returncode=0): + cp = run(cmd, stdout=PIPE, stderr=PIPE) + if cp.returncode == expect_returncode: + return cp + lines = [f"Subprocess {cmd} failed with status {cp.returncode}"] + for attr in ("stdout", "stderr"): + output = getattr(cp, attr) + if output: + lines.extend(("", f"{attr}:", output.decode(errors="backslashreplace"))) + raise ChildProcessError(os.linesep.join(lines)) + + +def check_has_pygame(): + return b"No module named pygame.__main__;" in run_cmd( + (executable, "-m", "pygame"), 1 + ).stderr + + +def get_ipv4_is_connected(): + """Read the default gateway directly from /proc.""" + with open("/proc/net/route") as fh: + for line in fh: + fields = line.rstrip().split("\t") + if fields[1] == '00000000' and int(fields[3], 16) & 2: + return True + return False + + +def walk_outer(path): + return ( + path, + *( + base / ent + for base, dirs, files in path.walk() + for ent in (*dirs, *files) + ) + ) + + +def get_venv_environ(venv_dir): new_env = {"VIRTUAL_ENV": str(venv_dir)} venv_bin_str = str(venv_dir / "bin") if venv_bin_str not in os.environ['PATH'].split(os.pathsep): new_env["PATH"] = f"{venv_bin_str}{os.pathsep}{os.environ['PATH']}" - os.environ.update(new_env) - if install_venv: - os.system( - shlex.join( - ( - Path(sys.executable).name, - "-m", - "pip", - "install", - "-qU", - "pip", - "pygame", - ) + return new_env + + +def setup_venv(): + """ + import pygame failed, but this could just be the missing + VIRTUAL_ENV environment varirable. + Load the venv and check again. + """ + venv_dir = (Path(__file__).parent / "venv").absolute() + venv_dir_is_dir = venv_dir.is_dir() + if not venv_dir_is_dir: + if venv_dir.exists(): + raise SystemError("'venv' is not a directory.") + run_cmd((sys.executable, "-m", "venv", str(venv_dir))) + os.environ.update(get_venv_environ(venv_dir)) + has_pygame = venv_dir_is_dir and check_has_pygame() + if get_ipv4_is_connected(): + yesterday = time() - 86400 + if not has_pygame or all( + p.stat().st_mtime < yesterday for p in walk_outer(venv_dir) + ): + run_cmd((executable, "-m", "pip", "install", "-qU", "pip", "pygame")) + venv_dir.touch(0o755, True) + elif not has_pygame: + raise ConnectionError("Internet needed to install requirement: pygame") + + +def pre_run(): + pg = "pygame" + if pg in sys.modules: + return sys.modules[pg] + try: + with redirect_stdout(None): + pygame = import_module(pg) + except ImportError: + with exception_wrapper(): + setup_venv() + os.execl( + sys.executable, + executable, + *sys.orig_argv[sys.orig_argv[0] == executable:], ) - ) - venv_dir.touch(0o755, True) - os.execl(sys.executable, Path(sys.executable).name, *sys.argv) - exit(1) + exit(1) + pygame.init() + return pygame + +def resolve_symlink(path): + path = path.absolute() + if not path.is_symlink(): + return path + dest = path.readlink() + if dest.is_absolute(): + return dest + return path.parent / dest -def find_root(module_name, module_globals): - from ui import Root - for key, value in module_globals.items(): - if key.startswith("__") and key.endswith("__"): - continue - if value is not Root and isinstance(value, type) and issubclass(value, Root): - value().run() - break +def find_module(module_name): + file_path = resolve_symlink(Path(__file__).absolute()) + module_name_py = f"{module_name}.py" + dest = file_path.parent / module_name_py + if dest.exists(): + if resolve_symlink(dest) != file_path: + return module_name + assert (file_path.parent / module_name / module_name_py).exists() + return ".".join((module_name, module_name)) + + +def try_run(module_name): + module = import_module(module_name) + if hasattr(module, "Root"): + module.Root().run() else: - raise ValueError(f"Module not found: {module_name}") - return + module.main() def main(): + ap = ArgumentParser() + ap.add_argument("--module", "-m") + module_name = find_module(Path(ap.parse_args().module or sys.argv[0]).stem) try: - with redirect_stdout(StringIO()): - # ruff: noqa: F401 - import pygame # type: ignore - except ImportError: - setup_venv() + try_run(module_name) + except (ImportError, AttributeError): raise + try_run(".".join((module_name, module_name))) - ap = ArgumentParser() - ap.add_argument("--module", "-m") - args = ap.parse_args() - module_name = Path(args.module or sys.argv[0]).stem - find_root( - module_name, - import_module(f"{module_name}.{module_name}", module_name).__dict__, - ) +pygame = pre_run() if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/memory.py b/memory.py index 129c98f..1d5ba17 100755 --- a/memory.py +++ b/memory.py @@ -4,8 +4,8 @@ from functools import partial from pathlib import Path from secrets import choice -from launch import run, pygame -from ui import Button, Child, Label, Modal, Root, TextInput +from launch import pygame +from ui import BaseRoot, Button, Child, Label, Modal, TextInput class QuittableModal(Modal): @@ -196,7 +196,7 @@ class MemoryCard(Child): self.parent.check_turn() -class MemoryGame(Root): +class Root(BaseRoot): BACKGROUND_COLOR = "gray34" def __init__(self): @@ -304,4 +304,4 @@ class MemoryGame(Root): if __name__ == "__main__": - run() + Root().run() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0f13c09 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +evdev==1.9.2 +pygame==2.6.1 diff --git a/rps/rps.py b/rps/rps.py index 34edc10..90596f9 100644 --- a/rps/rps.py +++ b/rps/rps.py @@ -1,9 +1,8 @@ from argparse import ArgumentParser from functools import partial -import pygame - -from ui import Button, Root +from launch import pygame +from ui import BaseRoot, Button from .rps_button import RPSButton @@ -38,7 +37,7 @@ class FingerButton(Button): self.dirty = True -class RockPaperScissors(Root): +class Root(BaseRoot): BACKGROUND_COLOR = "black" LABELS = RPSButton.LABELS @@ -46,7 +45,6 @@ class RockPaperScissors(Root): ap = ArgumentParser() ap.add_argument("--display", type=int, default=0) args = ap.parse_args() - pygame.init() num_displays = len(pygame.display.get_desktop_sizes()) if args.display < -num_displays or args.display >= num_displays: raise ValueError(f"Invalid display: {args.display}") diff --git a/ui/__init__.py b/ui/__init__.py index 824209f..e422676 100644 --- a/ui/__init__.py +++ b/ui/__init__.py @@ -11,7 +11,7 @@ from .message_box import MessageBox from .modal import Modal from .parent import Parent from .rect import Rect -from .root import Root +from .root import BaseRoot from .scroll import Scroll from .slider import Slider from .spinner import FloatSpinner, RepeatButton, Spinner @@ -20,6 +20,7 @@ from .tab_bar import TabBar from .text_input import TextInput __all__ = [ + "BaseRoot", "Button", "Child", "ColorButton", @@ -35,7 +36,6 @@ __all__ = [ "Parent", "Rect", "RepeatButton", - "Root", "Scroll", "Slider", "Spinner", diff --git a/ui/child.py b/ui/child.py index d197021..d0f8f04 100644 --- a/ui/child.py +++ b/ui/child.py @@ -1,7 +1,7 @@ from functools import cached_property from .event_method_dispatcher import EventMethodDispatcher -from .root import Root +from .root import BaseRoot class Child(EventMethodDispatcher): @@ -16,7 +16,7 @@ class Child(EventMethodDispatcher): parent = self.parent while hasattr(parent, "parent"): parent = parent.root or parent.parent - if not isinstance(parent, Root): + if not isinstance(parent, BaseRoot): raise AttributeError(f"No root found for {self}") return parent diff --git a/ui/focus.py b/ui/focus.py index 2d3c609..1432a38 100644 --- a/ui/focus.py +++ b/ui/focus.py @@ -1,12 +1,12 @@ -from .root import Root +from .root import BaseRoot class Focusable: - root: Root + root: BaseRoot dirty: bool @property - def focused(self): + def is_focused(self): return self.root.focus_stack[-1] is self def activate(self): @@ -15,6 +15,6 @@ class Focusable: self.dirty = True def deactivate(self): - assert self.focused + assert self.is_focused self.root.focus_stack.pop() self.dirty = True diff --git a/ui/multitouch.py b/ui/multitouch.py new file mode 100755 index 0000000..5336cc9 --- /dev/null +++ b/ui/multitouch.py @@ -0,0 +1,86 @@ +from evdev import InputDevice, ecodes, list_devices + + +class MultitouchHandler: + @staticmethod + def find_device(**kwargs): + for path in list_devices(): + device = InputDevice(path) + for k, v in kwargs.items(): + if getattr(device, k) != v: + break + else: + return device + return None + + @staticmethod + def multitouch_ranges(device): + return { + v: (info.min, info.max) + for v, info in ( + (v, device.absinfo(v)) + for v in ( + ecodes.ABS_X, + ecodes.ABS_Y, + ecodes.ABS_MT_POSITION_X, + ecodes.ABS_MT_POSITION_Y, + ) + ) + } + + def __init__(self, **kwargs): + self.device = self.find_device(**kwargs) + self.info = self.multitouch_ranges(self.device) + self.context_app = kwargs.get("context_app") + self.slots = [{}] + self.slot = 0 + + def current_slot(self): + while self.slot >= len(self.slots): + self.slots.append({}) + return self.slots[self.slot] + + def handle_event(self, event): + if event.type == ecodes.EV_SYN: + current_slot = self.current_slot() + if current_slot.get(ecodes.ABS_MT_TRACKING_ID) == -1: + current_slot.clear() + elif event.type == ecodes.EV_ABS: + if event.code == ecodes.ABS_MT_SLOT: + self.slot = event.value + else: + self.current_slot()[event.code] = event.value + elif event.type == ecodes.EV_MSC: + self.current_slot()[event.code] = event.value + elif event.type == ecodes.EV_KEY: + if event.code == ecodes.BTN_TOUCH: + if event.value == 0: + for s in self.slots: + s.clear() + + def handle_events(self): + while (event := self.device.read_one()) is not None: + self.handle_event(event) + + def map_coords(self, rect): + for slot in self.slots: + if ecodes.ABS_MT_POSITION_X in slot and ecodes.ABS_MT_POSITION_Y in slot: + infos = ( + self.info[ecodes.ABS_MT_POSITION_X], + self.info[ecodes.ABS_MT_POSITION_Y], + ) + pos = (slot[ecodes.ABS_MT_POSITION_X], slot[ecodes.ABS_MT_POSITION_Y]) + elif ecodes.ABS_X in slot and ecodes.ABS_Y in slot: + infos = (self.info[ecodes.ABS_X], self.info[ecodes.ABS_Y]) + pos = (slot[ecodes.ABS_X], slot[ecodes.ABS_Y]) + else: + continue + yield tuple( + offset + (value - info[0]) * scale / (info[1] - info[0]) + for value, info, offset, scale in zip( + pos, + infos, + rect.topleft, + rect.size, + ) + ), slot diff --git a/ui/root.py b/ui/root.py index d561d84..3726482 100644 --- a/ui/root.py +++ b/ui/root.py @@ -3,15 +3,22 @@ import pygame from .parent import Parent -class Root(Parent): +class BaseRoot(Parent): BACKGROUND_COLOR: pygame.Color + ACTIVATE_EVENTS = ( + pygame.WINDOWENTER, + pygame.WINDOWEXPOSED, + pygame.WINDOWFOCUSGAINED, + pygame.WINDOWMAXIMIZED, + ) + FPS = 120 def __init__(self, surf, font=None): super().__init__() + self.surf = surf self.font = font self.running = True - self.dirty = False - self.surf = surf + self.dirty = True self.clock = pygame.time.Clock() self.stop_event = False self.root = self @@ -23,11 +30,11 @@ class Root(Parent): key_methods = {frozenset(): {pygame.K_ESCAPE: handle_quit}} @property - def focused(self): + def is_focused(self): return self.focus_stack[-1] is self def handle_event(self, ev): - if ev.type in (pygame.WINDOWEXPOSED, pygame.ACTIVEEVENT): + if ev.type in self.ACTIVATE_EVENTS: self.dirty = True return focused = self.focus_stack[-1] @@ -50,12 +57,10 @@ class Root(Parent): self.stop_event = False self.handle_event(ev) if not self.running: - break - if not self.running: - break + return self.update() if self.dirty: self.draw() pygame.display.update() self.dirty = False - self.clock.tick(60) + self.clock.tick(self.FPS) diff --git a/ui/spinner.py b/ui/spinner.py index 726b254..962513b 100644 --- a/ui/spinner.py +++ b/ui/spinner.py @@ -155,7 +155,7 @@ class FloatSpinner(Spinner): return if float_value == self.value: return - self.value = float_value + self._value = float_value str_value = str(float_value) if str_value != self.text_input.value: self.text_input.value = str_value diff --git a/ui/text_input.py b/ui/text_input.py index 325ca1e..60d0ff7 100644 --- a/ui/text_input.py +++ b/ui/text_input.py @@ -156,7 +156,7 @@ class TextInput(Focusable, Child): def draw(self): pygame.draw.rect(self.surf, "black", self.rect) - if self.focused: + if self.is_focused: fs = self.get_focused_font_surface() else: fs = self.get_unfocused_font_surface() @@ -196,7 +196,7 @@ class TextInput(Focusable, Child): self.cursor.pos = self.pos_from_offset( ev.pos[0] - self.padded_rect.left + self.offset ) - elif self.focused: + elif self.is_focused: self.deactivate(True) @contextmanager @@ -257,13 +257,13 @@ class TextInput(Focusable, Child): self.cursor.pos = pos def activate(self): - if self.focused: + if self.is_focused: return super().activate() self.cursor = Cursor(self.value) def deactivate(self, restore=False): - if not self.focused: + if not self.is_focused: return if restore: self.value = self.cursor.old_value @@ -291,7 +291,7 @@ class TextInput(Focusable, Child): } def handle_keydown(self, ev): - if not self.focused: + if not self.is_focused: return if (key_method := self.get_key_method(ev.key, ev.mod)) is not None: key_callback = key_method @@ -301,7 +301,7 @@ class TextInput(Focusable, Child): return with self.check_dirty(): key_callback() - if self.focused: + if self.is_focused: self.cursor.press_key(key_callback, ev.key) def handle_keyup(self, ev): diff --git a/vs_memory.py b/vs_memory.py index 8f88a10..bcb5c66 100755 --- a/vs_memory.py +++ b/vs_memory.py @@ -4,8 +4,8 @@ from pathlib import Path from secrets import choice, randbelow from time import time -from launch import run, pygame -from ui import Button, Child, FloatSpinner, Label, Root, Spinner +from launch import pygame +from ui import BaseRoot, Button, Child, FloatSpinner, Label, Spinner class Team: @@ -76,7 +76,7 @@ class MemoryCard(Child): self.surf.blit(self.team.logo, self.rect.topleft) -class VSMemory(Root): +class Root(BaseRoot): BACKGROUND_COLOR = "black" TOP = 186 LEFT = 256 @@ -98,7 +98,6 @@ class VSMemory(Root): return f"Score: {self.failed} failed, {self.successful} successful" def __init__(self): - pygame.init() super().__init__( pygame.display.set_mode((0, 0), pygame.FULLSCREEN), pygame.font.Font(None, size=96), @@ -106,11 +105,11 @@ class VSMemory(Root): self.teams = tuple( Team(file) for file in (Path(__file__).parent / "logos").iterdir() - if file.stem != "National League" + if file.stem not in ("National League", "Challenge League") ) len_teams = len(self.teams) # fits one column of memory destinations (todo) - assert len_teams < 15 and len_teams % 2 == 0 + #assert len_teams < 15 and len_teams % 2 == 0, len_teams self.num_pairs = len_teams // 2 self.failed = 0 self.successful = 0 @@ -262,4 +261,4 @@ class VSMemory(Root): if __name__ == "__main__": - run() + Root().run() diff --git a/zenbook_conf/zenbook_conf.py b/zenbook_conf/zenbook_conf.py index 4d776f3..16171ec 100644 --- a/zenbook_conf/zenbook_conf.py +++ b/zenbook_conf/zenbook_conf.py @@ -1,9 +1,9 @@ from functools import partial -import pygame +from launch import pygame +from ui import BaseRoot, Button, FPSWidget, Icon, IconButton, Switch from .bluetooth import BluetoothConf -from ui import Button, FPSWidget, Icon, IconButton, Root, Switch from .shapes import ( bluetooth, laptop_double, @@ -16,11 +16,10 @@ from .xinput import XinputConf from .xrandr import XrandrConf -class ZenbookConf(Root): +class Root(BaseRoot): BACKGROUND_COLOR = 0x333333 def __init__(self): - pygame.init() pygame.display.set_icon(self.get_icon()) pygame.display.set_caption("Zenbook Config") window_size = (1536, 1200) -- 2.51.0