from __future__ import annotations
import argparse
import ctypes
import ctypes.util
import sys
import time
Window = ctypes.c_ulong
Display = ctypes.c_void_p
class X11Driver:
def __init__(self, title: str) -> None:
x11_name = ctypes.util.find_library("X11")
xtst_name = ctypes.util.find_library("Xtst")
if not x11_name or not xtst_name:
raise RuntimeError("X11 test libraries are unavailable")
self.x11 = ctypes.CDLL(x11_name)
self.xtst = ctypes.CDLL(xtst_name)
self._declare_functions()
self.display = self.x11.XOpenDisplay(None)
if not self.display:
raise RuntimeError("could not open the current X11 display")
self.root = self.x11.XDefaultRootWindow(self.display)
self.window = self._find_window(self.root, title)
if not self.window:
raise RuntimeError(f"could not find an X11 window named {title!r}")
def _declare_functions(self) -> None:
self.x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
self.x11.XOpenDisplay.restype = Display
self.x11.XDefaultRootWindow.argtypes = [Display]
self.x11.XDefaultRootWindow.restype = Window
self.x11.XQueryTree.argtypes = [
Display,
Window,
ctypes.POINTER(Window),
ctypes.POINTER(Window),
ctypes.POINTER(ctypes.POINTER(Window)),
ctypes.POINTER(ctypes.c_uint),
]
self.x11.XQueryTree.restype = ctypes.c_int
self.x11.XFetchName.argtypes = [Display, Window, ctypes.POINTER(ctypes.c_char_p)]
self.x11.XFetchName.restype = ctypes.c_int
self.x11.XFree.argtypes = [ctypes.c_void_p]
self.x11.XRaiseWindow.argtypes = [Display, Window]
self.x11.XSetInputFocus.argtypes = [Display, Window, ctypes.c_int, ctypes.c_ulong]
self.x11.XResizeWindow.argtypes = [Display, Window, ctypes.c_uint, ctypes.c_uint]
self.x11.XWarpPointer.argtypes = [
Display,
Window,
Window,
ctypes.c_int,
ctypes.c_int,
ctypes.c_uint,
ctypes.c_uint,
ctypes.c_int,
ctypes.c_int,
]
self.x11.XStringToKeysym.argtypes = [ctypes.c_char_p]
self.x11.XStringToKeysym.restype = ctypes.c_ulong
self.x11.XKeysymToKeycode.argtypes = [Display, ctypes.c_ulong]
self.x11.XKeysymToKeycode.restype = ctypes.c_ubyte
self.x11.XFlush.argtypes = [Display]
self.x11.XCloseDisplay.argtypes = [Display]
self.xtst.XTestFakeKeyEvent.argtypes = [
Display,
ctypes.c_uint,
ctypes.c_int,
ctypes.c_ulong,
]
self.xtst.XTestFakeKeyEvent.restype = ctypes.c_int
self.xtst.XTestFakeButtonEvent.argtypes = [
Display,
ctypes.c_uint,
ctypes.c_int,
ctypes.c_ulong,
]
self.xtst.XTestFakeButtonEvent.restype = ctypes.c_int
def _title(self, window: int) -> str:
name = ctypes.c_char_p()
if not self.x11.XFetchName(self.display, window, ctypes.byref(name)) or not name.value:
return ""
try:
return name.value.decode("utf-8", errors="replace")
finally:
self.x11.XFree(name)
def _find_window(self, parent: int, title: str) -> int:
if self._title(parent) == title:
return parent
root = Window()
returned_parent = Window()
children = ctypes.POINTER(Window)()
count = ctypes.c_uint()
if not self.x11.XQueryTree(
self.display,
parent,
ctypes.byref(root),
ctypes.byref(returned_parent),
ctypes.byref(children),
ctypes.byref(count),
):
return 0
try:
for index in range(count.value):
match = self._find_window(children[index], title)
if match:
return match
finally:
if children:
self.x11.XFree(children)
return 0
def activate(self) -> None:
self.x11.XRaiseWindow(self.display, self.window)
self.x11.XSetInputFocus(self.display, self.window, 1, 0)
self.x11.XFlush(self.display)
def resize(self, width: int, height: int) -> None:
self.x11.XResizeWindow(self.display, self.window, width, height)
self.x11.XFlush(self.display)
def key(self, chord: str) -> None:
names = [part.strip() for part in chord.split("+") if part.strip()]
if not names:
raise ValueError("key chord is empty")
aliases = {
"ctrl": "Control_L",
"control": "Control_L",
"shift": "Shift_L",
"alt": "Alt_L",
"super": "Super_L",
"escape": "Escape",
"esc": "Escape",
"space": "space",
}
keycodes = []
for name in names:
keysym_name = aliases.get(name.lower(), name)
keysym = self.x11.XStringToKeysym(keysym_name.encode())
keycode = self.x11.XKeysymToKeycode(self.display, keysym)
if not keycode:
raise ValueError(f"unknown X11 key {name!r}")
keycodes.append(keycode)
self.activate()
for keycode in keycodes:
self.xtst.XTestFakeKeyEvent(self.display, keycode, True, 0)
for keycode in reversed(keycodes):
self.xtst.XTestFakeKeyEvent(self.display, keycode, False, 0)
self.x11.XFlush(self.display)
def click(self, x: int, y: int) -> None:
self.activate()
self.x11.XWarpPointer(self.display, 0, self.window, 0, 0, 0, 0, x, y)
self.xtst.XTestFakeButtonEvent(self.display, 1, True, 0)
self.xtst.XTestFakeButtonEvent(self.display, 1, False, 0)
self.x11.XFlush(self.display)
def scroll(self, x: int, y: int, steps: int) -> None:
self.activate()
self.x11.XWarpPointer(self.display, 0, self.window, 0, 0, 0, 0, x, y)
button = 5 if steps > 0 else 4
for _ in range(abs(steps)):
self.xtst.XTestFakeButtonEvent(self.display, button, True, 0)
self.xtst.XTestFakeButtonEvent(self.display, button, False, 0)
self.x11.XFlush(self.display)
def close(self) -> None:
if self.display:
self.x11.XCloseDisplay(self.display)
self.display = None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--title", default="KnottVideo")
parser.add_argument("--activate", action="store_true")
parser.add_argument("--resize", nargs=2, type=int, metavar=("WIDTH", "HEIGHT"))
parser.add_argument("--key", action="append", default=[])
parser.add_argument("--click", nargs=2, type=int, action="append", default=[])
parser.add_argument(
"--scroll",
nargs=3,
type=int,
action="append",
default=[],
metavar=("X", "Y", "STEPS"),
)
parser.add_argument("--settle", type=float, default=0.35)
args = parser.parse_args()
driver = X11Driver(args.title)
try:
if args.activate:
driver.activate()
if args.resize:
driver.resize(*args.resize)
for chord in args.key:
driver.key(chord)
time.sleep(0.08)
for x, y in args.click:
driver.click(x, y)
time.sleep(0.08)
for x, y, steps in args.scroll:
driver.scroll(x, y, steps)
time.sleep(0.08)
time.sleep(max(args.settle, 0.0))
print(f"visual gate target: {args.title} ({driver.window:#x})")
finally:
driver.close()
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (RuntimeError, ValueError) as error:
print(f"visual gate error: {error}", file=sys.stderr)
raise SystemExit(1)