diff options
| author | kj_sh604 | 2026-06-06 20:18:22 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-06-06 20:18:22 -0400 |
| commit | fe96f35f1e6a101719dbd13b58084881cf5f58b4 (patch) | |
| tree | d0607c7a7a685ae1a40c6e9d367e3417870b98f9 | |
| parent | 13327a7acd226d7806257aa6a8fc7d87a8836b33 (diff) | |
refactor: complete re-implementation of pboomer for performance
| -rwxr-xr-x | pboomer | 2329 |
1 files changed, 1233 insertions, 1096 deletions
@@ -1,1180 +1,1317 @@ #!/usr/bin/env python3 -from __future__ import annotations -import dataclasses -import ctypes +from __future__ import annotations +import ctypes as ct +from ctypes import ( + c_int, c_uint, c_ulong, c_long, c_short, c_char, c_char_p, + c_void_p, c_float, c_ubyte, + POINTER, byref, cast, CFUNCTYPE, +) import math import os -from pathlib import Path -import subprocess import sys import time +VERSION = "20260605" -USAGE = """usage: boomer [OPTIONS] +USAGE = """usage: pboomer [OPTIONS] -d, --delay <seconds: float> delay execution of the program by provided <seconds> -h, --help show this help and exit --new-config [filepath] generate a new default config at [filepath] -c, --config <filepath> use config at <filepath> -V, --version show the current version and exit - -w, --windowed windowed mode instead of fullscreen - - Syntax for short flags: -h / -d:<value> / -d<value>""" - -INITIAL_FL_DELTA_RADIUS = 250.0 -FL_DELTA_RADIUS_DECELERATION = 10.0 -VELOCITY_THRESHOLD = 15.0 - -# x11 keysym constants -XK_ESCAPE = 0xFF1B -XK_MINUS = 0x002D -XK_EQUAL = 0x003D -XK_0 = 0x0030 -XK_Q = 0x0051 -XK_q = 0x0071 -XK_R = 0x0052 -XK_r = 0x0072 -XK_F = 0x0046 -XK_f = 0x0066 -XK_KP_ADD = 0xFFAB -XK_KP_SUBTRACT = 0xFFAD -XK_CONTROL_L = 0xFFE3 -XK_CONTROL_R = 0xFFE4 -CONTROL_KEYSYMS = {XK_CONTROL_L, XK_CONTROL_R} - -# shader sources -VERTEX_SHADER = """#version 130 -in vec3 aPos; -in vec2 aTexCoord; -out vec2 texcoord; - -uniform vec2 cameraPos; -uniform float cameraScale; -uniform vec2 windowSize; -uniform vec2 screenshotSize; + -w, --windowed windowed mode instead of fullscreen""" + + +# X11 / GL CONSTANTS + +# event types +KeyPress = 2 +KeyRelease = 3 +ButtonPress = 4 +ButtonRelease = 5 +MotionNotify = 6 +ClientMessage = 33 + +# event masks +KeyPressMask = 1 << 0 +KeyReleaseMask = 1 << 1 +ButtonPressMask = 1 << 2 +ButtonReleaseMask = 1 << 3 +PointerMotionMask = 1 << 6 +ExposureMask = 1 << 15 +CWColormap = 1 << 13 +CWEventMask = 1 << 11 +CWOverrideRedirect = 1 << 9 +CWSaveUnder = 1 << 10 + +# x11 misc +ZPixmap = 2 +AllPlanes = 0xFFFFFFFF +RevertToParent = 2 +CurrentTime = 0 +AllocNone = 0 +InputOutput = 1 +False_ = 0 +True_ = 1 + +# glx attrs +GLX_RGBA = 4 +GLX_DEPTH_SIZE = 12 +GLX_DOUBLEBUFFER = 5 + +# keysyms +XK_Escape = 0xFF1B +XK_q = 0x0071 +XK_r = 0x0072 +XK_f = 0x0066 +XK_m = 0x006D +XK_0 = 0x0030 +XK_equal = 0x003D +XK_minus = 0x002D +XK_ControlMask = 0x04 +XK_Button1 = 1 +XK_Button4 = 4 +XK_Button5 = 5 + +# opengl constants +GL_COLOR_BUFFER_BIT = 0x00004000 +GL_DEPTH_BUFFER_BIT = 0x00000100 +GL_TEXTURE_2D = 0x0DE1 +GL_RGB = 0x1907 +GL_BGRA = 0x80E1 +GL_UNSIGNED_BYTE = 0x1401 +GL_NEAREST = 0x2600 +GL_LINEAR = 0x2601 +GL_CLAMP_TO_BORDER = 0x812D +GL_TEXTURE_MIN_FILTER = 0x2801 +GL_TEXTURE_MAG_FILTER = 0x2800 +GL_TEXTURE_WRAP_S = 0x2802 +GL_TEXTURE_WRAP_T = 0x2803 +GL_ARRAY_BUFFER = 0x8892 +GL_ELEMENT_ARRAY_BUFFER = 0x8893 +GL_STATIC_DRAW = 0x88E4 +GL_FLOAT = 0x1406 +GL_VERTEX_SHADER = 0x8B31 +GL_FRAGMENT_SHADER = 0x8B30 +GL_COMPILE_STATUS = 0x8B81 +GL_LINK_STATUS = 0x8B82 +GL_TEXTURE0 = 0x84C0 +GL_TRIANGLES = 0x0004 +GL_UNSIGNED_INT = 0x1405 + + +# CTYPES STRUCT DEFINITIONS + +class XVisualInfo(ct.Structure): + _fields_ = [ + ("visual", c_void_p), + ("visualid", c_ulong), + ("screen", c_int), + ("depth", c_int), + ("class", c_int), + ("red_mask", c_ulong), + ("green_mask", c_ulong), + ("blue_mask", c_ulong), + ("colormap_size", c_int), + ("bits_per_rgb", c_int), + ] + + +class XSetWindowAttributes(ct.Structure): + _fields_ = [ + ("background_pixmap", c_ulong), + ("background_pixel", c_ulong), + ("border_pixmap", c_ulong), + ("border_pixel", c_ulong), + ("bit_gravity", c_int), + ("win_gravity", c_int), + ("backing_store", c_int), + ("backing_planes", c_ulong), + ("backing_pixel", c_ulong), + ("save_under", c_int), + ("event_mask", c_long), + ("do_not_propagate_mask", c_long), + ("override_redirect", c_int), + ("colormap", c_ulong), + ("cursor", c_ulong), + ] + + +class XWindowAttributes(ct.Structure): + _fields_ = [ + ("x", c_int), + ("y", c_int), + ("width", c_int), + ("height", c_int), + ("border_width", c_int), + ("depth", c_int), + ("visual", c_void_p), + ("root", c_ulong), + ("class", c_int), + ("bit_gravity", c_int), + ("win_gravity", c_int), + ("backing_store", c_int), + ("backing_planes", c_ulong), + ("backing_pixel", c_ulong), + ("save_under", c_int), + ("colormap", c_ulong), + ("map_installed", c_int), + ("map_state", c_int), + ("all_event_masks", c_long), + ("your_event_mask", c_long), + ("do_not_propagate_mask", c_long), + ("override_redirect", c_int), + ("screen", c_void_p), + ] + + +class XImage(ct.Structure): + _fields_ = [ + ("width", c_int), + ("height", c_int), + ("xoffset", c_int), + ("format", c_int), + ("data", c_void_p), + ("byte_order", c_int), + ("bitmap_unit", c_int), + ("bitmap_bit_order", c_int), + ("bitmap_pad", c_int), + ("depth", c_int), + ("bytes_per_line", c_int), + ("bits_per_pixel", c_int), + ] + + +class XClassHint(ct.Structure): + _fields_ = [ + ("res_name", c_char_p), + ("res_class", c_char_p), + ] + + +class XKeyEvent(ct.Structure): + _fields_ = [ + ("type", c_int), + ("serial", c_ulong), + ("send_event", c_int), + ("display", c_void_p), + ("window", c_ulong), + ("root", c_ulong), + ("subwindow", c_ulong), + ("time", c_ulong), + ("x", c_int), + ("y", c_int), + ("x_root", c_int), + ("y_root", c_int), + ("state", c_uint), + ("keycode", c_uint), + ("same_screen", c_int), + ] + + +class XButtonEvent(ct.Structure): + _fields_ = [ + ("type", c_int), + ("serial", c_ulong), + ("send_event", c_int), + ("display", c_void_p), + ("window", c_ulong), + ("root", c_ulong), + ("subwindow", c_ulong), + ("time", c_ulong), + ("x", c_int), + ("y", c_int), + ("x_root", c_int), + ("y_root", c_int), + ("state", c_uint), + ("button", c_uint), + ("same_screen", c_int), + ] + + +class XMotionEvent(ct.Structure): + _fields_ = [ + ("type", c_int), + ("serial", c_ulong), + ("send_event", c_int), + ("display", c_void_p), + ("window", c_ulong), + ("root", c_ulong), + ("subwindow", c_ulong), + ("time", c_ulong), + ("x", c_int), + ("y", c_int), + ("x_root", c_int), + ("y_root", c_int), + ("state", c_uint), + ("is_hint", c_char), + ("same_screen", c_int), + ] + + +class _ClientData(ct.Union): + _fields_ = [ + ("b", c_char * 20), + ("s", c_short * 10), + ("l", c_long * 5), + ] + + +class XClientMessageEvent(ct.Structure): + _fields_ = [ + ("type", c_int), + ("serial", c_ulong), + ("send_event", c_int), + ("display", c_void_p), + ("window", c_ulong), + ("message_type", c_ulong), + ("format", c_int), + ("data", _ClientData), + ] + + +class XErrorEvent(ct.Structure): + _fields_ = [ + ("type", c_int), + ("display", c_void_p), + ("resourceid", c_ulong), + ("serial", c_ulong), + ("error_code", c_ubyte), + ("request_code", c_ubyte), + ("minor_code", c_ubyte), + ] + + +class XEvent(ct.Union): + _fields_ = [ + ("type", c_int), + ("xkey", XKeyEvent), + ("xbutton", XButtonEvent), + ("xmotion", XMotionEvent), + ("xclient", XClientMessageEvent), + ("_pad", c_long * 24), + ] + + +# SHARED LIBRARY LOADING + +_x11 = ct.CDLL("libX11.so.6") +_xr = ct.CDLL("libXrandr.so.2") +_libgl = ct.CDLL("libGL.so.1") + + +# X11 / XRANDR FUNCTION TYPING + +_x11.XOpenDisplay.argtypes = [c_char_p] +_x11.XOpenDisplay.restype = c_void_p + +_x11.XCloseDisplay.argtypes = [c_void_p] +_x11.XCloseDisplay.restype = c_int + +_x11.XDefaultRootWindow.argtypes = [c_void_p] +_x11.XDefaultRootWindow.restype = c_ulong + +_x11.XGetWindowAttributes.argtypes = [c_void_p, c_ulong, POINTER(XWindowAttributes)] +_x11.XGetWindowAttributes.restype = c_int + +_x11.XCreateColormap.argtypes = [c_void_p, c_ulong, c_void_p, c_int] +_x11.XCreateColormap.restype = c_ulong -vec3 to_world(vec3 v) { - vec2 ratio = vec2( - windowSize.x / screenshotSize.x / cameraScale, - windowSize.y / screenshotSize.y / cameraScale); - return vec3((v.x / screenshotSize.x * 2.0 - 1.0) / ratio.x, - (v.y / screenshotSize.y * 2.0 - 1.0) / ratio.y, - v.z); -} +_x11.XCreateWindow.argtypes = [ + c_void_p, c_ulong, c_int, c_int, + c_uint, c_uint, c_uint, c_int, + c_uint, c_void_p, c_ulong, + POINTER(XSetWindowAttributes), +] +_x11.XCreateWindow.restype = c_ulong -void main() -{ - gl_Position = vec4(to_world((aPos - vec3(cameraPos * vec2(1.0, -1.0), 0.0))), 1.0); - texcoord = aTexCoord; -} -""" +_x11.XMapWindow.argtypes = [c_void_p, c_ulong] +_x11.XMapWindow.restype = c_int -FRAGMENT_SHADER = """#version 130 -out vec4 color; -in vec2 texcoord; -uniform sampler2D tex; -uniform vec2 cursorPos; -uniform vec2 windowSize; -uniform float flShadow; -uniform float flRadius; -uniform float cameraScale; - -void main() -{ - vec4 cursor = vec4(cursorPos.x, windowSize.y - cursorPos.y, 0.0, 1.0); - color = mix( - texture(tex, texcoord), vec4(0.0, 0.0, 0.0, 0.0), - length(cursor - gl_FragCoord) < (flRadius * cameraScale) ? 0.0 : flShadow); -} -""" +_x11.XDestroyWindow.argtypes = [c_void_p, c_ulong] +_x11.XDestroyWindow.restype = c_int +_x11.XStoreName.argtypes = [c_void_p, c_ulong, c_char_p] +_x11.XStoreName.restype = c_int -class UsageError(Exception): - pass +_x11.XSetClassHint.argtypes = [c_void_p, c_ulong, POINTER(XClassHint)] +_x11.XSetClassHint.restype = c_int +_x11.XInternAtom.argtypes = [c_void_p, c_char_p, c_int] +_x11.XInternAtom.restype = c_ulong -# config -@dataclasses.dataclass -class Config: - min_scale: float = 0.01 - scroll_speed: float = 1.5 - drag_friction: float = 6.0 - scale_friction: float = 4.0 +_x11.XSetWMProtocols.argtypes = [c_void_p, c_ulong, POINTER(c_ulong), c_int] +_x11.XSetWMProtocols.restype = c_int - def __str__(self) -> str: - return ( - "(min_scale: " - f"{self.min_scale}, " - "scroll_speed: " - f"{self.scroll_speed}, " - "drag_friction: " - f"{self.drag_friction}, " - "scale_friction: " - f"{self.scale_friction})" - ) +_x11.XPending.argtypes = [c_void_p] +_x11.XPending.restype = c_int +_x11.XNextEvent.argtypes = [c_void_p, POINTER(XEvent)] +_x11.XNextEvent.restype = c_int -@dataclasses.dataclass -class Vec2: - x: float - y: float +_x11.XQueryPointer.argtypes = [ + c_void_p, c_ulong, + POINTER(c_ulong), POINTER(c_ulong), + POINTER(c_int), POINTER(c_int), + POINTER(c_int), POINTER(c_int), + POINTER(c_uint), +] +_x11.XQueryPointer.restype = c_int - def copy(self) -> "Vec2": - return Vec2(self.x, self.y) +_x11.XGetInputFocus.argtypes = [c_void_p, POINTER(c_ulong), POINTER(c_int)] +_x11.XGetInputFocus.restype = c_int - def __add__(self, other: "Vec2") -> "Vec2": - return Vec2(self.x + other.x, self.y + other.y) +_x11.XSetInputFocus.argtypes = [c_void_p, c_ulong, c_int, c_ulong] +_x11.XSetInputFocus.restype = c_int - def __sub__(self, other: "Vec2") -> "Vec2": - return Vec2(self.x - other.x, self.y - other.y) +_x11.XSync.argtypes = [c_void_p, c_int] +_x11.XSync.restype = c_int - def __mul__(self, value: float) -> "Vec2": - return Vec2(self.x * value, self.y * value) +_x11.XGetImage.argtypes = [ + c_void_p, c_ulong, c_int, c_int, + c_uint, c_uint, c_ulong, c_int, +] +_x11.XGetImage.restype = POINTER(XImage) - def __truediv__(self, value: float) -> "Vec2": - return Vec2(self.x / value, self.y / value) +_x11.XDestroyImage.argtypes = [POINTER(XImage)] +_x11.XDestroyImage.restype = c_int - def length(self) -> float: - return math.sqrt(self.x * self.x + self.y * self.y) +_x11.XLookupKeysym.argtypes = [POINTER(XKeyEvent), c_int] +_x11.XLookupKeysym.restype = c_ulong +_x11.XFree.argtypes = [c_void_p] +_x11.XFree.restype = c_int -@dataclasses.dataclass -class Mouse: - curr: Vec2 - prev: Vec2 - drag: bool = False +_x11.XGetErrorText.argtypes = [c_void_p, c_int, c_char_p, c_int] +_x11.XGetErrorText.restype = c_int +_xr.XRRGetScreenInfo.argtypes = [c_void_p, c_ulong] +_xr.XRRGetScreenInfo.restype = c_void_p -@dataclasses.dataclass -class Camera: - position: Vec2 = dataclasses.field(default_factory=lambda: Vec2(0.0, 0.0)) - velocity: Vec2 = dataclasses.field(default_factory=lambda: Vec2(0.0, 0.0)) - scale: float = 1.0 - delta_scale: float = 0.0 - scale_pivot: Vec2 = dataclasses.field(default_factory=lambda: Vec2(0.0, 0.0)) +_xr.XRRConfigCurrentRate.argtypes = [c_void_p] +_xr.XRRConfigCurrentRate.restype = c_int +_xr.XRRFreeScreenConfigInfo.argtypes = [c_void_p] +_xr.XRRFreeScreenConfigInfo.restype = None -@dataclasses.dataclass -class Flashlight: - is_enabled: bool = False - shadow: float = 0.0 - radius: float = 200.0 - delta_radius: float = 0.0 - - -def get_config_dir() -> Path: - xdg = os.environ.get("XDG_CONFIG_HOME") - if xdg: - return Path(xdg) - return Path.home() / ".config" - - -def default_config_path() -> Path: - return get_config_dir() / "boomer" / "config" - - -def load_config(path: Path) -> Config: - result = Config() - for raw_line in path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line[0] == "#": - continue - pair = line.split("=", 1) - if len(pair) < 2: - raise SystemExit(f"Invalid config line `{line}`") - key = pair[0].strip() - value = pair[1].strip() - try: - parsed_value = float(value) - except ValueError: - raise SystemExit(f"Could not parse config value `{value}` for key `{key}`") from None - if key == "min_scale": - result.min_scale = parsed_value - elif key == "scroll_speed": - result.scroll_speed = parsed_value - elif key == "drag_friction": - result.drag_friction = parsed_value - elif key == "scale_friction": - result.scale_friction = parsed_value - else: - raise SystemExit(f"Unknown config key `{key}`") - return result +# GL / GLX FUNCTION TYPING -def generate_default_config(path: Path) -> None: - with path.open("w", encoding="utf-8") as out: - out.write(f"min_scale = {Config.min_scale}\n") - out.write(f"scroll_speed = {Config.scroll_speed}\n") - out.write(f"drag_friction = {Config.drag_friction}\n") - out.write(f"scale_friction = {Config.scale_friction}\n") +_libgl.glXChooseVisual.argtypes = [c_void_p, c_int, POINTER(c_int)] +_libgl.glXChooseVisual.restype = POINTER(XVisualInfo) +_libgl.glXCreateContext.argtypes = [c_void_p, POINTER(XVisualInfo), c_void_p, c_int] +_libgl.glXCreateContext.restype = c_void_p -def boomer_version() -> str: - try: - revision = subprocess.check_output( - ["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL - ).decode("utf-8", errors="replace").strip() - except Exception: - revision = "unknown" - if revision != "unknown": - revision = revision[:8] - return f"boomer-{revision}" +_libgl.glXMakeCurrent.argtypes = [c_void_p, c_ulong, c_void_p] +_libgl.glXMakeCurrent.restype = c_int +_libgl.glXSwapBuffers.argtypes = [c_void_p, c_ulong] +_libgl.glXSwapBuffers.restype = None -@dataclasses.dataclass -class CliOptions: - config_file: Path = dataclasses.field(default_factory=default_config_path) - delay_sec: float = 0.0 - windowed: bool = False - action: str = "run" - new_config_path: Path | None = None +_libgl.glXDestroyContext.argtypes = [c_void_p, c_void_p] +_libgl.glXDestroyContext.restype = None +_libgl.glXQueryVersion.argtypes = [c_void_p, POINTER(c_int), POINTER(c_int)] +_libgl.glXQueryVersion.restype = c_int -def parse_delay(text: str) -> float: - try: - return float(text) - except ValueError: - raise SystemExit(f"Delay parameter `{text}` is not a valid number.") from None +# core GL 1.1 +_libgl.glClearColor.argtypes = [c_float, c_float, c_float, c_float] +_libgl.glClearColor.restype = None +_libgl.glClear.argtypes = [c_uint] +_libgl.glClear.restype = None -def parse_cli(argv: list[str]) -> CliOptions: - opts = CliOptions() - idx = 0 - while idx < len(argv): - arg = argv[idx] +_libgl.glViewport.argtypes = [c_int, c_int, c_int, c_int] +_libgl.glViewport.restype = None - if arg in ("-h", "--help"): - opts.action = "help" - return opts - - if arg in ("-V", "--version"): - opts.action = "version" - return opts - - if arg in ("-w", "--windowed"): - opts.windowed = True - idx += 1 - continue - - if arg == "--new-config": - opts.action = "new-config" - if idx + 1 < len(argv) and not argv[idx + 1].startswith("-"): - opts.new_config_path = Path(argv[idx + 1]).expanduser() - idx += 2 - else: - opts.new_config_path = opts.config_file - idx += 1 - continue - - if arg.startswith("--new-config="): - value = arg.split("=", 1)[1] - if not value: - raise UsageError("Unknown flag `new-config`") - opts.action = "new-config" - opts.new_config_path = Path(value).expanduser() - idx += 1 - continue - - if arg in ("-d", "--delay"): - if idx + 1 < len(argv): - opts.delay_sec = parse_delay(argv[idx + 1]) - idx += 2 - else: - opts.delay_sec = parse_delay("") - idx += 1 - continue - - if arg.startswith("--delay="): - opts.delay_sec = parse_delay(arg.split("=", 1)[1]) - idx += 1 - continue - - if arg == "-c" or arg == "--config": - if idx + 1 >= len(argv): - raise UsageError("Unknown argument `config`") - opts.config_file = Path(argv[idx + 1]).expanduser() - idx += 2 - continue - - if arg.startswith("--config="): - opts.config_file = Path(arg.split("=", 1)[1]).expanduser() - idx += 1 - continue - - if arg == "-d" or arg.startswith("-d:") or (arg.startswith("-d") and len(arg) > 2): - compact = arg[2:] - if compact.startswith(":"): - compact = compact[1:] - if compact: - opts.delay_sec = parse_delay(compact) - idx += 1 - continue - - if arg == "-c" or (arg.startswith("-c") and len(arg) > 2): - compact = arg[2:] - if compact: - opts.config_file = Path(compact).expanduser() - idx += 1 - continue - - if arg.startswith("-"): - raise UsageError(f"Unknown flag `{arg.lstrip('-')}`") - raise UsageError(f"Unknown argument `{arg}`") - - return opts - - -# x11 key translation -# falls back gracefully if libX11.so.6 is unavailable -class X11KeyMapper: - def __init__(self) -> None: - self._x11 = None - self._display = None - self._root_window = 0 - try: - x11 = ctypes.CDLL("libX11.so.6") - x11.XOpenDisplay.argtypes = [ctypes.c_char_p] - x11.XOpenDisplay.restype = ctypes.c_void_p - x11.XCloseDisplay.argtypes = [ctypes.c_void_p] - x11.XCloseDisplay.restype = ctypes.c_int - x11.XDefaultScreen.argtypes = [ctypes.c_void_p] - x11.XDefaultScreen.restype = ctypes.c_int - x11.XRootWindow.argtypes = [ctypes.c_void_p, ctypes.c_int] - x11.XRootWindow.restype = ctypes.c_ulong - x11.XkbKeycodeToKeysym.argtypes = [ - ctypes.c_void_p, - ctypes.c_uint, - ctypes.c_int, - ctypes.c_int, - ] - x11.XkbKeycodeToKeysym.restype = ctypes.c_ulong - x11.XQueryPointer.argtypes = [ - ctypes.c_void_p, - ctypes.c_ulong, - ctypes.POINTER(ctypes.c_ulong), - ctypes.POINTER(ctypes.c_ulong), - ctypes.POINTER(ctypes.c_int), - ctypes.POINTER(ctypes.c_int), - ctypes.POINTER(ctypes.c_int), - ctypes.POINTER(ctypes.c_int), - ctypes.POINTER(ctypes.c_uint), - ] - x11.XQueryPointer.restype = ctypes.c_int - x11.XWarpPointer.argtypes = [ - ctypes.c_void_p, - ctypes.c_ulong, - ctypes.c_ulong, - ctypes.c_int, - ctypes.c_int, - ctypes.c_uint, - ctypes.c_uint, - ctypes.c_int, - ctypes.c_int, - ] - x11.XWarpPointer.restype = ctypes.c_int - x11.XRaiseWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong] - x11.XRaiseWindow.restype = ctypes.c_int - x11.XSetInputFocus.argtypes = [ - ctypes.c_void_p, - ctypes.c_ulong, - ctypes.c_int, - ctypes.c_ulong, - ] - x11.XSetInputFocus.restype = ctypes.c_int - x11.XGetInputFocus.argtypes = [ - ctypes.c_void_p, - ctypes.POINTER(ctypes.c_ulong), - ctypes.POINTER(ctypes.c_int), - ] - x11.XGetInputFocus.restype = ctypes.c_int - x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] - x11.XSync.restype = ctypes.c_int - x11.XFlush.argtypes = [ctypes.c_void_p] - x11.XFlush.restype = ctypes.c_int - - self._display = x11.XOpenDisplay(None) - if self._display: - self._x11 = x11 - screen = x11.XDefaultScreen(self._display) - self._root_window = int(x11.XRootWindow(self._display, screen)) - except Exception: - self._x11 = None - self._display = None - self._root_window = 0 - - def keysym(self, scancode: int, shifted: bool) -> int | None: - if self._x11 is None or self._display is None or scancode <= 0: - return None +_libgl.glFinish.argtypes = [] +_libgl.glFinish.restype = None - level = 1 if shifted else 0 - keysym = int(self._x11.XkbKeycodeToKeysym(self._display, scancode, 0, level)) - if keysym == 0 and level == 1: - keysym = int(self._x11.XkbKeycodeToKeysym(self._display, scancode, 0, 0)) +_libgl.glEnable.argtypes = [c_uint] +_libgl.glEnable.restype = None - if keysym == 0: - return None - return keysym +_libgl.glGetString.argtypes = [c_uint] +_libgl.glGetString.restype = c_char_p - def pointer_pos(self) -> tuple[int, int] | None: - if self._x11 is None or self._display is None or self._root_window == 0: - return None +_libgl.glGenTextures.argtypes = [c_int, POINTER(c_uint)] +_libgl.glGenTextures.restype = None - root_return = ctypes.c_ulong() - child_return = ctypes.c_ulong() - root_x = ctypes.c_int() - root_y = ctypes.c_int() - win_x = ctypes.c_int() - win_y = ctypes.c_int() - mask_return = ctypes.c_uint() - - if self._x11.XQueryPointer( - self._display, - self._root_window, - ctypes.byref(root_return), - ctypes.byref(child_return), - ctypes.byref(root_x), - ctypes.byref(root_y), - ctypes.byref(win_x), - ctypes.byref(win_y), - ctypes.byref(mask_return), - ) == 0: - return None +_libgl.glActiveTexture.argtypes = [c_uint] +_libgl.glActiveTexture.restype = None - return int(root_x.value), int(root_y.value) - - def warp_pointer(self, x: int, y: int) -> bool: - if self._x11 is None or self._display is None or self._root_window == 0: - return False - - self._x11.XWarpPointer( - self._display, - 0, - self._root_window, - 0, - 0, - 0, - 0, - int(x), - int(y), - ) - self._x11.XFlush(self._display) - return True - - def focused_window(self) -> int | None: - if self._x11 is None or self._display is None: - return None +_libgl.glBindTexture.argtypes = [c_uint, c_uint] +_libgl.glBindTexture.restype = None - focus_window = ctypes.c_ulong() - revert_to = ctypes.c_int() - self._x11.XGetInputFocus( - self._display, - ctypes.byref(focus_window), - ctypes.byref(revert_to), - ) - if int(focus_window.value) == 0: - return None - return int(focus_window.value) +_libgl.glTexImage2D.argtypes = [ + c_uint, c_int, c_int, c_int, c_int, c_int, c_uint, c_uint, c_void_p, +] +_libgl.glTexImage2D.restype = None - def promote_window(self, window_id: int) -> bool: - if self._x11 is None or self._display is None or window_id <= 0: - return False +_libgl.glGenerateMipmap.argtypes = [c_uint] +_libgl.glGenerateMipmap.restype = None - self._x11.XRaiseWindow(self._display, int(window_id)) - self._x11.XSetInputFocus(self._display, int(window_id), 1, 0) - self._x11.XSync(self._display, 0) - return True +_libgl.glTexParameteri.argtypes = [c_uint, c_uint, c_int] +_libgl.glTexParameteri.restype = None - def close(self) -> None: - if self._x11 is not None and self._display is not None: - self._x11.XCloseDisplay(self._display) - self._display = None +_libgl.glDeleteTextures.argtypes = [c_int, POINTER(c_uint)] +_libgl.glDeleteTextures.restype = None +# GL 2.0+ (should be in libGL.so on modern Mesa) +_libgl.glCreateShader.argtypes = [c_uint] +_libgl.glCreateShader.restype = c_uint -# camera and flashlight physics -def world(camera: Camera, v: Vec2) -> Vec2: - return v / camera.scale +_libgl.glShaderSource.argtypes = [c_uint, c_int, POINTER(c_char_p), POINTER(c_int)] +_libgl.glShaderSource.restype = None +_libgl.glCompileShader.argtypes = [c_uint] +_libgl.glCompileShader.restype = None -def update_camera(camera: Camera, config: Config, dt: float, mouse: Mouse, window_size: Vec2) -> None: - if abs(camera.delta_scale) > 0.5: - p0 = (camera.scale_pivot - (window_size * 0.5)) / camera.scale - camera.scale = max(camera.scale + camera.delta_scale * dt, config.min_scale) - p1 = (camera.scale_pivot - (window_size * 0.5)) / camera.scale - camera.position = camera.position + (p0 - p1) - camera.delta_scale -= camera.delta_scale * dt * config.scale_friction +_libgl.glGetShaderiv.argtypes = [c_uint, c_uint, POINTER(c_int)] +_libgl.glGetShaderiv.restype = None - if not mouse.drag and camera.velocity.length() > VELOCITY_THRESHOLD: - camera.position = camera.position + (camera.velocity * dt) - camera.velocity = camera.velocity - (camera.velocity * dt * config.drag_friction) +_libgl.glGetShaderInfoLog.argtypes = [c_uint, c_int, POINTER(c_int), c_char_p] +_libgl.glGetShaderInfoLog.restype = None +_libgl.glDeleteShader.argtypes = [c_uint] +_libgl.glDeleteShader.restype = None -def update_flashlight(flashlight: Flashlight, dt: float) -> None: - if abs(flashlight.delta_radius) > 1.0: - flashlight.radius = max(0.0, flashlight.radius + flashlight.delta_radius * dt) - flashlight.delta_radius -= flashlight.delta_radius * FL_DELTA_RADIUS_DECELERATION * dt +_libgl.glCreateProgram.argtypes = [] +_libgl.glCreateProgram.restype = c_uint - if flashlight.is_enabled: - flashlight.shadow = min(flashlight.shadow + 6.0 * dt, 0.8) - else: - flashlight.shadow = max(flashlight.shadow - 6.0 * dt, 0.0) +_libgl.glAttachShader.argtypes = [c_uint, c_uint] +_libgl.glAttachShader.restype = None +_libgl.glLinkProgram.argtypes = [c_uint] +_libgl.glLinkProgram.restype = None -# opengl helpers -def build_vertices(width: int, height: int, np_module): - w = float(width) - h = float(height) - return np_module.array( - [ - w, - 0.0, - 0.0, - 1.0, - 1.0, - w, - h, - 0.0, - 1.0, - 0.0, - 0.0, - h, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - ], - dtype=np_module.float32, - ) +_libgl.glGetProgramiv.argtypes = [c_uint, c_uint, POINTER(c_int)] +_libgl.glGetProgramiv.restype = None +_libgl.glGetProgramInfoLog.argtypes = [c_uint, c_int, POINTER(c_int), c_char_p] +_libgl.glGetProgramInfoLog.restype = None -def compile_shader(gl_module, source: str, shader_type: int, shader_name: str) -> int: - shader = gl_module.glCreateShader(shader_type) - gl_module.glShaderSource(shader, source) - gl_module.glCompileShader(shader) - success = gl_module.glGetShaderiv(shader, gl_module.GL_COMPILE_STATUS) - if not success: - log = gl_module.glGetShaderInfoLog(shader).decode("utf-8", errors="replace") - raise SystemExit( - "------------------------------\n" - f"Error during shader compilation: {shader_name}. Log:\n" - f"{log}\n" - "------------------------------" - ) - return shader +_libgl.glDeleteProgram.argtypes = [c_uint] +_libgl.glDeleteProgram.restype = None +_libgl.glUseProgram.argtypes = [c_uint] +_libgl.glUseProgram.restype = None -def create_shader_program(gl_module, vertex_source: str, fragment_source: str) -> int: - vertex = compile_shader(gl_module, vertex_source, gl_module.GL_VERTEX_SHADER, "vert.glsl") - fragment = compile_shader( - gl_module, fragment_source, gl_module.GL_FRAGMENT_SHADER, "frag.glsl" - ) +_libgl.glGetUniformLocation.argtypes = [c_uint, c_char_p] +_libgl.glGetUniformLocation.restype = c_int - program = gl_module.glCreateProgram() - gl_module.glAttachShader(program, vertex) - gl_module.glAttachShader(program, fragment) - gl_module.glBindAttribLocation(program, 0, b"aPos") - gl_module.glBindAttribLocation(program, 1, b"aTexCoord") - gl_module.glLinkProgram(program) - - gl_module.glDeleteShader(vertex) - gl_module.glDeleteShader(fragment) - - success = gl_module.glGetProgramiv(program, gl_module.GL_LINK_STATUS) - if not success: - log = gl_module.glGetProgramInfoLog(program).decode("utf-8", errors="replace") - raise SystemExit(log) - - gl_module.glUseProgram(program) - return program - - -def upload_texture(gl_module, width: int, height: int, bgra_data: bytes) -> None: - gl_module.glTexImage2D( - gl_module.GL_TEXTURE_2D, - 0, - gl_module.GL_RGBA8, - width, - height, - 0, - gl_module.GL_BGRA, - gl_module.GL_UNSIGNED_BYTE, - bgra_data, - ) +_libgl.glUniform1i.argtypes = [c_int, c_int] +_libgl.glUniform1i.restype = None + +_libgl.glUniform1f.argtypes = [c_int, c_float] +_libgl.glUniform1f.restype = None + +_libgl.glUniform2f.argtypes = [c_int, c_float, c_float] +_libgl.glUniform2f.restype = None +_libgl.glGenVertexArrays.argtypes = [c_int, POINTER(c_uint)] +_libgl.glGenVertexArrays.restype = None -# main entrypoint - gl imports are deferred so --help/--version don't require them -def run_boomer(config_file: Path, config: Config, windowed: bool) -> None: - import threading +_libgl.glGenBuffers.argtypes = [c_int, POINTER(c_uint)] +_libgl.glGenBuffers.restype = None - # capture screenshot in a background thread while heavy GL imports load - - # this overlaps the two largest startup costs to reduce time-to-interactive - _ss: dict = {"screenshot": None, "root": None, "error": None} +_libgl.glBindVertexArray.argtypes = [c_uint] +_libgl.glBindVertexArray.restype = None + +_libgl.glBindBuffer.argtypes = [c_uint, c_uint] +_libgl.glBindBuffer.restype = None + +_libgl.glBufferData.argtypes = [c_uint, c_long, c_void_p, c_uint] +_libgl.glBufferData.restype = None + +_libgl.glVertexAttribPointer.argtypes = [c_uint, c_int, c_uint, c_ubyte, c_int, c_void_p] +_libgl.glVertexAttribPointer.restype = None + +_libgl.glEnableVertexAttribArray.argtypes = [c_uint] +_libgl.glEnableVertexAttribArray.restype = None + +_libgl.glDrawElements.argtypes = [c_uint, c_int, c_uint, c_void_p] +_libgl.glDrawElements.restype = None + +_libgl.glDeleteVertexArrays.argtypes = [c_int, POINTER(c_uint)] +_libgl.glDeleteVertexArrays.restype = None + +_libgl.glDeleteBuffers.argtypes = [c_int, POINTER(c_uint)] +_libgl.glDeleteBuffers.restype = None + + +# X11 ERROR HANDLER + +_XErrorHandlerType = CFUNCTYPE(c_int, c_void_p, POINTER(XErrorEvent)) + +@_XErrorHandlerType +def _x11_error_handler(display, error_ev_ptr): + buf = ct.create_string_buffer(256) + ev = error_ev_ptr.contents + _x11.XGetErrorText(display, ev.error_code, buf, 256) + msg = buf.value.decode("utf-8", errors="replace") + print(f"error: x11: {msg}", file=sys.stderr) + return 0 - def _capture() -> None: +# keep a reference so the callback isnt garbage-collected +_x11_error_handler_ref = _x11_error_handler + + +# SHADER SOURCES + +_vertex_shader_source = b"""#version 130 +in vec3 aPos; +in vec2 aTexCoord; +out vec2 texcoord; +uniform vec2 camera_pos; +uniform float camera_scale; +uniform vec2 window_size; +uniform vec2 screenshot_size; +vec3 to_world(vec3 v) { + vec2 ratio = vec2( + window_size.x / screenshot_size.x / camera_scale, + window_size.y / screenshot_size.y / camera_scale); + return vec3((v.x / screenshot_size.x * 2.0 - 1.0) / ratio.x, + (v.y / screenshot_size.y * 2.0 - 1.0) / ratio.y, + v.z); +} +void main() { + gl_Position = vec4(to_world((aPos - vec3(camera_pos * vec2(1.0, -1.0), 0.0))), 1.0); + texcoord = aTexCoord; +} +""" + +_fragment_shader_source = b"""#version 130 +out mediump vec4 color; +in mediump vec2 texcoord; +uniform sampler2D tex; +uniform vec2 cursor_pos; +uniform vec2 window_size; +uniform float fl_shadow; +uniform float fl_radius; +uniform float camera_scale; +uniform float fl_feather; +uniform float mirror; +void main() { + vec4 cursor = vec4(cursor_pos.x, window_size.y - cursor_pos.y, 0.0, 1.0); + float dist = length(cursor - gl_FragCoord); + float radius_px = fl_radius * camera_scale; + float inner = radius_px * (1.0 - fl_feather); + float outer = radius_px; + float alpha = smoothstep(inner, outer, dist); + vec2 tc = texcoord; + if (mirror > 0.5) tc.x = 1.0 - tc.x; + color = mix(texture(tex, tc), vec4(0.0, 0.0, 0.0, 0.0), alpha * fl_shadow); +} +""" + + +# CONFIG + +class Config: + __slots__ = ( + "min_scale", "scroll_speed", "drag_friction", "scale_friction", + "velocity_threshold", "scale_change_threshold", + "initial_radius", "initial_delta_radius", "radius_damping", + "fade_speed", "max_shadow_opacity", "radius_change_threshold", + "feather", "texture_filter", + "key_escape", "key_flashlight", "key_reset", "key_mirror", + "key_zoom_in", "key_zoom_out", + "modifier_flashlight", "button_drag", "button_zoom_in", "button_zoom_out", + "_keys", + ) + + def __init__(self): + # camera + self.min_scale = 0.5 + self.scroll_speed = 1.5 + self.drag_friction = 6.0 + self.scale_friction = 4.0 + self.velocity_threshold = 15.0 + self.scale_change_threshold = 0.5 + # flashlight + self.initial_radius = 200.0 + self.initial_delta_radius = 250.0 + self.radius_damping = 10.0 + self.fade_speed = 6.0 + self.max_shadow_opacity = 0.8 + self.radius_change_threshold = 1.0 + self.feather = 0.0 + # opengl + self.texture_filter = 0 + # key bindings + self.key_escape = XK_Escape + self.key_flashlight = XK_f + self.key_reset = XK_0 + self.key_mirror = XK_m + self.key_zoom_in = XK_equal + self.key_zoom_out = XK_minus + self.modifier_flashlight = XK_ControlMask + self.button_drag = XK_Button1 + self.button_zoom_in = XK_Button4 + self.button_zoom_out = XK_Button5 + # parse map + self._keys = { + "min_scale": ("min_scale", float), + "scroll_speed": ("scroll_speed", float), + "drag_friction": ("drag_friction", float), + "scale_friction": ("scale_friction", float), + "velocity_threshold": ("velocity_threshold", float), + "scale_change_threshold": ("scale_change_threshold", float), + "initial_radius": ("initial_radius", float), + "initial_delta_radius": ("initial_delta_radius", float), + "radius_damping": ("radius_damping", float), + "fade_speed": ("fade_speed", float), + "max_shadow_opacity": ("max_shadow_opacity", float), + "radius_change_threshold": ("radius_change_threshold", float), + "feather": ("feather", float), + "texture_filter": ("texture_filter", int), + } + + def apply_value(self, key, val): + entry = self._keys.get(key) + if entry is None: + return + attr, typ = entry + try: + setattr(self, attr, typ(val)) + except (ValueError, TypeError): + pass + + def load_from_file(self, path): try: - import mss as _m # type: ignore - with _m.MSS() as s: - if not s.monitors: - _ss["error"] = SystemExit("No monitors found") - return - _ss["root"] = s.monitors[0].copy() - _ss["screenshot"] = s.grab(_ss["root"]) - except Exception as exc: - _ss["error"] = exc - - _ss_thread = threading.Thread(target=_capture, daemon=True) - _ss_thread.start() - - try: - import glfw # type: ignore - import numpy # type: ignore - from OpenGL import GL # type: ignore - import mss # type: ignore - except Exception as exc: - raise SystemExit( - "Missing runtime dependencies. Install with `pip install -r requirements.txt`." - ) from exc - - _ss_thread.join() - if _ss["error"] is not None: - raise _ss["error"] - - if not glfw.init(): - raise SystemExit("Failed to initialize GLFW") - - window = None - key_mapper = X11KeyMapper() - try: - with mss.MSS() as sct: - screenshot = _ss["screenshot"] - root = _ss["root"] - saved_pointer = key_mapper.pointer_pos() - - rate = 60 - monitor = glfw.get_primary_monitor() - if monitor is not None: - mode = glfw.get_video_mode(monitor) - if mode is not None and getattr(mode, "refresh_rate", 0): - rate = int(mode.refresh_rate) - print(f"Screen rate: {rate}") - - glfw.default_window_hints() - # keep window hidden during setup to avoid a flash; - # glfw ignores this hint for fullscreen windows, handled below instead - glfw.window_hint(glfw.VISIBLE, glfw.FALSE) - if hasattr(glfw, "FOCUSED"): - glfw.window_hint(glfw.FOCUSED, glfw.TRUE) - if hasattr(glfw, "FOCUS_ON_SHOW"): - glfw.window_hint(glfw.FOCUS_ON_SHOW, glfw.TRUE) - - fullscreen_monitor = None - if not windowed: - fullscreen_monitor = glfw.get_primary_monitor() - - if not windowed and fullscreen_monitor is None: - glfw.window_hint(glfw.DECORATED, glfw.FALSE) - glfw.window_hint(glfw.FLOATING, glfw.TRUE) - - if not windowed and fullscreen_monitor is not None: - glfw.window_hint(glfw.AUTO_ICONIFY, glfw.FALSE) - - window_width = int(screenshot.width) - window_height = int(screenshot.height) - window_left = int(root.get("left", 0)) - window_top = int(root.get("top", 0)) - if fullscreen_monitor is not None: - mode = glfw.get_video_mode(fullscreen_monitor) - if mode is not None: - if hasattr(mode, "size"): - size = mode.size - if hasattr(size, "width") and hasattr(size, "height"): - window_width = int(size.width) - window_height = int(size.height) - elif isinstance(size, (tuple, list)) and len(size) >= 2: - window_width = int(size[0]) - window_height = int(size[1]) - else: - window_width = int(getattr(mode, "width", window_width)) - window_height = int(getattr(mode, "height", window_height)) - if hasattr(glfw, "get_monitor_pos"): - monitor_pos = glfw.get_monitor_pos(fullscreen_monitor) - window_left = int(monitor_pos[0]) - window_top = int(monitor_pos[1]) - - window = glfw.create_window( - window_width, - window_height, - "boomer", - fullscreen_monitor, - None, - ) - if window is None: - raise SystemExit("Failed to create GLFW window") - - if windowed or fullscreen_monitor is None: - glfw.set_window_pos(window, window_left, window_top) - glfw.make_context_current(window) - glfw.swap_interval(0) - - x11_window = 0 - if hasattr(glfw, "get_x11_window"): - try: - x11_window = int(glfw.get_x11_window(window)) - except Exception: - x11_window = 0 - - if not windowed and hasattr(glfw, "set_window_attrib") and hasattr(glfw, "FLOATING"): - glfw.set_window_attrib(window, glfw.FLOATING, glfw.TRUE) - - def raise_window() -> None: - if x11_window: - key_mapper.promote_window(x11_window) - glfw.focus_window(window) - if hasattr(glfw, "request_window_attention"): - glfw.request_window_attention(window) - - # immediately clear to black and swap so the fullscreen window covers - # the desktop with black rather than grey/garbage during setup - GL.glClearColor(0.0, 0.0, 0.0, 1.0) - GL.glClear(GL.GL_COLOR_BUFFER_BIT) - glfw.swap_buffers(window) - if fullscreen_monitor is not None: - raise_window() - - gl_version = GL.glGetString(GL.GL_VERSION) - if gl_version is None: - print("GL version unknown") + with open(path) as f: + for line in f: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if "=" not in stripped: + continue + k, _, v = stripped.partition("=") + self.apply_value(k.strip(), v.strip()) + except FileNotFoundError: + pass + + @staticmethod + def write_default(path): + c = Config() + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "w") as f: + f.write(f"min_scale = {c.min_scale:.1f}\n") + f.write(f"scroll_speed = {c.scroll_speed:.1f}\n") + f.write(f"drag_friction = {c.drag_friction:.1f}\n") + f.write(f"scale_friction = {c.scale_friction:.1f}\n") + f.write(f"velocity_threshold = {c.velocity_threshold:.1f}\n") + f.write(f"scale_change_threshold = {c.scale_change_threshold:.2f}\n") + f.write(f"initial_radius = {c.initial_radius:.1f}\n") + f.write(f"initial_delta_radius = {c.initial_delta_radius:.1f}\n") + f.write(f"radius_damping = {c.radius_damping:.1f}\n") + f.write(f"fade_speed = {c.fade_speed:.1f}\n") + f.write(f"max_shadow_opacity = {c.max_shadow_opacity:.2f}\n") + f.write(f"radius_change_threshold = {c.radius_change_threshold:.2f}\n") + f.write(f"feather = {c.feather:.2f}\n") + f.write(f"texture_filter = {c.texture_filter}\n") + + @staticmethod + def default_config_path(): + home = os.environ.get("HOME") + if not home: + return None + return os.path.join(home, ".config", "boomer", "config") + + +# APPLICATION STATE + +class Camera: + __slots__ = ("position_x", "position_y", "velocity_x", "velocity_y", + "scale", "delta_scale", "scale_pivot_x", "scale_pivot_y") + def __init__(self): + self.position_x = 0.0 + self.position_y = 0.0 + self.velocity_x = 0.0 + self.velocity_y = 0.0 + self.scale = 1.0 + self.delta_scale = 0.0 + self.scale_pivot_x = 0.0 + self.scale_pivot_y = 0.0 + + +class Mouse: + __slots__ = ("curr_x", "curr_y", "prev_x", "prev_y", "drag") + def __init__(self): + self.curr_x = 0.0 + self.curr_y = 0.0 + self.prev_x = 0.0 + self.prev_y = 0.0 + self.drag = False + + +class Flashlight: + __slots__ = ("enabled", "shadow", "radius", "delta_radius") + def __init__(self): + self.enabled = False + self.shadow = 0.0 + self.radius = 0.0 + self.delta_radius = 0.0 + + +class State: + __slots__ = ("camera", "mouse", "flashlight", "dt", "running", "mirror") + def __init__(self): + self.camera = Camera() + self.mouse = Mouse() + self.flashlight = Flashlight() + self.dt = 0.0 + self.running = True + self.mirror = False + + +class App: + __slots__ = ("config", "state", "config_path") + + def __init__(self, config, config_path=None): + self.config = config + self.state = State() + self.config_path = config_path + self.state.flashlight.radius = config.initial_radius + if config_path: + config.load_from_file(config_path) + + def camera_update(self, ww, wh): + cfg = self.config + cam = self.state.camera + dt = self.state.dt + + if abs(cam.delta_scale) > cfg.scale_change_threshold: + half_w = ww * 0.5 + half_h = wh * 0.5 + sub_x = cam.scale_pivot_x - half_w + sub_y = cam.scale_pivot_y - half_h + p0_x = sub_x / cam.scale + p0_y = sub_y / cam.scale + + cam.scale += cam.delta_scale * dt + if cam.scale < cfg.min_scale: + cam.scale = cfg.min_scale + + p1_x = sub_x / cam.scale + p1_y = sub_y / cam.scale + cam.position_x += p0_x - p1_x + cam.position_y += p0_y - p1_y + cam.delta_scale -= cam.delta_scale * dt * cfg.scale_friction + + m = self.state.mouse + if not m.drag: + vx = cam.velocity_x + vy = cam.velocity_y + vlen = math.sqrt(vx * vx + vy * vy) + if vlen > cfg.velocity_threshold: + cam.position_x += vx * dt + cam.position_y += vy * dt + cam.velocity_x -= vx * dt * cfg.drag_friction + cam.velocity_y -= vy * dt * cfg.drag_friction + + def flashlight_update(self): + fl = self.state.flashlight + dt = self.state.dt + cfg = self.config + + if fl.enabled: + fl.shadow = min(fl.shadow + cfg.fade_speed * dt, cfg.max_shadow_opacity) + else: + fl.shadow = max(fl.shadow - cfg.fade_speed * dt, 0.0) + + if abs(fl.delta_radius) > cfg.radius_change_threshold: + fl.radius = max(0.0, fl.radius + fl.delta_radius * dt) + fl.delta_radius -= fl.delta_radius * cfg.radius_damping * dt + + def _world_position(self, pos_x, pos_y, scale): + return pos_x / scale, pos_y / scale + + def seed_mouse(self, wx, wy): + self.state.mouse.curr_x = float(wx) + self.state.mouse.curr_y = float(wy) + self.state.mouse.prev_x = float(wx) + self.state.mouse.prev_y = float(wy) + + def _handle_keypress(self, keysym): + cfg = self.config + if keysym == cfg.key_escape or keysym == XK_q: + self.state.running = False + elif keysym == XK_r: + if self.config_path: + cfg.load_from_file(self.config_path) + elif keysym == cfg.key_flashlight: + self.state.flashlight.enabled = not self.state.flashlight.enabled + elif keysym == cfg.key_reset: + self.state.camera = Camera() + self.state.flashlight.shadow = 0.0 + self.state.flashlight.radius = cfg.initial_radius + self.state.flashlight.delta_radius = 0.0 + elif keysym == cfg.key_mirror: + self.state.mirror = not self.state.mirror + elif keysym == cfg.key_zoom_in: + self.state.camera.delta_scale += cfg.scroll_speed + self.state.camera.scale_pivot_x = self.state.mouse.curr_x + self.state.camera.scale_pivot_y = self.state.mouse.curr_y + elif keysym == cfg.key_zoom_out: + self.state.camera.delta_scale -= cfg.scroll_speed + self.state.camera.scale_pivot_x = self.state.mouse.curr_x + self.state.camera.scale_pivot_y = self.state.mouse.curr_y + + def _handle_mousemove(self, motion_ptr, refresh_rate): + motion = motion_ptr.contents + m = self.state.mouse + m.curr_x = float(motion.x) + m.curr_y = float(motion.y) + + if m.drag: + cam = self.state.camera + prev_x, prev_y = self._world_position(m.prev_x, m.prev_y, cam.scale) + cur_x, cur_y = self._world_position(m.curr_x, m.curr_y, cam.scale) + cam.position_x += prev_x - cur_x + cam.position_y += prev_y - cur_y + cam.velocity_x = (prev_x - cur_x) * refresh_rate + cam.velocity_y = (prev_y - cur_y) * refresh_rate + + m.prev_x = m.curr_x + m.prev_y = m.curr_y + + def _handle_buttonpress(self, button_ptr): + be = button_ptr.contents + cfg = self.config + ctrl_pressed = (be.state & cfg.modifier_flashlight) != 0 + + if be.button == cfg.button_drag: + m = self.state.mouse + m.prev_x = m.curr_x + m.prev_y = m.curr_y + m.drag = True + cam = self.state.camera + cam.velocity_x = 0.0 + cam.velocity_y = 0.0 + elif be.button == cfg.button_zoom_in: + if ctrl_pressed and self.state.flashlight.enabled: + self.state.flashlight.delta_radius += cfg.initial_delta_radius else: - print("GL version", gl_version.decode("utf-8", errors="replace")) - - developer_mode = os.environ.get("BOOMER_DEVELOPER") == "1" - shader_dir = Path.cwd() / "src" - - def load_shader_sources() -> tuple[str, str]: - if not developer_mode: - return VERTEX_SHADER, FRAGMENT_SHADER - - vertex_path = shader_dir / "vert.glsl" - fragment_path = shader_dir / "frag.glsl" - if not vertex_path.exists() or not fragment_path.exists(): - raise SystemExit( - "Developer mode expects shader files at ./src/vert.glsl and ./src/frag.glsl" - ) - - return ( - vertex_path.read_text(encoding="utf-8"), - fragment_path.read_text(encoding="utf-8"), - ) - - vert_source, frag_source = load_shader_sources() - shader_program = create_shader_program(GL, vert_source, frag_source) - - # cache uniform locations once after linking, glGetUniformLocation - # is slow and must not be called every frame - def cache_uniforms(prog: int) -> dict: - names = [ - "cameraPos", "cameraScale", "screenshotSize", "windowSize", - "cursorPos", "flShadow", "flRadius", "tex", - ] - return {n: GL.glGetUniformLocation(prog, n) for n in names} - - uniforms = cache_uniforms(shader_program) - - # geometry - vao = GL.glGenVertexArrays(1) - vbo = GL.glGenBuffers(1) - ebo = GL.glGenBuffers(1) - - vertices = build_vertices(screenshot.width, screenshot.height, numpy) - indices = numpy.array([0, 1, 3, 1, 2, 3], dtype=numpy.uint32) - - GL.glBindVertexArray(vao) - - GL.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo) - GL.glBufferData(GL.GL_ARRAY_BUFFER, vertices.nbytes, vertices, GL.GL_STATIC_DRAW) - - GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, ebo) - GL.glBufferData( - GL.GL_ELEMENT_ARRAY_BUFFER, indices.nbytes, indices, GL.GL_STATIC_DRAW - ) - - stride = 5 * vertices.itemsize - GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None) - GL.glEnableVertexAttribArray(0) - GL.glVertexAttribPointer( - 1, - 2, - GL.GL_FLOAT, - GL.GL_FALSE, - stride, - ctypes.c_void_p(3 * vertices.itemsize), - ) - GL.glEnableVertexAttribArray(1) - - # screenshot texture - texture = GL.glGenTextures(1) - GL.glActiveTexture(GL.GL_TEXTURE0) - GL.glBindTexture(GL.GL_TEXTURE_2D, texture) - GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST) - GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST) - GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_BORDER) - GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_BORDER) - upload_texture(GL, screenshot.width, screenshot.height, screenshot.bgra) - # no glGenerateMipmap - - GL.glUniform1i(uniforms["tex"], 0) - - # cache framebuffer size and cursor scale - updated via resize callback - # to avoid per-frame and per-cursor-move glfw calls - _fb_init = glfw.get_framebuffer_size(window) - _win_init = glfw.get_window_size(window) - _fb = [_fb_init[0], _fb_init[1]] - cursor_scale = [ - (_fb_init[0] / _win_init[0]) if _win_init[0] else 1.0, - (_fb_init[1] / _win_init[1]) if _win_init[1] else 1.0, - ] - - # scale logical window coords to framebuffer pixels (handles hidpi) - def window_to_framebuffer(x: float, y: float) -> Vec2: - return Vec2(x * cursor_scale[0], y * cursor_scale[1]) - - if saved_pointer is not None: - init_pos = Vec2( - (saved_pointer[0] - window_left) * cursor_scale[0], - (saved_pointer[1] - window_top) * cursor_scale[1], - ) + self.state.camera.delta_scale += cfg.scroll_speed + self.state.camera.scale_pivot_x = self.state.mouse.curr_x + self.state.camera.scale_pivot_y = self.state.mouse.curr_y + elif be.button == cfg.button_zoom_out: + if ctrl_pressed and self.state.flashlight.enabled: + self.state.flashlight.delta_radius -= cfg.initial_delta_radius else: - init_x, init_y = glfw.get_cursor_pos(window) - init_pos = window_to_framebuffer(init_x, init_y) - - # runtime state - quitting = False - fatal_error = None - active_keysyms: set[int] = set() - camera = Camera(scale=1.0) - mouse = Mouse(curr=init_pos, prev=init_pos.copy()) - flashlight = Flashlight(is_enabled=False, radius=200.0) - startup_focus_deadline = time.monotonic() + 15.0 - next_focus_check = 0.0 - - dt = 1.0 / float(max(1, rate)) - - def draw_frame( - camera_pos: Vec2, - camera_scale: float, - cursor_pos: Vec2, - fl_shadow: float, - fl_radius: float, - ) -> None: - fb_w, fb_h = _fb[0], _fb[1] - GL.glViewport(0, 0, fb_w, fb_h) - GL.glClearColor(0.1, 0.1, 0.1, 1.0) - GL.glClear(GL.GL_COLOR_BUFFER_BIT) - - # upload per-frame uniforms using pre-cached locations - GL.glUseProgram(shader_program) - GL.glUniform2f(uniforms["cameraPos"], camera_pos.x, camera_pos.y) - GL.glUniform1f(uniforms["cameraScale"], camera_scale) - GL.glUniform2f(uniforms["screenshotSize"], float(screenshot.width), float(screenshot.height)) - GL.glUniform2f(uniforms["windowSize"], float(fb_w), float(fb_h)) - GL.glUniform2f(uniforms["cursorPos"], cursor_pos.x, cursor_pos.y) - GL.glUniform1f(uniforms["flShadow"], fl_shadow) - GL.glUniform1f(uniforms["flRadius"], fl_radius) - - GL.glBindVertexArray(vao) - GL.glDrawElements(GL.GL_TRIANGLES, 6, GL.GL_UNSIGNED_INT, None) - - # present the captured screen immediately so startup feels responsive - # before input callbacks and zoom state are fully initialized - draw_frame(camera.position, camera.scale, mouse.curr, flashlight.shadow, flashlight.radius) - glfw.swap_buffers(window) - if windowed or fullscreen_monitor is None: - glfw.show_window(window) - raise_window() - - def ctrl_pressed() -> bool: - if active_keysyms & CONTROL_KEYSYMS: - return True - return ( - glfw.get_key(window, glfw.KEY_LEFT_CONTROL) == glfw.PRESS - or glfw.get_key(window, glfw.KEY_RIGHT_CONTROL) == glfw.PRESS - ) - - def scroll_up(amount: float = 1.0) -> None: - if ctrl_pressed() and flashlight.is_enabled: - flashlight.delta_radius += INITIAL_FL_DELTA_RADIUS * amount - else: - camera.delta_scale += config.scroll_speed * amount - camera.scale_pivot = mouse.curr.copy() - - def scroll_down(amount: float = 1.0) -> None: - if ctrl_pressed() and flashlight.is_enabled: - flashlight.delta_radius -= INITIAL_FL_DELTA_RADIUS * amount - else: - camera.delta_scale -= config.scroll_speed * amount - camera.scale_pivot = mouse.curr.copy() - - # input handlers - def handle_key(key: int, keysym: int | None) -> None: - nonlocal quitting, config, fatal_error, shader_program, uniforms - - if keysym is not None: - if keysym in (XK_EQUAL, XK_KP_ADD): - scroll_up() - elif keysym in (XK_MINUS, XK_KP_SUBTRACT): - scroll_down() - elif keysym == XK_0: - camera.scale = 1.0 - camera.delta_scale = 0.0 - camera.position = Vec2(0.0, 0.0) - camera.velocity = Vec2(0.0, 0.0) - elif keysym in (XK_q, XK_Q, XK_ESCAPE): - quitting = True - glfw.set_window_should_close(window, True) - elif keysym in (XK_r, XK_R): - if str(config_file) and config_file.exists(): - try: - config = load_config(config_file) - except BaseException as exc: # noqa: BLE001 - fatal_error = exc - quitting = True - - if ctrl_pressed() and developer_mode: - print("------------------------------") - print("RELOADING SHADERS") - try: - vert_source, frag_source = load_shader_sources() - new_shader_program = create_shader_program( - GL, vert_source, frag_source - ) - GL.glDeleteProgram(shader_program) - shader_program = new_shader_program - uniforms = cache_uniforms(shader_program) - GL.glUniform1i(uniforms["tex"], 0) - print(f"Shader program ID: {shader_program}") - except BaseException: # noqa: BLE001 - print("Could not reload the shaders") - print("------------------------------") - elif keysym in (XK_f, XK_F): - flashlight.is_enabled = not flashlight.is_enabled - return - - if key in (glfw.KEY_EQUAL, glfw.KEY_KP_ADD): - scroll_up(1.0) - elif key in (glfw.KEY_MINUS, glfw.KEY_KP_SUBTRACT): - scroll_down(1.0) - elif key == glfw.KEY_0: - camera.scale = 1.0 - camera.delta_scale = 0.0 - camera.position = Vec2(0.0, 0.0) - camera.velocity = Vec2(0.0, 0.0) - elif key in (glfw.KEY_Q, glfw.KEY_ESCAPE): - quitting = True - glfw.set_window_should_close(window, True) - elif key == glfw.KEY_R: - if str(config_file) and config_file.exists(): - try: - config = load_config(config_file) - except BaseException as exc: # noqa: BLE001 - fatal_error = exc - quitting = True - - if ctrl_pressed() and developer_mode: - print("------------------------------") - print("RELOADING SHADERS") - try: - vert_source, frag_source = load_shader_sources() - new_shader_program = create_shader_program( - GL, vert_source, frag_source - ) - GL.glDeleteProgram(shader_program) - shader_program = new_shader_program - uniforms = cache_uniforms(shader_program) - GL.glUniform1i(uniforms["tex"], 0) - print(f"Shader program ID: {shader_program}") - except BaseException: # noqa: BLE001 - print("Could not reload the shaders") - print("------------------------------") - elif key == glfw.KEY_F: - flashlight.is_enabled = not flashlight.is_enabled - - def on_cursor_pos(_window, xpos: float, ypos: float) -> None: - pos = window_to_framebuffer(xpos, ypos) - mouse.curr = pos - if mouse.drag: - delta = world(camera, mouse.prev) - world(camera, mouse.curr) - camera.position = camera.position + delta - camera.velocity = delta * float(rate) - mouse.prev = mouse.curr.copy() - - def on_mouse_button(_window, button: int, action: int, _mods: int) -> None: - if button != glfw.MOUSE_BUTTON_LEFT: - return - if action == glfw.PRESS: - mouse.prev = mouse.curr.copy() - mouse.drag = True - camera.velocity = Vec2(0.0, 0.0) - elif action == glfw.RELEASE: - mouse.drag = False - - def on_scroll(_window, _xoffset: float, yoffset: float) -> None: - if yoffset > 0: - scroll_up(abs(float(yoffset))) - elif yoffset < 0: - scroll_down(abs(float(yoffset))) - - def on_key(_window, key: int, scancode: int, action: int, mods: int) -> None: - keysym = key_mapper.keysym(scancode, shifted=bool(mods & glfw.MOD_SHIFT)) - - if keysym is not None: - if action in (glfw.PRESS, glfw.REPEAT): - active_keysyms.add(keysym) - elif action == glfw.RELEASE: - active_keysyms.discard(keysym) - - if action == glfw.PRESS: - handle_key(key, keysym) - - def on_close(_window) -> None: - nonlocal quitting - quitting = True - - def on_framebuffer_size(_win, w: int, h: int) -> None: - _fb[0] = w - _fb[1] = h - ww, wh = glfw.get_window_size(_win) - cursor_scale[0] = (w / ww) if ww else 1.0 - cursor_scale[1] = (h / wh) if wh else 1.0 - - # register callbacks - glfw.set_cursor_pos_callback(window, on_cursor_pos) - glfw.set_mouse_button_callback(window, on_mouse_button) - glfw.set_scroll_callback(window, on_scroll) - glfw.set_key_callback(window, on_key) - glfw.set_window_close_callback(window, on_close) - glfw.set_framebuffer_size_callback(window, on_framebuffer_size) - if saved_pointer is not None: - key_mapper.warp_pointer(saved_pointer[0], saved_pointer[1]) - - live_mode = os.environ.get("BOOMER_LIVE") == "1" - - # main render loop - while not quitting and not glfw.window_should_close(window): - now = time.monotonic() - if not windowed and now >= next_focus_check: - next_focus_check = now + 0.1 - if x11_window: - if key_mapper.focused_window() != x11_window: - raise_window() - elif now < startup_focus_deadline: - raise_window() - elif not windowed and fullscreen_monitor is None: - raise_window() - - glfw.poll_events() - - update_camera(camera, config, dt, mouse, Vec2(float(_fb[0]), float(_fb[1]))) - update_flashlight(flashlight, dt) - - draw_frame( - camera.position, - camera.scale, - mouse.curr, - flashlight.shadow, - flashlight.radius, - ) - - glfw.swap_buffers(window) - - if live_mode: - screenshot = sct.grab(root) - GL.glBindTexture(GL.GL_TEXTURE_2D, texture) - if screenshot.width != int(vertices[0]) or screenshot.height != int(vertices[6]): - # dimensions changed - reallocate texture and update geometry - vertices = build_vertices(screenshot.width, screenshot.height, numpy) - GL.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo) - GL.glBufferData( - GL.GL_ARRAY_BUFFER, - vertices.nbytes, - vertices, - GL.GL_STATIC_DRAW, - ) - upload_texture(GL, screenshot.width, screenshot.height, screenshot.bgra) - else: - # same size - glTexSubImage2D avoids reallocating texture storage - GL.glTexSubImage2D( - GL.GL_TEXTURE_2D, 0, 0, 0, - screenshot.width, screenshot.height, - GL.GL_BGRA, GL.GL_UNSIGNED_BYTE, - screenshot.bgra, - ) - - GL.glDeleteTextures(1, [texture]) - GL.glDeleteVertexArrays(1, [vao]) - GL.glDeleteBuffers(1, [vbo]) - GL.glDeleteBuffers(1, [ebo]) - GL.glDeleteProgram(shader_program) - - if fatal_error is not None: - raise fatal_error - finally: - key_mapper.close() - if window is not None: - glfw.destroy_window(window) - glfw.terminate() - - -def run(argv: list[str]) -> int: - try: - opts = parse_cli(argv) - except UsageError as exc: - print(str(exc), file=sys.stderr) - print(USAGE, file=sys.stderr) - return 1 - - if opts.action == "help": - print(USAGE) - return 0 - - if opts.action == "version": - print(boomer_version()) - return 0 - - if opts.action == "new-config": - new_config_path = opts.new_config_path if opts.new_config_path is not None else opts.config_file - parent = new_config_path.parent - parent.mkdir(parents=True, exist_ok=True) - if new_config_path.exists(): - sys.stdout.write(f"File {new_config_path} already exists. Replace it? [yn] ") - sys.stdout.flush() - if sys.stdin.read(1) != "y": - print("Disaster prevented") - return 0 - - generate_default_config(new_config_path) - print(f"Generated config at `{new_config_path}`") - return 0 - - sleep_seconds = math.floor(opts.delay_sec * 1000.0) / 1000.0 - if sleep_seconds > 0.0: - time.sleep(sleep_seconds) - - config = Config() - if opts.config_file.exists(): - config = load_config(opts.config_file) + self.state.camera.delta_scale -= cfg.scroll_speed + self.state.camera.scale_pivot_x = self.state.mouse.curr_x + self.state.camera.scale_pivot_y = self.state.mouse.curr_y + + def _handle_buttonrelease(self, button_ptr): + be = button_ptr.contents + if be.button == self.config.button_drag: + self.state.mouse.drag = False + + +# OPENGL HELPERS + +def _compile_shader(shader_type, source): + shader = _libgl.glCreateShader(shader_type) + src = ct.c_char_p(source) + srclen = c_int(len(source)) + _libgl.glShaderSource(shader, 1, byref(src), byref(srclen)) + _libgl.glCompileShader(shader) + + success = c_int() + _libgl.glGetShaderiv(shader, GL_COMPILE_STATUS, byref(success)) + if not success.value: + log = ct.create_string_buffer(512) + _libgl.glGetShaderInfoLog(shader, 512, None, log) + print(f"error: shader compile: {log.value.decode(errors='replace')}", file=sys.stderr) + return shader + + +def _build_program(): + v = _compile_shader(GL_VERTEX_SHADER, _vertex_shader_source) + f = _compile_shader(GL_FRAGMENT_SHADER, _fragment_shader_source) + + prog = _libgl.glCreateProgram() + _libgl.glAttachShader(prog, v) + _libgl.glAttachShader(prog, f) + _libgl.glLinkProgram(prog) + + success = c_int() + _libgl.glGetProgramiv(prog, GL_LINK_STATUS, byref(success)) + if not success.value: + log = ct.create_string_buffer(512) + _libgl.glGetProgramInfoLog(prog, 512, None, log) + print(f"error: program link: {log.value.decode(errors='replace')}", file=sys.stderr) + + _libgl.glDeleteShader(v) + _libgl.glDeleteShader(f) + _libgl.glUseProgram(prog) + _libgl.glUniform1i(_libgl.glGetUniformLocation(prog, b"tex"), 0) + + # cache uniform locations + locs = { + "camera_pos": _libgl.glGetUniformLocation(prog, b"camera_pos"), + "camera_scale": _libgl.glGetUniformLocation(prog, b"camera_scale"), + "window_size": _libgl.glGetUniformLocation(prog, b"window_size"), + "screenshot_size": _libgl.glGetUniformLocation(prog, b"screenshot_size"), + "cursor_pos": _libgl.glGetUniformLocation(prog, b"cursor_pos"), + "fl_shadow": _libgl.glGetUniformLocation(prog, b"fl_shadow"), + "fl_radius": _libgl.glGetUniformLocation(prog, b"fl_radius"), + "fl_feather": _libgl.glGetUniformLocation(prog, b"fl_feather"), + "mirror": _libgl.glGetUniformLocation(prog, b"mirror"), + } + return prog, locs + + +def _create_texture(img, texture_filter): + tex = c_uint() + _libgl.glGenTextures(1, byref(tex)) + _libgl.glActiveTexture(GL_TEXTURE0) + _libgl.glBindTexture(GL_TEXTURE_2D, tex) + _libgl.glTexImage2D( + GL_TEXTURE_2D, 0, GL_RGB, + img.width, img.height, 0, + GL_BGRA, GL_UNSIGNED_BYTE, img.data, + ) + _libgl.glGenerateMipmap(GL_TEXTURE_2D) + + if texture_filter != 0: + _libgl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) + _libgl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) else: - print(f"{opts.config_file} doesn't exist. Using default values. ", file=sys.stderr) + _libgl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST) + _libgl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST) - print(f"Using config: {config}") - run_boomer(opts.config_file, config, opts.windowed) - return 0 + _libgl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER) + _libgl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER) + return tex -def main() -> None: - sys.exit(run(sys.argv[1:])) +def _create_geometry(width, height): + w = float(width) + h = float(height) + vertices = (c_float * 20)( + w, 0.0, 0.0, 1.0, 1.0, + w, h, 0.0, 1.0, 0.0, + 0.0, h, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 1.0, + ) + indices = (c_uint * 6)(0, 1, 3, 1, 2, 3) + + vao = c_uint() + vbo = c_uint() + ebo = c_uint() + _libgl.glGenVertexArrays(1, byref(vao)) + _libgl.glGenBuffers(1, byref(vbo)) + _libgl.glGenBuffers(1, byref(ebo)) + + _libgl.glBindVertexArray(vao) + _libgl.glBindBuffer(GL_ARRAY_BUFFER, vbo) + _libgl.glBufferData(GL_ARRAY_BUFFER, ct.sizeof(vertices), vertices, GL_STATIC_DRAW) + _libgl.glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo) + _libgl.glBufferData(GL_ELEMENT_ARRAY_BUFFER, ct.sizeof(indices), indices, GL_STATIC_DRAW) + + stride = 5 * ct.sizeof(c_float) + _libgl.glVertexAttribPointer(0, 3, GL_FLOAT, False_, stride, None) + _libgl.glEnableVertexAttribArray(0) + _libgl.glVertexAttribPointer(1, 2, GL_FLOAT, False_, stride, c_void_p(3 * ct.sizeof(c_float))) + _libgl.glEnableVertexAttribArray(1) + + return vao, vbo, ebo + + +# MAIN + +def main(argv=None): + if argv is None: + argv = sys.argv + + # cli parsing + windowed = False + delay_sec = 0.0 + config_path = None + new_cfg_out = None + + i = 1 + argn = len(argv) + while i < argn: + arg = argv[i] + if arg in ("-h", "--help"): + print(USAGE) + return + elif arg in ("-V", "--version"): + print(f"pboomer-{VERSION}") + return + elif arg in ("-w", "--windowed"): + windowed = True + elif arg in ("-d", "--delay"): + i += 1 + if i >= argn: + print(f"error: no value provided for {arg}", file=sys.stderr) + print(USAGE) + sys.exit(1) + try: + delay_sec = float(argv[i]) + except ValueError: + print(f"error: invalid delay value: {argv[i]}", file=sys.stderr) + sys.exit(1) + elif arg in ("-c", "--config"): + i += 1 + if i >= argn: + print(f"error: no value provided for {arg}", file=sys.stderr) + print(USAGE) + sys.exit(1) + config_path = argv[i] + elif arg == "--new-config": + if i + 1 < argn and not argv[i + 1].startswith("-"): + i += 1 + new_cfg_out = argv[i] + else: + new_cfg_out = Config.default_config_path() + if new_cfg_out: + Config.write_default(new_cfg_out) + print(f"generated config at {new_cfg_out}") + return + else: + print(f"error: unknown flag `{arg}`", file=sys.stderr) + print(USAGE) + sys.exit(1) + i += 1 + + # delay + if delay_sec > 0.0: + time.sleep(delay_sec) + + # resolve config path + if config_path is None: + config_path = Config.default_config_path() + + if config_path: + print(f"using config: {config_path}") + + # x11 init + display = _x11.XOpenDisplay(None) + if not display: + print("error: failed to open display", file=sys.stderr) + sys.exit(1) + + _x11.XSetErrorHandler(_x11_error_handler_ref) + + root = _x11.XDefaultRootWindow(display) + root_attrs = XWindowAttributes() + _x11.XGetWindowAttributes(display, root, byref(root_attrs)) + + sc = _xr.XRRGetScreenInfo(display, root) + refresh_rate = _xr.XRRConfigCurrentRate(sc) + _xr.XRRFreeScreenConfigInfo(sc) + if refresh_rate <= 0: + refresh_rate = 60 + + screen_w = root_attrs.width + screen_h = root_attrs.height + print(f"screen: {screen_w}x{screen_h} @ {refresh_rate}hz") + + # GLX check + glx_major = c_int() + glx_minor = c_int() + if not _libgl.glXQueryVersion(display, byref(glx_major), byref(glx_minor)): + print("error: glXQueryVersion failed", file=sys.stderr) + sys.exit(1) + if glx_major.value < 1 or (glx_major.value == 1 and glx_minor.value < 3): + print("error: need GLX >= 1.3", file=sys.stderr) + sys.exit(1) + print(f"glx version: {glx_major.value}.{glx_minor.value}") + + # create window + glx_attrs = (c_int * 5)(GLX_RGBA, GLX_DEPTH_SIZE, 24, GLX_DOUBLEBUFFER, 0) + vi = _libgl.glXChooseVisual(display, 0, glx_attrs) + if not vi: + print("error: no appropriate visual found", file=sys.stderr) + sys.exit(1) + + vi_info = vi.contents + print(f"visual id: 0x{vi_info.visualid:x}") + + swa = XSetWindowAttributes() + swa.colormap = _x11.XCreateColormap(display, root, vi_info.visual, AllocNone) + swa.event_mask = (ButtonPressMask | ButtonReleaseMask | KeyPressMask | + KeyReleaseMask | PointerMotionMask | ExposureMask) + mask = CWColormap | CWEventMask + + if not windowed: + swa.override_redirect = 1 + swa.save_under = 1 + mask |= CWOverrideRedirect | CWSaveUnder + + window = _x11.XCreateWindow( + display, root, + 0, 0, screen_w, screen_h, 0, + vi_info.depth, InputOutput, vi_info.visual, + mask, byref(swa), + ) + + _x11.XStoreName(display, window, b"pboomer") + ch = XClassHint(b"pboomer", b"Pboomer") + _x11.XSetClassHint(display, window, byref(ch)) + + wm_delete_window = c_ulong(0) + if windowed: + wm_delete_window = c_ulong(_x11.XInternAtom(display, b"WM_DELETE_WINDOW", False_)) + _x11.XSetWMProtocols(display, window, byref(wm_delete_window), 1) + + _x11.XMapWindow(display, window) + + gl_ctx = _libgl.glXCreateContext(display, vi, None, True_) + if not gl_ctx: + print("error: failed to create glx context", file=sys.stderr) + sys.exit(1) + _libgl.glXMakeCurrent(display, window, gl_ctx) + _x11.XFree(vi) + + original_focus = c_ulong() + revert = c_int() + _x11.XGetInputFocus(display, byref(original_focus), byref(revert)) + + # screenshot + img_ptr = _x11.XGetImage(display, root, 0, 0, screen_w, screen_h, AllPlanes, ZPixmap) + if not img_ptr: + print("error: failed to take screenshot", file=sys.stderr) + sys.exit(1) + img = img_ptr.contents + print(f"screenshot: {img.width}x{img.height}") + + # opengl init + _libgl.glEnable(GL_TEXTURE_2D) + + ov = _libgl.glGetString(0x1F02) + gl_version = ov.decode() if ov else "unknown" + print(f"opengl version: {gl_version}") + + program, uniform_locs = _build_program() + + # app init + app = App(Config(), config_path) + + texture = _create_texture(img, app.config.texture_filter) + vao, vbo, ebo = _create_geometry(img.width, img.height) + + # seed mouse position + root_ret = c_ulong() + child_ret = c_ulong() + rx = c_int(); ry = c_int(); wx = c_int(); wy = c_int(); msk = c_uint() + _x11.XQueryPointer(display, root, + byref(root_ret), byref(child_ret), + byref(rx), byref(ry), byref(wx), byref(wy), byref(msk)) + app.seed_mouse(wx.value, wy.value) + + dt = 1.0 / float(refresh_rate) + app.state.dt = dt + + # main loop + event = XEvent() + wa = XWindowAttributes() + gl = _libgl + x = _x11 + + # local bindings for speed + _glUniform2f = gl.glUniform2f + _glUniform1f = gl.glUniform1f + _glClearColor = gl.glClearColor + _glClear = gl.glClear + _glUseProgram = gl.glUseProgram + _glBindTexture = gl.glBindTexture + _glBindVertexArray = gl.glBindVertexArray + _glDrawElements = gl.glDrawElements + _glXSwapBuffers = gl.glXSwapBuffers + _glFinish = gl.glFinish + _glViewport = gl.glViewport + _XPending = x.XPending + _XNextEvent = x.XNextEvent + _XLookupKeysym = x.XLookupKeysym + _XSetInputFocus = x.XSetInputFocus + + ulocs = uniform_locs + w = window + + while app.state.running: + if not windowed: + _XSetInputFocus(display, w, RevertToParent, CurrentTime) + + x.XGetWindowAttributes(display, w, byref(wa)) + ww = wa.width + wh = wa.height + _glViewport(0, 0, ww, wh) + + # drain events + while _XPending(display) > 0: + _XNextEvent(display, byref(event)) + ev_type = event.type + + if ev_type == KeyPress: + kp = cast(byref(event), POINTER(XKeyEvent)) + keysym = _XLookupKeysym(kp, 0) + app._handle_keypress(keysym) + elif ev_type == MotionNotify: + mp = cast(byref(event), POINTER(XMotionEvent)) + app._handle_mousemove(mp, refresh_rate) + elif ev_type == ButtonPress: + bp = cast(byref(event), POINTER(XButtonEvent)) + app._handle_buttonpress(bp) + elif ev_type == ButtonRelease: + bp = cast(byref(event), POINTER(XButtonEvent)) + app._handle_buttonrelease(bp) + elif ev_type == ClientMessage: + if event.xclient.data.l[0] == wm_delete_window.value: + app.state.running = False + + app.camera_update(ww, wh) + app.flashlight_update() + + # render + cam = app.state.camera + fl = app.state.flashlight + m = app.state.mouse + + _glClearColor(0.1, 0.1, 0.1, 1.0) + _glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + _glUseProgram(program) + + _glUniform2f(ulocs["camera_pos"], cam.position_x, cam.position_y) + _glUniform1f(ulocs["camera_scale"], cam.scale) + _glUniform2f(ulocs["window_size"], float(ww), float(wh)) + _glUniform2f(ulocs["screenshot_size"], float(img.width), float(img.height)) + _glUniform2f(ulocs["cursor_pos"], m.curr_x, m.curr_y) + _glUniform1f(ulocs["fl_shadow"], fl.shadow) + _glUniform1f(ulocs["fl_radius"], fl.radius) + _glUniform1f(ulocs["fl_feather"], app.config.feather) + _glUniform1f(ulocs["mirror"], 1.0 if app.state.mirror else 0.0) + + _glBindTexture(GL_TEXTURE_2D, texture) + _glBindVertexArray(vao) + _glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, None) + + _glXSwapBuffers(display, w) + _glFinish() + + # cleanup + if not windowed: + x.XSetInputFocus(display, original_focus, RevertToParent, CurrentTime) + x.XSync(display, False_) + + _libgl.glDeleteVertexArrays(1, byref(vao)) + _libgl.glDeleteBuffers(1, byref(vbo)) + _libgl.glDeleteBuffers(1, byref(ebo)) + _libgl.glDeleteProgram(program) + _libgl.glDeleteTextures(1, byref(texture)) + + _x11.XDestroyImage(img_ptr) + _libgl.glXMakeCurrent(display, 0, None) + _libgl.glXDestroyContext(display, gl_ctx) + _x11.XDestroyWindow(display, window) + _x11.XCloseDisplay(display) if __name__ == "__main__": |
