pyside_utils.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933
  1. """
  2. Copyright (c) Contributors to the Open 3D Engine Project.
  3. For complete copyright and license terms please see the LICENSE at the root of this distribution.
  4. SPDX-License-Identifier: Apache-2.0 OR MIT
  5. """
  6. import azlmbr.qt
  7. import azlmbr.qt_helpers
  8. import asyncio
  9. import re
  10. from shiboken2 import wrapInstance, getCppPointer
  11. from PySide2 import QtCore, QtWidgets, QtGui, QtTest
  12. from PySide2.QtWidgets import QAction, QWidget
  13. from PySide2.QtCore import Qt
  14. from PySide2.QtTest import QTest
  15. import traceback
  16. import threading
  17. import types
  18. qApp = QtWidgets.QApplication.instance()
  19. class LmbrQtEventLoop(asyncio.AbstractEventLoop):
  20. def __init__(self):
  21. self.running = False
  22. self.shutdown = threading.Event()
  23. self.blocked_events = set()
  24. self.finished_events = set()
  25. self.queue = []
  26. self._wait_future = None
  27. self._event_loop_nesting = 0
  28. def get_debug(self):
  29. return False
  30. def time(self):
  31. return azlmbr.qt_helpers.time()
  32. def wait_for_condition(self, condition, action, on_timeout=None, timeout=1.0):
  33. timeout = self.time() + timeout if timeout is not None else None
  34. def callback(time):
  35. # Run our action and remove us from the queue if our condition is satisfied
  36. if condition():
  37. action()
  38. return True
  39. # Give up if timeout has elapsed
  40. if time > timeout:
  41. if on_timeout is not None:
  42. on_timeout()
  43. return True
  44. return False
  45. self.queue.append((callback))
  46. def event_loop(self):
  47. time = self.time()
  48. def run_event(event):
  49. if event in self.blocked_events or event in self.finished_events:
  50. return False
  51. self.blocked_events.add(event)
  52. try:
  53. if event(time):
  54. self.finished_events.add(event)
  55. except Exception:
  56. traceback.print_exc()
  57. self.finished_events.add(event)
  58. finally:
  59. self.blocked_events.remove(event)
  60. self._event_loop_nesting += 1
  61. try:
  62. for event in self.queue:
  63. run_event(event)
  64. finally:
  65. self._event_loop_nesting -= 1
  66. # Clear out any finished events if the queue is safe to mutate
  67. if self._event_loop_nesting == 0:
  68. self.queue = [event for event in self.queue if event not in self.finished_events]
  69. self.finished_events = set()
  70. if not self.running or self._wait_future is not None and self._wait_future.done():
  71. self.close()
  72. def run_until_shutdown(self):
  73. # Run our event loop callback (via azlmbr.qt_helpers) by pumping the Qt event loop
  74. # azlmbr.qt_helpers will attempt to ensure our event loop is always run, even when a
  75. # new event loop is started and run from the main event loop
  76. self.running = True
  77. self.shutdown.clear()
  78. azlmbr.qt_helpers.set_loop_callback(self.event_loop)
  79. while not self.shutdown.is_set():
  80. qApp.processEvents(QtCore.QEventLoop.AllEvents, 0)
  81. def run_forever(self):
  82. self._wait_future = None
  83. self.run_until_shutdown()
  84. def run_until_complete(self, future):
  85. # Wrap coroutines into Tasks (future-like analogs)
  86. if isinstance(future, types.CoroutineType):
  87. future = self.create_task(future)
  88. self._wait_future = future
  89. self.run_until_shutdown()
  90. def _timer_handle_cancelled(self, handle):
  91. pass
  92. def is_running(self):
  93. return self.running
  94. def is_closed(self):
  95. return not azlmbr.qt_helpers.loop_is_running()
  96. def stop(self):
  97. self.running = False
  98. def close(self):
  99. self.running = False
  100. self.shutdown.set()
  101. azlmbr.qt_helpers.clear_loop_callback()
  102. def shutdown_asyncgens(self):
  103. pass
  104. def call_exception_handler(self, context):
  105. try:
  106. raise context.get('exception', None)
  107. except:
  108. traceback.print_exc()
  109. def call_soon(self, callback, *args, **kw):
  110. h = asyncio.Handle(callback, args, self)
  111. def callback_wrapper(time):
  112. if not h.cancelled():
  113. h._run()
  114. return True
  115. self.queue.append(callback_wrapper)
  116. return h
  117. def call_later(self, delay, callback, *args, **kw):
  118. if delay < 0:
  119. raise Exception("Can't schedule in the past")
  120. return self.call_at(self.time() + delay, callback, *args)
  121. def call_at(self, when, callback, *args, **kw):
  122. h = asyncio.TimerHandle(when, callback, args, self)
  123. h._scheduled = True
  124. def callback_wrapper(time):
  125. if time > when:
  126. if not h.cancelled():
  127. h._run()
  128. return True
  129. return False
  130. self.queue.append(callback_wrapper)
  131. return h
  132. def create_task(self, coro):
  133. return asyncio.Task(coro, loop=self)
  134. def create_future(self):
  135. return asyncio.Future(loop=self)
  136. class EventLoopTimeoutException(Exception):
  137. pass
  138. event_loop = LmbrQtEventLoop()
  139. def wait_for_condition(condition, timeout=1.0):
  140. """
  141. Asynchronously waits for `condition` to evaluate to True.
  142. condition: A function with the signature def condition() -> bool
  143. This condition will be evaluated until it evaluates to True or the timeout elapses
  144. timeout: The time in seconds to wait - if 0, this will wait forever
  145. Throws pyside_utils.EventLoopTimeoutException on timeout.
  146. """
  147. future = event_loop.create_future()
  148. def on_complete():
  149. future.set_result(True)
  150. def on_timeout():
  151. future.set_exception(EventLoopTimeoutException())
  152. event_loop.wait_for_condition(condition, on_complete, on_timeout=on_timeout, timeout=timeout)
  153. return future
  154. async def wait_for(expression, timeout=1.0):
  155. """
  156. Asynchronously waits for "expression" to evaluate to a non-None value,
  157. then returns that value.
  158. expression: A function with the signature def expression() -> Generic[Any,None]
  159. The result of expression will be returned as soon as it returns a non-None value.
  160. timeout: The time in seconds to wait - if 0, this will wait forever
  161. Throws pyside_utils.EventLoopTimeoutException on timeout.
  162. """
  163. result = None
  164. def condition():
  165. nonlocal result
  166. result = expression()
  167. return result is not None
  168. await wait_for_condition(condition, timeout)
  169. return result
  170. def run_soon(fn):
  171. """
  172. Runs a function on the event loop to enable asynchronous execution.
  173. fn: The function to run, should be a function that takes no arguments
  174. Returns a future that will be popualted with the result of fn or the exception it threw.
  175. """
  176. future = event_loop.create_future()
  177. def coroutine():
  178. try:
  179. fn()
  180. future.set_result(True)
  181. except Exception as e:
  182. future.set_exception(e)
  183. event_loop.call_soon(coroutine)
  184. return future
  185. def run_async(awaitable):
  186. """
  187. Synchronously runs a coroutine or a future on the event loop.
  188. This can be used in lieu of "await" in non-async functions.
  189. awaitable: The coroutine or future to await.
  190. Returns the result of operation specified.
  191. """
  192. if isinstance(awaitable, types.CoroutineType):
  193. awaitable = event_loop.create_task(awaitable)
  194. event_loop.run_until_complete(awaitable)
  195. return awaitable.result()
  196. def wrap_async(fn):
  197. """
  198. This decorator enables an async function's execution from a synchronous one.
  199. For example:
  200. @pyside_utils.wrap_async
  201. async def foo():
  202. result = await long_operation()
  203. return result
  204. def non_async_fn():
  205. x = foo() # this will return the correct result by executing the event loop
  206. fn: The function to wrap
  207. Returns the decorated function.
  208. """
  209. def wrapper(*args, **kw):
  210. result = fn(*args, **kw)
  211. return run_async(result)
  212. return wrapper
  213. def get_editor_main_window():
  214. """
  215. Fetches the main Editor instance of QMainWindow for use with PySide tests
  216. :return Instance of QMainWindow for the Editor
  217. """
  218. params = azlmbr.qt.QtForPythonRequestBus(azlmbr.bus.Broadcast, "GetQtBootstrapParameters")
  219. editor_id = QtWidgets.QWidget.find(params.mainWindowId)
  220. main_window = wrapInstance(int(getCppPointer(editor_id)[0]), QtWidgets.QMainWindow)
  221. return main_window
  222. def get_action_for_menu_path(editor_window: QtWidgets.QMainWindow, main_menu_item: str, *menu_item_path: str):
  223. """
  224. main_menu_item: Main menu item among the MenuBar actions. Ex: "File"
  225. menu_item_path: Path to any nested menu item. Ex: "Viewport", "Goto Coordinates"
  226. returns: QAction object for the corresponding path.
  227. """
  228. # Check if path is valid
  229. menu_bar = editor_window.menuBar()
  230. menu_bar_actions = [index.iconText() for index in menu_bar.actions()]
  231. # Verify if the given Menu exists in the Menubar
  232. if main_menu_item not in menu_bar_actions:
  233. print(f"QAction not found for main menu item '{main_menu_item}'")
  234. return None
  235. curr_action = menu_bar.actions()[menu_bar_actions.index(main_menu_item)]
  236. curr_menu = curr_action.menu()
  237. for index, element in enumerate(menu_item_path):
  238. curr_menu_actions = [index.iconText() for index in curr_menu.actions()]
  239. if element not in curr_menu_actions:
  240. print(f"QAction not found for menu item '{element}'")
  241. return None
  242. if index == len(menu_item_path) - 1:
  243. return curr_menu.actions()[curr_menu_actions.index(element)]
  244. curr_action = curr_menu.actions()[curr_menu_actions.index(element)]
  245. curr_menu = curr_action.menu()
  246. return None
  247. def _pattern_to_dict(pattern, **kw):
  248. """
  249. Helper function, turns a pattern match parameter into a normalized dictionary
  250. """
  251. def is_string_or_regex(x):
  252. return isinstance(x, str) or isinstance(x, re.Pattern)
  253. # If it's None, just make an empty dict
  254. if pattern is None:
  255. pattern = {}
  256. # If our pattern is a string or regex, turn it into a text match
  257. elif is_string_or_regex(pattern):
  258. pattern = dict(text=pattern)
  259. # If our pattern is an (int, int) tuple, turn it into a row/column match
  260. elif isinstance(pattern, tuple) and isinstance(pattern[0], int) and isinstance(pattern[1], int):
  261. pattern = dict(row=pattern[0], column=pattern[1])
  262. # If our pattern is a QObject type, turn it into a type match
  263. elif isinstance(pattern, type(QtCore.QObject)):
  264. pattern = dict(type=pattern)
  265. # Otherwise assume it's a dict and make a copy
  266. else:
  267. pattern = dict(pattern)
  268. # Merge with any kw arguments
  269. for key, value in kw.items():
  270. pattern[key] = value
  271. return pattern
  272. def _match_pattern(obj, pattern):
  273. """
  274. Helper function, determines whether obj matches the pattern specified by pattern.
  275. It is required that pattern is normalized into a dict before calling this.
  276. """
  277. def compare(value1, value2):
  278. # Do a regex search if it's a regex, otherwise do a normal compare
  279. if isinstance(value2, re.Pattern):
  280. return re.search(value2, value1)
  281. return value1 == value2
  282. item_roles = Qt.ItemDataRole.values.values()
  283. for key, value in pattern.items():
  284. if key == "type": # Class type
  285. if not isinstance(obj, value):
  286. return False
  287. elif key == "text": # Default 'text' path, depends on type
  288. text_values = []
  289. def get_from_attrs(*args):
  290. for attr in args:
  291. try:
  292. text_values.append(getattr(obj, attr)())
  293. except Exception:
  294. pass
  295. # Use any of the following fields for default matching, if they're defined
  296. get_from_attrs("text", "objectName", "windowTitle")
  297. # Additionally, use the DisplayRole for QModelIndexes
  298. if isinstance(obj, QtCore.QModelIndex):
  299. text_values.append(obj.data(Qt.DisplayRole))
  300. if not any(compare(text, value) for text in text_values):
  301. return False
  302. elif key in item_roles: # QAbstractItemModel display role
  303. if not isinstance(obj, QtCore.QModelIndex):
  304. raise RuntimeError(f"Attempted to match data role on unsupported object {obj}")
  305. if not compare(obj.data(key), value):
  306. return False
  307. elif hasattr(obj, key):
  308. # Look up our key on the object itself
  309. objectValue = getattr(obj, key)
  310. # Invoke it if it's a getter
  311. if callable(objectValue):
  312. objectValue = objectValue()
  313. if not compare(objectValue, value):
  314. return False
  315. else:
  316. return False
  317. return True
  318. def get_child_indexes(model, parent_index=QtCore.QModelIndex()):
  319. indexes = [parent_index]
  320. while len(indexes) > 0:
  321. parent_index = indexes.pop(0)
  322. for row in range(model.rowCount(parent_index)):
  323. # FIXME
  324. # PySide appears to have a bug where-in it thinks columnCount is private
  325. # Bail gracefully for now, we can add a C++ wrapper to work around if needed
  326. try:
  327. column_count = model.columnCount(parent_index)
  328. except Exception:
  329. column_count = 1
  330. for col in range(column_count):
  331. cur_index = model.index(row, col, parent_index)
  332. yield cur_index
  333. def _get_children(obj):
  334. """
  335. Helper function. Get the direct descendants from a given PySide object.
  336. This includes all: QObject children, QActions owned by the object, and QModelIndexes if applicable
  337. """
  338. if isinstance(obj, QtCore.QObject):
  339. yield from obj.children()
  340. if isinstance(obj, QtWidgets.QWidget):
  341. yield from obj.actions()
  342. if isinstance(obj, (QtWidgets.QAbstractItemView, QtCore.QModelIndex)):
  343. model = obj.model()
  344. if model is None:
  345. return
  346. # For a QAbstractItemView (e.g. QTreeView, QListView), the parent index
  347. # will be an invalid QModelIndex(), which will use find all indexes on the root.
  348. # For a QModelIndex, we use the actual QModelIndex as the parent_index so that
  349. # it will find any child indexes under it
  350. parent_index = QtCore.QModelIndex()
  351. if isinstance(obj, QtCore.QModelIndex):
  352. parent_index = obj
  353. yield from get_child_indexes(model, parent_index)
  354. def _get_parents_to_search(obj_entry_or_list):
  355. """
  356. Helper function, turns obj_entry_or_list into a list of parents to search
  357. If obj_entry_or_list is None, returns all visible top level widgets
  358. If obj_entry_or_list is iterable, return it as a list
  359. Otherwise, return a list containing obj_entry_or_list
  360. """
  361. if obj_entry_or_list is None:
  362. return [widget for widget in QtWidgets.QApplication.topLevelWidgets() if widget.isVisible()]
  363. try:
  364. return list(obj_entry_or_list)
  365. except TypeError:
  366. return [obj_entry_or_list]
  367. def find_children_by_pattern(obj=None, pattern=None, recursive=True, **kw):
  368. """
  369. Finds the children of an object that match a given pattern.
  370. See find_child_by_pattern for more information on usage.
  371. """
  372. pattern = _pattern_to_dict(pattern, **kw)
  373. parents_to_search = _get_parents_to_search(obj)
  374. while len(parents_to_search) > 0:
  375. parent = parents_to_search.pop(0)
  376. for child in _get_children(parent):
  377. if _match_pattern(child, pattern):
  378. yield child
  379. if recursive:
  380. parents_to_search.append(child)
  381. def find_child_by_pattern(obj=None, pattern=None, recursive=True, **kw):
  382. """
  383. Finds the child of an object that matches a given pattern.
  384. A "child" in this context is not necessarily a QObject child.
  385. QActions are also considered children, as are the QModelIndex children of QAbstractItemViews.
  386. obj: The object to search - should be either a QObject or a QModelIndex, or a list of them
  387. If None this will search all top level windows.
  388. pattern: The pattern to match, the first child that matches all of the criteria specified will
  389. be returned. This is a dictionary with any combination of the following:
  390. - "text": generic text to match, will search object names for QObjects, display role text
  391. for QModelIndexes, or action text() for QActions
  392. - "type": a class type, e.g. QtWidgets.QMenu, a child will only match if it's of this type
  393. - "row" / "column": integer row and column indices of a QModelIndex
  394. - "type": type class (e.g. PySide.QtWidgets.QComboBox) that the object must inherit from
  395. - A Qt.ItemDataRole: matches for QModelIndexes with data of a given value
  396. - Any other fields will fall back on being looked up on the object itself by name, e.g.
  397. {"windowTitle": "Foo"} would match a windowTitle named "Foo"
  398. Any instances where a field is specified as text can also be specified as a regular expression:
  399. find_child_by_pattern(obj, {text: re.compile("Foo_.*")}) would find a child with text starting
  400. with "Foo_"
  401. For convenience, these parameter types may also be specified as keyword arguments:
  402. find_child_by_pattern(obj, text="foo", type=QtWidgets.QAction)
  403. is equivalent to
  404. find_child_by_pattern(obj, {"text": "foo", "type": QtWidgets.QAction})
  405. If pattern is specified as a string, it will turn into a pattern matching "text":
  406. find_child_by_pattern(obj, "foo")
  407. is equivalent to
  408. find_child_by_pattern(obj, {"text": "foo"})
  409. If a pattern is specified as an (int, int) tuple, it will turn into a row/column match:
  410. find_child_by_pattern(obj, (0, 2))
  411. is equivalent to
  412. find_child_by_pattern(obj, {"row": 0, "column": 2})
  413. If a pattern is specified as a type, like PySide.QtWidgets.QLabel, it will turn into a type match:
  414. find_child_by_pattern(obj, PySide.QtWidgets.QLabel)
  415. is equivalent to
  416. find_child_by_pattern(obj, {"type": PySide.QtWidgets.QLabel})
  417. """
  418. # Return the first match result, if found
  419. for match in find_children_by_pattern(obj, pattern=pattern, recursive=recursive, **kw):
  420. return match
  421. return None
  422. def find_child_by_hierarchy(parent, *patterns, child_index=0):
  423. """
  424. Searches for a hierarchy of children descending from parent.
  425. parent: The Qt object (or list of Qt obejcts) to search within
  426. If none, this will search all top level windows.
  427. patterns: A list of patterns to match to find a hierarchy of descendants.
  428. These patterns will be tested in order.
  429. For example, to look for the QComboBox in a hierarchy like the following:
  430. QWidget (window)
  431. -QTabWidget
  432. -QWidget named "m_exampleTab"
  433. -QComboBox
  434. One might invoke:
  435. find_child_by_hierarchy(window, QtWidgets.QTabWidget, "m_exampleTab", QtWidgets.QComboBox)
  436. Alternatively, "..." may be specified in place of a parent, where the hierarchy will match any
  437. ancestors along the path, so the above might be shortened to:
  438. find_child_by_hierarchy(window, ..., "m_exampleTab", QtWidgets.QComboBox)
  439. """
  440. search_recursively = False
  441. current_objects = _get_parents_to_search(parent)
  442. for pattern in patterns:
  443. # If it's an ellipsis, do the next search recursively as we're looking for any number of intermediate ancestors
  444. if pattern is ...:
  445. search_recursively = True
  446. continue
  447. candidates = []
  448. for parent_candidate in current_objects:
  449. candidates += find_children_by_pattern(parent_candidate, pattern=pattern, recursive=search_recursively)
  450. if len(candidates) == 0:
  451. return None
  452. current_objects = candidates
  453. search_recursively = False
  454. return current_objects[child_index]
  455. async def wait_for_child_by_hierarchy(parent, *patterns, timeout=1.0):
  456. """
  457. Searches for a hierarchy of children descending from parent until timeout occurs.
  458. Returns a future that will result in either the found child or an EventLoopTimeoutException.
  459. See find_child_by_hierarchy for usage information.
  460. """
  461. match = None
  462. def condition():
  463. nonlocal match
  464. match = find_child_by_hierarchy(parent, *patterns)
  465. return match is not None
  466. await wait_for_condition(condition, timeout)
  467. return match
  468. async def wait_for_child_by_pattern(obj=None, pattern=None, recursive=True, timeout=1.0, **kw):
  469. """
  470. Finds the child of an object that matches a given pattern.
  471. Returns a future that will result in either the found child or an EventLoopTimeoutException.
  472. See find_child_by_hierarchy for usage information.
  473. """
  474. match = None
  475. def condition():
  476. nonlocal match
  477. match = find_child_by_pattern(obj, pattern, recursive, **kw)
  478. return match is not None
  479. await wait_for_condition(condition, timeout)
  480. return match
  481. def find_child_by_property(obj, obj_type, property_name, property_value, reg_exp_search=False):
  482. """
  483. Finds the child of an object which has the property name matching the property value
  484. of type obj_type
  485. obj: The property value is searched through obj children
  486. obj_type: Type of object to be matched
  487. property_name: Property of the child which should be verified for the required value.
  488. property_value: Property value that needs to be matched
  489. reg_exp_search: If True searches for the property_value based on re search. Defaults to False.
  490. """
  491. for child in obj.children():
  492. if reg_exp_search and re.search(property_value, getattr(child, property_name)()):
  493. return child
  494. if not reg_exp_search and isinstance(child, obj_type) and getattr(child, property_name)() == property_value:
  495. return child
  496. return None
  497. def get_item_view_index(item_view, row, column=0, parent=QtCore.QModelIndex()):
  498. """
  499. Retrieve the index for a specified row/column, with optional parent
  500. This is necessary when needing to reference into nested hierarchies in a QTreeView
  501. item_view: The QAbstractItemView instance
  502. row: The requested row index
  503. column: The requested column index (defaults to 0 in case of single column)
  504. parent: Parent index (defaults to invalid)
  505. """
  506. item_model = item_view.model()
  507. model_index = item_model.index(row, column, parent)
  508. return model_index
  509. def get_item_view_index_rect(item_view, index):
  510. """
  511. Gets the QRect for a given index in a QAbstractItemView (e.g. QTreeView, QTableView, QListView).
  512. This is helpful because for sending mouse events to a QAbstractItemView, you have to send them to
  513. the viewport() widget of the QAbstractItemView.
  514. item_view: The QAbstractItemView instance
  515. index: A QModelIndex for the item index
  516. """
  517. return item_view.visualRect(index)
  518. def item_view_index_mouse_click(item_view, index, button=QtCore.Qt.LeftButton, modifier=QtCore.Qt.NoModifier):
  519. """
  520. Helper method version of QTest.mouseClick for injecting mouse clicks on a QAbstractItemView
  521. item_view: The QAbstractItemView instance
  522. index: A QModelIndex for the item index to be clicked
  523. """
  524. item_index_rect = get_item_view_index_rect(item_view, index)
  525. item_index_center = item_index_rect.center()
  526. # For QAbstractItemView widgets, the events need to be forwarded to the actual viewport() widget
  527. QTest.mouseClick(item_view.viewport(), button, modifier, item_index_center)
  528. def item_view_mouse_click(item_view, row, column=0, button=QtCore.Qt.LeftButton, modifier=QtCore.Qt.NoModifier):
  529. """
  530. Helper method version of 'item_view_index_mouse_click' using a row, column instead of a QModelIndex
  531. item_view: The QAbstractItemView instance
  532. row: The requested row index
  533. column: The requested column index (defaults to 0 in case of single column)
  534. """
  535. index = get_item_view_index(item_view, row, column)
  536. item_view_index_mouse_click(item_view, index, button, modifier)
  537. async def wait_for_action_in_menu(menu, pattern, timeout=1.0):
  538. """
  539. Finds a QAction inside a menu, based on the specified pattern.
  540. menu: The QMenu to search
  541. pattern: The action text or pattern to match (see find_child_by_pattern)
  542. If pattern specifies a QWidget, this will search for the associated QWidgetAction
  543. """
  544. action = await wait_for_child_by_pattern(menu, pattern, timeout=timeout)
  545. if action is None:
  546. raise TimeoutError(f"Failed to find context menu action for {pattern}")
  547. # If we've found a valid QAction, we're good to go
  548. if hasattr(action, 'trigger'):
  549. return action
  550. # If pattern matches a widget and not a QAction, look for an associated QWidgetAction
  551. widget_actions = find_children_by_pattern(menu, type=QtWidgets.QWidgetAction)
  552. underlying_widget_action = None
  553. for widget_action in widget_actions:
  554. widgets_to_check = [widget_action.defaultWidget()] + widget_action.createdWidgets()
  555. for check_widget in widgets_to_check:
  556. if action in _get_children(check_widget):
  557. underlying_widget_action = widget_action
  558. break
  559. if underlying_widget_action is not None:
  560. action = underlying_widget_action
  561. break
  562. if not hasattr(action, 'trigger'):
  563. raise RuntimeError(f"Failed to find action associated with widget {action}")
  564. return action
  565. def queue_hide_event(widget):
  566. """
  567. Explicitly post a hide event for the next frame, this can be used to ensure modal dialogs exit correctly.
  568. widget: The widget to hide
  569. """
  570. qApp.postEvent(widget, QtGui.QHideEvent())
  571. async def wait_for_destroyed(obj, timeout=1.0):
  572. """
  573. Waits for a QObject (including a widget) to be fully destroyed
  574. This can be used to wait for a modal dialog to shut down properly
  575. obj: The object to wait on destruction
  576. timeout: The time, in seconds to wait. 0 for an indefinite wait.
  577. """
  578. was_destroyed = False
  579. def on_destroyed():
  580. nonlocal was_destroyed
  581. was_destroyed = True
  582. obj.destroyed.connect(on_destroyed)
  583. return await wait_for_condition(lambda: was_destroyed, timeout=timeout)
  584. async def close_modal(modal_widget, timeout=1.0):
  585. """
  586. Closes a modal dialog and waits for it to be cleaned up.
  587. This attempts to ensure the modal event loop gets properly exited.
  588. modal_widget: The widget to close
  589. timeout: The time, in seconds, to wait. 0 for an indefinite wait.
  590. """
  591. queue_hide_event(modal_widget)
  592. return await wait_for_destroyed(modal_widget, timeout=timeout)
  593. def trigger_context_menu_entry(widget, pattern, pos=None, index=None):
  594. """
  595. Trigger a context menu event on a widget and activate an entry
  596. widget: The widget to trigger the event on
  597. pattern: The action text or pattern to match (see find_child_by_pattern)
  598. pos: Optional, the QPoint to set as the event origin
  599. index: Optional, the QModelIndex to click in widget
  600. widget must be a QAbstractItemView
  601. """
  602. async def async_wrapper():
  603. menu = await open_context_menu(widget, pos=pos, index=index)
  604. action = await wait_for_action_in_menu(menu, pattern)
  605. action.trigger()
  606. queue_hide_event(menu)
  607. result = async_wrapper()
  608. # If we have an event loop, go ahead and just return the coroutine
  609. # Otherwise, do a synchronous wait
  610. if event_loop.is_running():
  611. return result
  612. else:
  613. return run_async(result)
  614. async def open_context_menu(widget, pos=None, index=None, timeout=5.0):
  615. """
  616. Trigger a context menu event on a widget
  617. widget: The widget to trigger the event on
  618. pos: Optional, the QPoint to set as the event origin
  619. index: Optional, the QModelIndex to click in widget
  620. widget must be a QAbstractItemView
  621. Returns the menu that was created.
  622. """
  623. if index is not None:
  624. if pos is not None:
  625. raise RuntimeError("Error: 'index' and 'pos' are mutually exclusive")
  626. pos = widget.visualRect(index).center()
  627. parent = widget
  628. widget = widget.viewport()
  629. pos = widget.mapFrom(parent, pos)
  630. if pos is None:
  631. pos = widget.rect().center()
  632. # Post both a mouse event and a context menu to let the widget handle whichever is appropriate
  633. qApp.postEvent(widget, QtGui.QContextMenuEvent(QtGui.QContextMenuEvent.Mouse, pos))
  634. QtTest.QTest.mouseClick(widget, Qt.RightButton, Qt.NoModifier, pos)
  635. menu = None
  636. # Wait for a menu popup
  637. def menu_has_focus():
  638. nonlocal menu
  639. for fw in [QtWidgets.QApplication.activePopupWidget(), QtWidgets.QApplication.activeModalWidget(),
  640. QtWidgets.QApplication.focusWidget(), QtWidgets.QApplication.activeWindow()]:
  641. if fw and isinstance(fw, QtWidgets.QMenu) and fw.isVisible():
  642. menu = fw
  643. return True
  644. return False
  645. await wait_for_condition(menu_has_focus, timeout)
  646. return menu
  647. def move_mouse(widget, position):
  648. """
  649. Helper method to move the mouse to a specified position on a widget
  650. widget: The widget to trigger the event on
  651. position: The QPoint (local to widget) to move the mouse to
  652. """
  653. # For some reason, Qt wouldn't register the mouse movement correctly unless both of these ways are invoked.
  654. # The QTest.mouseMove seems to update the global cursor position, but doesn't always result in the MouseMove event being
  655. # triggered, which prevents drag/drop being able to be simulated.
  656. # Similarly, if only the MouseMove event is sent by itself to the core application, the global cursor position wasn't
  657. # updated properly, so drag/drop logic that depends on grabbing the globalPos didn't work.
  658. QtTest.QTest.mouseMove(widget, position)
  659. event = QtGui.QMouseEvent(QtCore.QEvent.MouseMove, position, widget.mapToGlobal(position), QtCore.Qt.LeftButton, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier)
  660. QtCore.QCoreApplication.sendEvent(widget, event)
  661. def drag_and_drop(source, target, source_point = QtCore.QPoint(), target_point = QtCore.QPoint()):
  662. """
  663. Simulate a drag/drop event from a source object to a specified target
  664. This has special case handling if the source is a QDockWidget (for docking) vs normal drag/drop
  665. source: The source object to initiate the drag from
  666. This is either a QWidget, or a tuple of (QAbstractItemView, QModelIndex) for dragging an item view item
  667. target: The target object to drop on after dragging
  668. This is either a QWidget, or a tuple of (QAbstractItemView, QModelIndex) for dropping on an item view item
  669. source_point: Optional, The QPoint to initiate the drag from. If none is specified, the center of the source will be used.
  670. target_point: Optional, The QPoint to drop on. If none is specified, the center of the target will be used.
  671. """
  672. # Flag if this drag/drop is for docking, which has some special cases
  673. docking = False
  674. # If the source is a tuple of (QAbstractItemView, QModelIndex), we need to use the
  675. # viewport() as the source, and find the location of the index
  676. if isinstance(source, tuple) and len(source) == 2:
  677. source_item_view = source[0]
  678. source_widget = source_item_view.viewport()
  679. source_model_index = source[1]
  680. source_rect = source_item_view.visualRect(source_model_index)
  681. else:
  682. # There are some special case actions if we are doing this drag for docking,
  683. # so figure this out by checking if the source is a QDockWidget
  684. if isinstance(source, QtWidgets.QDockWidget):
  685. docking = True
  686. source_widget = source
  687. source_rect = source.rect()
  688. # If the target is a tuple of (QAbstractItemView, QModelIndex), we need to use the
  689. # viewport() as the target, and find the location of the index
  690. if isinstance(target, tuple) and len(target) == 2:
  691. target_item_view = target[0]
  692. target_widget = target_item_view.viewport()
  693. target_model_index = target[1]
  694. target_rect = target_item_view.visualRect(target_model_index)
  695. else:
  696. # If we are doing a drag for docking, we actually want all the mouse events
  697. # to still be directed through the source widget
  698. if docking:
  699. target_widget = source_widget
  700. else:
  701. target_widget = target
  702. target_rect = target.rect()
  703. # If no source_point is specified, we need to find the center point of
  704. # the source widget
  705. if source_point.isNull():
  706. # If we are dragging for docking, initiate the drag from the center of the
  707. # dock widget title bar
  708. if docking:
  709. title_bar_widget = source.titleBarWidget()
  710. if title_bar_widget:
  711. source_point = title_bar_widget.geometry().center()
  712. else:
  713. raise RuntimeError("No titleBarWidget found for QDockWidget")
  714. # Otherwise, can just find the center of the rect
  715. else:
  716. source_point = source_rect.center()
  717. # If no target_point was specified, we need to find the center point of the target widget
  718. if target_point.isNull():
  719. target_point = target_rect.center()
  720. # If we are dragging for docking and we aren't dragging within the same source/target,
  721. # the mouse movements need to be directed to the source_widget, so we need to use the
  722. # difference in global positions of our source and target widgets to adjust the target_point
  723. # to be relative to the source
  724. if docking and source != target:
  725. source_top_left = source.mapToGlobal(QtCore.QPoint(0, 0))
  726. target_top_left = target.mapToGlobal(QtCore.QPoint(0, 0))
  727. offset = target_top_left - source_top_left
  728. target_point += offset
  729. # Move the mouse to the source spot where we will start the drag
  730. move_mouse(source_widget, source_point)
  731. # Press the left-mouse button to begin the drag
  732. QtTest.QTest.mousePress(source_widget, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier, source_point)
  733. # If we are dragging for docking, we first need to drag the mouse past the minimum distance to
  734. # trigger the docking system properly
  735. if docking:
  736. drag_distance = QtWidgets.QApplication.startDragDistance() + 1
  737. docking_trigger_point = source_point + QtCore.QPoint(drag_distance, drag_distance)
  738. move_mouse(source_widget, docking_trigger_point)
  739. # Drag the mouse to the target widget over the desired point
  740. move_mouse(target_widget, target_point)
  741. # Release the left-mouse button to complete the drop.
  742. # If we are docking, we need to delay the actual mouse button release because the docking system has
  743. # a delay before the drop zone becomes active after it has been hovered, which can be found here:
  744. # FancyDockingDropZoneConstants::dockingTargetDelayMS = 110 ms
  745. # So we need to delay greater than dockingTargetDelayMS after the final mouse move
  746. # over the intended target.
  747. delay = -1
  748. if docking:
  749. delay = 200
  750. QtTest.QTest.mouseRelease(target_widget, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier, target_point, delay)
  751. # Some drag/drop events have extra processing on the following event tick, so let those processEvents
  752. # first before we complete the drag/drop operation
  753. QtWidgets.QApplication.processEvents()
  754. def trigger_action_async(action):
  755. """
  756. Convenience function. Triggers an action asynchronously.
  757. This can be used if calling action.trigger might block (e.g. if it opens a modal dialog)
  758. action: The action to trigger
  759. """
  760. return run_soon(lambda: action.trigger())
  761. def click_button_async(button):
  762. """
  763. Convenience function. Clicks a button asynchronously.
  764. This can be used if calling button.click might block (e.g. if it opens a modal dialog)
  765. button: The button to click
  766. """
  767. return run_soon(lambda: button.click())
  768. async def wait_for_modal_widget(timeout=1.0):
  769. """
  770. Waits for an active modal widget and returns it.
  771. """
  772. return await wait_for(lambda: QtWidgets.QApplication.activeModalWidget(), timeout=timeout)
  773. async def wait_for_popup_widget(timeout=1.0):
  774. """
  775. Waits for an active popup widget and returns it.
  776. """
  777. return await wait_for(lambda: QtWidgets.QApplication.activePopupWidget(), timeout=timeout)