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
from .page_menu import PageMenu
-class BookPaint(Root):
+class Root(BaseRoot):
BACKGROUND_COLOR = "black"
def setup_deactivate_keys(self):
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),
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()
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
self.draw_value(colors[2])
-class ConnectFour(Root):
+class Root(BaseRoot):
FIELD_SIZE = (7, 6)
FRAME_COLOR = "blue"
PLAYER_COLORS = ("red", "green")
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),
if __name__ == "__main__":
- run()
+ Root().run()
#!/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()
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):
self.parent.check_turn()
-class MemoryGame(Root):
+class Root(BaseRoot):
BACKGROUND_COLOR = "gray34"
def __init__(self):
if __name__ == "__main__":
- run()
+ Root().run()
--- /dev/null
+evdev==1.9.2
+pygame==2.6.1
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
self.dirty = True
-class RockPaperScissors(Root):
+class Root(BaseRoot):
BACKGROUND_COLOR = "black"
LABELS = RPSButton.LABELS
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}")
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
from .text_input import TextInput
__all__ = [
+ "BaseRoot",
"Button",
"Child",
"ColorButton",
"Parent",
"Rect",
"RepeatButton",
- "Root",
"Scroll",
"Slider",
"Spinner",
from functools import cached_property
from .event_method_dispatcher import EventMethodDispatcher
-from .root import Root
+from .root import BaseRoot
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
-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):
self.dirty = True
def deactivate(self):
- assert self.focused
+ assert self.is_focused
self.root.focus_stack.pop()
self.dirty = True
--- /dev/null
+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
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
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]
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)
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
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()
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
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
}
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
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):
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:
self.surf.blit(self.team.logo, self.rect.topleft)
-class VSMemory(Root):
+class Root(BaseRoot):
BACKGROUND_COLOR = "black"
TOP = 186
LEFT = 256
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),
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
if __name__ == "__main__":
- run()
+ Root().run()
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,
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)