#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os, signal
from typing import List, Tuple
from Xlib import X, display # using X11 Windows System library
from Xlib.ext import xinput, xtest # xinput used for grabbing input devices, xtest used for delivering simulated clicks and releases
from Xlib.error import BadAccess # raised on mouse cursor grab conflicts between applications
from pyglet.libs.x11.xlib import GenericEvent # for XInput2.0 events which are of generic type
x_display = display.Display() # currently used X display
root_window = x_display.screen().root # top-level window in X windows hierarchy
verbose_flag: bool = True # if True then print all logs to stdout
running: bool = True
pressed: bool = False
grab_enabled: bool = False
original_xy: Tuple[int, int] = (0, 0) # original location of right mouse button click
xinput_opcode: int = 131 # code used by XInput2.0 function calls, usually 131
Right_Mouse_Button : int = 3 # X Window constant for right mouse button = 3
Whitelisted_Processes: List[str] = [ # processes for which right mouse button grabbing feature is turned off
"blender", "autodesk", "wineserver", "wine", "wine64", "wine-preloader", "wine64-preloader",
"explorer.exe", "services.exe", "winedevice.exe", "plugplay.exe", "rpcss.exe",
]
def is_process_running(processes: List[str]) -> bool:
try: # /proc directory contains all processes information in folders for each process
for pid_dir in os.listdir("/proc"): # loop over all entries in /proc folder
if not pid_dir.isdigit(): continue # if folder name has letters in it then it is not a process info, then skip it
# processes' directories in /proc folder have pids as their names
with open(os.path.join("/proc", pid_dir, "comm"), "r", encoding="utf-8", errors="replace") as cmd_file:
process_name = cmd_file.readline().strip() # full path to process comm file, which has only 1 line = process name
if process_name and process_name in processes:
return True
except (FileNotFoundError, PermissionError, OSError):
return False
return False
def enable_cursor_grabbing(dpy: display.Display, root) -> None:
global xinput_opcode
reply = xinput.XIQueryDevice(dpy.display, deviceid=xinput.AllDevices, opcode=xinput_opcode)
if reply is None or reply.devices is None: raise RuntimeError("Failed to query XInput2 devices.")
for device in reply.devices:
# only X devices marked as X slave pointer (mouse) (as in xinput list command) are important and being grabbed by this function
if device.use != xinput.SlavePointer: continue
try: # below function doesn't grab mouse cursor instantly, it sets listening to button events and then grabs it in position when xinput.ButtonPress event is received
# reply = xinput.passive_grab_device( # alternative wrapper function call in python-xlib
xinput.XIPassiveGrabDevice(display=dpy.display, opcode=xinput_opcode, time=X.CurrentTime,
grab_window=root, cursor=X.NONE, detail=Right_Mouse_Button, deviceid=device.deviceid,
modifiers=[xinput.AnyModifier], mask=xinput.ButtonReleaseMask, grab_type=xinput.GrabtypeButton,
grab_mode=xinput.GrabModeAsync, paired_device_mode=xinput.GrabModeAsync, owner_events=False,
) # no need to provide xinput.ButtonPress in mask, because XIPassiveGrabDevice() function causes implicit listening to xinput.ButtonPress events
dpy.flush() # provide only xinput.ButtonReleaseMask in mask to listen for this event during passive grab after right mouse button click
global grab_enabled; grab_enabled = True
except BadAccess:
if verbose_flag: print("[XI2] xinput.XIPassiveGrabDevice denied (BadAccess).")
except Exception as exception:
if verbose_flag: print(f"[XI2] xinput.XIPassiveGrabDevice failed: {exception}")
def disable_cursor_grabbing(dpy: display.Display, root) -> None:
global xinput_opcode
reply = xinput.XIQueryDevice(dpy.display, deviceid=xinput.AllDevices, opcode=xinput_opcode)
if reply is None or reply.devices is None: raise RuntimeError("Failed to query XInput2 devices.")
for device in reply.devices:
# only X devices marked as X slave pointer (mouse) (as in xinput list command) are important and being ungrabbed by this function
if device.use != xinput.SlavePointer: continue
try:
# reply = xinput.passive_ungrab_device( # alternative wrapper function call in python-xlib
xinput.XIPassiveUngrabDevice(display=dpy.display, opcode=xinput_opcode, grab_window=root,
detail=Right_Mouse_Button, deviceid=device.deviceid, grab_type=xinput.GrabtypeButton,
modifiers=[xinput.AnyModifier],
)
dpy.flush()
global grab_enabled; grab_enabled = False
except Exception as exception:
if verbose_flag: print(f"[XI2] xinput.XIPassiveUngrabDevice failed: {exception}")
def x_extension_check(dpy: display.Display) -> None:
ext_info = dpy.query_extension('XTEST')
if ext_info is not None and ext_info.present:
if verbose_flag: print("XTest extension is available.")
else: raise RuntimeError("XTest extension not available.")
ext_info = dpy.query_extension('XInputExtension')
if ext_info and ext_info.present:
if verbose_flag: print("XInput extension is available.")
else: raise RuntimeError("XInput extension not available.")
global xinput_opcode; xinput_opcode = ext_info.major_opcode
reply = xinput.XIQueryVersion(dpy.display, major_version=2, minor_version=0, opcode=xinput_opcode)
if reply is None or reply.major_version < 2:
raise RuntimeError("XInput2.0 not available (need at least 2.0).")
def event_loop(dpy: display.Display, root) -> None:
global running, pressed, grab_enabled, original_xy
while running:
if grab_enabled and is_process_running(Whitelisted_Processes):
if verbose_flag: print("Whitelisted process detected, disabling right mouse button grabbing.")
disable_cursor_grabbing(dpy, root)
elif (not grab_enabled) and (not is_process_running(Whitelisted_Processes)):
if verbose_flag: print("Whitelisted process undetected, enabling right mouse button grabbing.")
enable_cursor_grabbing(dpy, root)
x_event = dpy.next_event() # blocking call that suspends current thread until next X event is received
if verbose_flag: print(f"Received X event: {x_event}") # X event code IDs are defined in X.h file
# generic events are generated only for key presses and key releases, only when passive grab is enabled
if x_event.type == GenericEvent and x_event.extension == xinput_opcode:
if verbose_flag: print(f"Received XI Extension event with code ID: {x_event.evtype}") # 4 = xinput.ButtonPress, 5 = xinput.ButtonRelease
if x_event.data and x_event.data.detail == Right_Mouse_Button:
if x_event.evtype == xinput.ButtonPress and pressed == False:
# saving right mouse click location, no need to do anything else here, because mouse cursor will be automatically grabbed by XIPassiveGrabDevice listener on xinput.ButtonPress event
original_xy = (int(x_event.data.root_x), int(x_event.data.root_y))
if verbose_flag: print(f"[XI2] right mouse button pressed: holding pointer lock at position: {original_xy}");
pressed = True
elif x_event.evtype == xinput.ButtonRelease and pressed == True:
if verbose_flag: print("[XI2] right mouse button released: showing context menu and releasing pointer lock.")
disable_cursor_grabbing(dpy, root) # release of grab lock and start of the short interval with no grab where simulated mouse click and release is delivered
root.warp_pointer(*original_xy) # fix for a glitch that sometimes the cursor can be invisibly moved away even when mouse cursor is visually locked in the same position
dpy.flush() # Ensure the changes are immediately flushed to X display
xtest.fake_input(dpy, X.ButtonPress, Right_Mouse_Button) # simulate right mouse button click in the short interval with no passive grab
xtest.fake_input(dpy, X.ButtonRelease, Right_Mouse_Button) # simulate right mouse button release in the short interval with no passive grab
dpy.flush() # Ensure the changes are immediately flushed to X display
enable_cursor_grabbing(dpy, root) # end of the short interval with no grab where simulated mouse click and release is delivered
pressed = False
def handle_signal(signal_id, _) -> None:
signals = {signal.SIGINT: "SIGINT", signal.SIGTERM: "SIGTERM"}
if verbose_flag: print(f"Received {signals[signal_id]} signal, exiting gracefully.")
global running; running = False
os._exit(0)
def main() -> int:
signal.signal(signal.SIGINT, handle_signal) # handler to exit application gracefully on signal
signal.signal(signal.SIGTERM, handle_signal) # handler to exit application gracefully on signal
x_extension_check(x_display)
this line is used to trigger X events when windows hierarchy change to determine if right mouse button grabbing is necessary when whitelisted windows are being created or closed
root_window.change_attributes(event_mask=X.SubstructureNotifyMask)
x_display.flush()
if verbose_flag: print("Listening for right mouse button press and release X events, Ctrl+C to exit.")
event_loop(x_display, root_window)
disable_cursor_grabbing(x_display, root_window)
x_display.close()
return 0
if name == "main":
raise SystemExit(main())