controls.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import copy
  2. import os
  3. import select
  4. import time
  5. from tooncher.actions import *
  6. try:
  7. import psutil
  8. except ImportError:
  9. psutil = False
  10. try:
  11. import Xlib.display
  12. from Xlib import X, XK, Xatom
  13. except ImportError:
  14. Xlib = False
  15. EXTENDED_CONTROLS_DEFAULT_TOGGLE_KEYSYM_NAME = 'grave'
  16. EXTENDED_CONTROLS_PID_XPROPERTY_NAME = '_TOONCHER_EXTENDED_CONTROLS_PID'
  17. TOONTOWN_WINDOW_NAME = 'Toontown Rewritten'
  18. if Xlib:
  19. EXTENDED_CONTROLS_DEFAULT_KEYSYM_MAPPINGS = {
  20. XK.XK_w: RewriteKeyEventAction(keysym=XK.XK_Up, target_engine_index=0),
  21. XK.XK_a: RewriteKeyEventAction(keysym=XK.XK_Left, target_engine_index=0),
  22. XK.XK_s: RewriteKeyEventAction(keysym=XK.XK_Down, target_engine_index=0),
  23. XK.XK_d: RewriteKeyEventAction(keysym=XK.XK_Right, target_engine_index=0),
  24. XK.XK_Control_L: RewriteKeyEventAction(keysym=XK.XK_Control_L, target_engine_index=0),
  25. XK.XK_v: RewriteKeyEventAction(keysym=XK.XK_Delete, target_engine_index=0),
  26. XK.XK_i: RewriteKeyEventAction(keysym=XK.XK_Up, target_engine_index=1),
  27. XK.XK_j: RewriteKeyEventAction(keysym=XK.XK_Left, target_engine_index=1),
  28. XK.XK_k: RewriteKeyEventAction(keysym=XK.XK_Down, target_engine_index=1),
  29. XK.XK_l: RewriteKeyEventAction(keysym=XK.XK_Right, target_engine_index=1),
  30. XK.XK_slash: RewriteKeyEventAction(keysym=XK.XK_Control_L, target_engine_index=1),
  31. XK.XK_n: RewriteKeyEventAction(keysym=XK.XK_Delete, target_engine_index=1),
  32. XK.XK_space: RewriteKeyEventAction(keysym=XK.XK_Control_L, target_engine_index=TargetEngine.All),
  33. XK.XK_e: SelectGagAction(target_engine_index=0, column_index=4, factor_y=-0.047), # elephant trunk
  34. XK.XK_o: SelectGagAction(target_engine_index=1, column_index=4, factor_y=-0.047), # elephant trunk
  35. XK.XK_f: SelectGagAction(target_engine_index=0, column_index=5, factor_y=-0.047), # foghorn
  36. XK.XK_semicolon: SelectGagAction(target_engine_index=1, column_index=5, factor_y=-0.047), # foghorn
  37. }
  38. def x_find_window(parent_window, filter_callback):
  39. matching = []
  40. for child_window in parent_window.query_tree().children:
  41. if filter_callback(child_window):
  42. matching.append(child_window)
  43. matching += x_find_window(child_window, filter_callback)
  44. return matching
  45. def x_find_window_by_pid(display, pid):
  46. pid_prop = display.intern_atom('_NET_WM_PID')
  47. def filter_callback(window):
  48. prop = window.get_full_property(pid_prop, X.AnyPropertyType)
  49. return prop and prop.value.tolist() == [pid]
  50. return x_find_window(display.screen().root, filter_callback)
  51. def x_wait_for_event(xdisplay, timeout_seconds):
  52. """ Wait up to `timeout_seconds` seconds for a xevent.
  53. Return True, if a xevent is available.
  54. Return False, if the timeout was reached. """
  55. rlist = select.select(
  56. [xdisplay.display.socket], # rlist
  57. [], # wlist
  58. [], # xlist
  59. timeout_seconds, # timeout [seconds]
  60. )[0]
  61. return len(rlist) > 0
  62. class ExtendedControls:
  63. def __init__(self, primary_engine_pid, primary_engine_window_name=None,
  64. toggle_keysym_name=EXTENDED_CONTROLS_DEFAULT_TOGGLE_KEYSYM_NAME):
  65. if not psutil:
  66. raise Exception('\n'.join([
  67. 'Extended keyboard controls require the python lib psutil to be installed.',
  68. 'Depending on your system run',
  69. '\t$ sudo apt-get install python3-psutil',
  70. 'or',
  71. '\t$ pip3 install --user psutil',
  72. ]))
  73. if not Xlib:
  74. raise Exception('\n'.join([
  75. 'Extended keyboard controls require xlib for python to be installed.',
  76. 'Depending on your system run',
  77. '\t$ sudo apt-get install python3-xlib',
  78. 'or',
  79. '\t$ pip3 install --user xlib',
  80. ]))
  81. self._primary_engine_pid = primary_engine_pid
  82. self._primary_engine_window_name = primary_engine_window_name
  83. self._xdisplay = Xlib.display.Display()
  84. self._toggle_keysym = XK.string_to_keysym(toggle_keysym_name)
  85. if self._toggle_keysym == X.NoSymbol:
  86. raise Exception("Extended keyboard controls toggle:"
  87. + " Unknown keysym name '{}'".format(toggle_keysym_name))
  88. self._keysym_mappings = copy.deepcopy(
  89. EXTENDED_CONTROLS_DEFAULT_KEYSYM_MAPPINGS,
  90. )
  91. if self._toggle_keysym in self._keysym_mappings:
  92. print("INFO Extended Controls:"
  93. + " Ignoring mapping for toggle key '{}'".format(toggle_keysym_name))
  94. self._keysym_mappings[self._toggle_keysym] \
  95. = ToggleExtendedControlsAction()
  96. self._default_action = ForwardKeyEventAction()
  97. self._primary_engine_window = None
  98. self._engine_windows_by_target_index = None
  99. self._active_key_registry = {}
  100. self._enabled = False
  101. @property
  102. def engine_running(self):
  103. return psutil.pid_exists(self._primary_engine_pid)
  104. @property
  105. def xdisplay(self):
  106. return self._xdisplay
  107. @property
  108. def primary_engine_window(self):
  109. return self._primary_engine_window
  110. def _wait_for_engine_window(self, timeout_seconds=20, search_interval_seconds=2):
  111. start_epoch = time.time()
  112. while self.engine_running and (time.time() - start_epoch) <= timeout_seconds:
  113. windows = x_find_window_by_pid(
  114. self._xdisplay,
  115. self._primary_engine_pid,
  116. )
  117. assert len(windows) <= 1
  118. if len(windows) == 1:
  119. return windows[0]
  120. time.sleep(search_interval_seconds)
  121. return None
  122. def run(self):
  123. self._primary_engine_window = self._wait_for_engine_window()
  124. if not self._primary_engine_window:
  125. raise Exception('Could not find the game\'s window.')
  126. self._grab_key(
  127. self._xdisplay.keysym_to_keycode(self._toggle_keysym),
  128. )
  129. self._primary_engine_window.change_property(
  130. self.xdisplay.intern_atom(EXTENDED_CONTROLS_PID_XPROPERTY_NAME),
  131. Xatom.CARDINAL,
  132. format=32,
  133. data=[os.getpid()],
  134. mode=X.PropModeReplace,
  135. )
  136. if self._primary_engine_window_name:
  137. self._primary_engine_window.set_wm_name(
  138. self._primary_engine_window_name,
  139. )
  140. print("INFO Changed engine's window name to {!r}".format(
  141. self._primary_engine_window_name,
  142. ))
  143. if not self.enabled:
  144. keysym_name = XK.keysym_to_string(self._toggle_keysym)
  145. print("INFO Extended Controls are currently disabled."
  146. + " Press key '{}' to enable.".format(keysym_name))
  147. while self.engine_running:
  148. while self.xdisplay.pending_events():
  149. self._handle_xevent(self._xdisplay.next_event())
  150. self._check_active_key_registry()
  151. # keep timeout low for _check_active_key_registry()
  152. # to be called frequently
  153. x_wait_for_event(self.xdisplay, timeout_seconds=0.05)
  154. def _handle_xevent(self, xevent):
  155. if isinstance(xevent, Xlib.protocol.event.KeyPress) \
  156. or isinstance(xevent, Xlib.protocol.event.KeyRelease):
  157. self._handle_xkeyevent(xevent)
  158. def _handle_xkeyevent(self, xkeyevent):
  159. self._update_active_key_registry(xkeyevent)
  160. keysym_in = self._xdisplay.keycode_to_keysym(
  161. xkeyevent.detail,
  162. index=0,
  163. )
  164. if keysym_in in self._keysym_mappings:
  165. action = self._keysym_mappings[keysym_in]
  166. else:
  167. action = self._default_action
  168. action.execute(self, xkeyevent)
  169. def enable(self):
  170. for keysym in self._keysym_mappings.keys():
  171. if keysym != self._toggle_keysym:
  172. self._grab_key(
  173. self._xdisplay.keysym_to_keycode(keysym),
  174. )
  175. self._enabled = True
  176. # reset cache
  177. self._engine_windows_by_target_index = None
  178. print("INFO Enabled Extended Controls")
  179. def disable(self):
  180. for keysym in self._keysym_mappings.keys():
  181. if keysym != self._toggle_keysym:
  182. self._ungrab_key(
  183. self._xdisplay.keysym_to_keycode(keysym),
  184. )
  185. self._enabled = False
  186. print("INFO Disabled Extended Controls")
  187. @property
  188. def enabled(self):
  189. return self._enabled
  190. def toggle(self):
  191. if self.enabled:
  192. self.disable()
  193. else:
  194. self.enable()
  195. def _grab_key(self, keycode):
  196. self.primary_engine_window.grab_key(
  197. keycode,
  198. X.AnyModifier,
  199. # owner_events
  200. # https://stackoverflow.com/questions/32122360/x11-will-xgrabpointer-prevent-other-apps-from-any-mouse-event
  201. # False,
  202. True,
  203. X.GrabModeAsync,
  204. X.GrabModeAsync,
  205. )
  206. def _ungrab_key(self, keycode):
  207. self.primary_engine_window.ungrab_key(keycode, X.AnyModifier)
  208. def find_engine_windows(self):
  209. controls_xprop = self.xdisplay.intern_atom(
  210. EXTENDED_CONTROLS_PID_XPROPERTY_NAME,
  211. )
  212. return x_find_window(
  213. self.xdisplay.screen().root,
  214. lambda w: w.get_wm_name() == TOONTOWN_WINDOW_NAME
  215. or w.get_full_property(controls_xprop, X.AnyPropertyType),
  216. )
  217. @property
  218. def engine_windows_by_target_index(self):
  219. if not self._engine_windows_by_target_index:
  220. win_by_index = {}
  221. for target_index, win in enumerate(self.find_engine_windows()):
  222. print('INFO Engine window {} has no target index, assuming {}'.format(
  223. win.id,
  224. target_index,
  225. ))
  226. if not target_index in win_by_index:
  227. win_by_index[target_index] = []
  228. win_by_index[target_index].append(win)
  229. self._engine_windows_by_target_index = win_by_index
  230. return self._engine_windows_by_target_index
  231. @property
  232. def engine_windows(self):
  233. return [w for g in self.engine_windows_by_target_index.values() for w in g]
  234. def _update_active_key_registry(self, xkeyevent):
  235. # see self._check_active_key_registry
  236. keycode = xkeyevent.detail
  237. if isinstance(xkeyevent, Xlib.protocol.event.KeyPress):
  238. self._active_key_registry[keycode] = xkeyevent
  239. elif keycode in self._active_key_registry:
  240. del self._active_key_registry[keycode]
  241. def _check_active_key_registry(self):
  242. """
  243. WORKAROUND
  244. For an unknown reason some key release events don't get queued
  245. when multiple keys are being released simultaneously.
  246. So we keep a hashmap of supposedly currently pressed keys
  247. and periodically compare it with xdispaly.query_keymap().
  248. ref: https://stackoverflow.com/q/18160792/5894777
  249. """
  250. # https://tronche.com/gui/x/xlib/input/XQueryKeymap.html
  251. keymap = self.xdisplay.query_keymap()
  252. missed_releases = []
  253. for keycode, press_event in self._active_key_registry.items():
  254. byte_index = keycode >> 3
  255. bit_index = keycode & ((1 << 3) - 1)
  256. if not keymap[byte_index] & (1 << bit_index):
  257. print("DEBUG missed release event of key {}".format(keycode))
  258. missed_releases.append(Xlib.protocol.event.KeyRelease(
  259. window=press_event.window,
  260. detail=press_event.detail,
  261. state=press_event.state,
  262. root_x=press_event.root_x,
  263. root_y=press_event.root_y,
  264. event_x=press_event.event_x,
  265. event_y=press_event.event_y,
  266. child=press_event.child,
  267. root=press_event.root,
  268. time=X.CurrentTime,
  269. same_screen=press_event.same_screen,
  270. ))
  271. for release_event in missed_releases:
  272. self._handle_xkeyevent(release_event)