You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
o3de/Tests/ly_shared/pyside_utils.py

939 lines
36 KiB
Python

"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import azlmbr.qt
import azlmbr.qt_helpers
import asyncio
import re
from shiboken2 import wrapInstance, getCppPointer
from PySide2 import QtCore, QtWidgets, QtGui, QtTest
from PySide2.QtWidgets import QAction, QWidget
from PySide2.QtCore import Qt
from PySide2.QtTest import QTest
import azlmbr.legacy.general as general
import traceback
import threading
import types
qApp = QtWidgets.QApplication.instance()
# Monkey patch static method calls
QtWidgets.QApplication.activeModalWidget = qApp.activeModalWidget
class LmbrQtEventLoop(asyncio.AbstractEventLoop):
def __init__(self):
self.running = False
self.shutdown = threading.Event()
self.blocked_events = set()
self.finished_events = set()
self.queue = []
self._wait_future = None
self._event_loop_nesting = 0
def get_debug(self):
return False
def time(self):
return azlmbr.qt_helpers.time()
def wait_for_condition(self, condition, action, on_timeout=None, timeout=1.0):
timeout = self.time() + timeout if timeout is not None else None
def callback(time):
# Run our action and remove us from the queue if our condition is satisfied
if condition():
action()
return True
# Give up if timeout has elapsed
if time > timeout:
if on_timeout is not None:
on_timeout()
return True
return False
self.queue.append((callback))
def event_loop(self):
time = self.time()
def run_event(event):
if event in self.blocked_events or event in self.finished_events:
return False
self.blocked_events.add(event)
try:
if event(time):
self.finished_events.add(event)
except Exception:
traceback.print_exc()
self.finished_events.add(event)
finally:
self.blocked_events.remove(event)
self._event_loop_nesting += 1
try:
for event in self.queue:
run_event(event)
finally:
self._event_loop_nesting -= 1
# Clear out any finished events if the queue is safe to mutate
if self._event_loop_nesting == 0:
self.queue = [event for event in self.queue if event not in self.finished_events]
self.finished_events = set()
if not self.running or self._wait_future is not None and self._wait_future.done():
self.close()
def run_until_shutdown(self):
# Run our event loop callback (via azlmbr.qt_helpers) by pumping the Qt event loop
# azlmbr.qt_helpers will attempt to ensure our event loop is always run, even when a
# new event loop is started and run from the main event loop
self.running = True
self.shutdown.clear()
azlmbr.qt_helpers.set_loop_callback(self.event_loop)
while not self.shutdown.is_set():
qApp.processEvents(QtCore.QEventLoop.AllEvents, 0)
def run_forever(self):
self._wait_future = None
self.run_until_shutdown()
def run_until_complete(self, future):
# Wrap coroutines into Tasks (future-like analogs)
if isinstance(future, types.CoroutineType):
future = self.create_task(future)
self._wait_future = future
self.run_until_shutdown()
def _timer_handle_cancelled(self, handle):
pass
def is_running(self):
return self.running
def is_closed(self):
return not azlmbr.qt_helpers.loop_is_running()
def stop(self):
self.running = False
def close(self):
self.running = False
self.shutdown.set()
azlmbr.qt_helpers.clear_loop_callback()
def shutdown_asyncgens(self):
pass
def call_exception_handler(self, context):
try:
raise context.get('exception', None)
except:
traceback.print_exc()
def call_soon(self, callback, *args, **kw):
h = asyncio.Handle(callback, args, self)
def callback_wrapper(time):
if not h.cancelled():
h._run()
return True
self.queue.append(callback_wrapper)
return h
def call_later(self, delay, callback, *args, **kw):
if delay < 0:
raise Exception("Can't schedule in the past")
return self.call_at(self.time() + delay, callback, *args)
def call_at(self, when, callback, *args, **kw):
h = asyncio.TimerHandle(when, callback, args, self)
h._scheduled = True
def callback_wrapper(time):
if time > when:
if not h.cancelled():
h._run()
return True
return False
self.queue.append(callback_wrapper)
return h
def create_task(self, coro):
return asyncio.Task(coro, loop=self)
def create_future(self):
return asyncio.Future(loop=self)
class EventLoopTimeoutException(Exception):
pass
event_loop = LmbrQtEventLoop()
def wait_for_condition(condition, timeout=1.0):
"""
Asynchronously waits for `condition` to evaluate to True.
condition: A function with the signature def condition() -> bool
This condition will be evaluated until it evaluates to True or the timeout elapses
timeout: The time in seconds to wait - if 0, this will wait forever
Throws pyside_utils.EventLoopTimeoutException on timeout.
"""
future = event_loop.create_future()
def on_complete():
future.set_result(True)
def on_timeout():
future.set_exception(EventLoopTimeoutException())
event_loop.wait_for_condition(condition, on_complete, on_timeout=on_timeout, timeout=timeout)
return future
async def wait_for(expression, timeout=1.0):
"""
Asynchronously waits for "expression" to evaluate to a non-None value,
then returns that value.
expression: A function with the signature def expression() -> Generic[Any,None]
The result of expression will be returned as soon as it returns a non-None value.
timeout: The time in seconds to wait - if 0, this will wait forever
Throws pyside_utils.EventLoopTimeoutException on timeout.
"""
result = None
def condition():
nonlocal result
result = expression()
return result is not None
await wait_for_condition(condition, timeout)
return result
def run_soon(fn):
"""
Runs a function on the event loop to enable asynchronous execution.
fn: The function to run, should be a function that takes no arguments
Returns a future that will be popualted with the result of fn or the exception it threw.
"""
future = event_loop.create_future()
def coroutine():
try:
fn()
future.set_result(True)
except Exception as e:
future.set_exception(e)
event_loop.call_soon(coroutine)
return future
def run_async(awaitable):
"""
Synchronously runs a coroutine or a future on the event loop.
This can be used in lieu of "await" in non-async functions.
awaitable: The coroutine or future to await.
Returns the result of operation specified.
"""
if isinstance(awaitable, types.CoroutineType):
awaitable = event_loop.create_task(awaitable)
event_loop.run_until_complete(awaitable)
return awaitable.result()
def wrap_async(fn):
"""
This decorator enables an async function's execution from a synchronous one.
For example:
@pyside_utils.wrap_async
async def foo():
result = await long_operation()
return result
def non_async_fn():
x = foo() # this will return the correct result by executing the event loop
fn: The function to wrap
Returns the decorated function.
"""
def wrapper(*args, **kw):
result = fn(*args, **kw)
return run_async(result)
return wrapper
def get_editor_main_window():
"""
Fetches the main Editor instance of QMainWindow for use with PySide tests
:return Instance of QMainWindow for the Editor
"""
params = azlmbr.qt.QtForPythonRequestBus(azlmbr.bus.Broadcast, "GetQtBootstrapParameters")
editor_id = QtWidgets.QWidget.find(params.mainWindowId)
main_window = wrapInstance(int(getCppPointer(editor_id)[0]), QtWidgets.QMainWindow)
return main_window
def get_action_for_menu_path(editor_window: QtWidgets.QMainWindow, main_menu_item: str, *menu_item_path: str):
"""
main_menu_item: Main menu item among the MenuBar actions. Ex: "File"
menu_item_path: Path to any nested menu item. Ex: "Viewport", "Goto Coordinates"
returns: QAction object for the corresponding path.
"""
# Check if path is valid
menu_bar = editor_window.menuBar()
menu_bar_actions = [index.iconText() for index in menu_bar.actions()]
# Verify if the given Menu exists in the Menubar
if main_menu_item not in menu_bar_actions:
print(f"QAction not found for main menu item '{main_menu_item}'")
return None
curr_action = menu_bar.actions()[menu_bar_actions.index(main_menu_item)]
curr_menu = curr_action.menu()
for index, element in enumerate(menu_item_path):
curr_menu_actions = [index.iconText() for index in curr_menu.actions()]
if element not in curr_menu_actions:
print(f"QAction not found for menu item '{element}'")
return None
if index == len(menu_item_path) - 1:
return curr_menu.actions()[curr_menu_actions.index(element)]
curr_action = curr_menu.actions()[curr_menu_actions.index(element)]
curr_menu = curr_action.menu()
return None
def _pattern_to_dict(pattern, **kw):
"""
Helper function, turns a pattern match parameter into a normalized dictionary
"""
def is_string_or_regex(x):
return isinstance(x, str) or isinstance(x, re.Pattern)
# If it's None, just make an empty dict
if pattern is None:
pattern = {}
# If our pattern is a string or regex, turn it into a text match
elif is_string_or_regex(pattern):
pattern = dict(text=pattern)
# If our pattern is an (int, int) tuple, turn it into a row/column match
elif isinstance(pattern, tuple) and isinstance(pattern[0], int) and isinstance(pattern[1], int):
pattern = dict(row=pattern[0], column=pattern[1])
# If our pattern is a QObject type, turn it into a type match
elif isinstance(pattern, type(QtCore.QObject)):
pattern = dict(type=pattern)
# Otherwise assume it's a dict and make a copy
else:
pattern = dict(pattern)
# Merge with any kw arguments
for key, value in kw.items():
pattern[key] = value
return pattern
def _match_pattern(obj, pattern):
"""
Helper function, determines whether obj matches the pattern specified by pattern.
It is required that pattern is normalized into a dict before calling this.
"""
def compare(value1, value2):
# Do a regex search if it's a regex, otherwise do a normal compare
if isinstance(value2, re.Pattern):
return re.search(value2, value1)
return value1 == value2
item_roles = Qt.ItemDataRole.values.values()
for key, value in pattern.items():
if key == "type": # Class type
if not isinstance(obj, value):
return False
elif key == "text": # Default 'text' path, depends on type
text_values = []
def get_from_attrs(*args):
for attr in args:
try:
text_values.append(getattr(obj, attr)())
except Exception:
pass
# Use any of the following fields for default matching, if they're defined
get_from_attrs("text", "objectName", "windowTitle")
# Additionally, use the DisplayRole for QModelIndexes
if isinstance(obj, QtCore.QModelIndex):
text_values.append(obj.data(Qt.DisplayRole))
if not any(compare(text, value) for text in text_values):
return False
elif key in item_roles: # QAbstractItemModel display role
if not isinstance(obj, QtCore.QModelIndex):
raise RuntimeError(f"Attempted to match data role on unsupported object {obj}")
if not compare(obj.data(key), value):
return False
elif hasattr(obj, key):
# Look up our key on the object itself
objectValue = getattr(obj, key)
# Invoke it if it's a getter
if callable(objectValue):
objectValue = objectValue()
if not compare(objectValue, value):
return False
else:
return False
return True
def get_child_indexes(model, parent_index=QtCore.QModelIndex()):
indexes = [parent_index]
while len(indexes) > 0:
parent_index = indexes.pop(0)
for row in range(model.rowCount(parent_index)):
# FIXME
# PySide appears to have a bug where-in it thinks columnCount is private
# Bail gracefully for now, we can add a C++ wrapper to work around if needed
try:
column_count = model.columnCount(parent_index)
except Exception:
column_count = 1
for col in range(column_count):
cur_index = model.index(row, col, parent_index)
yield cur_index
def _get_children(obj):
"""
Helper function. Get the direct descendants from a given PySide object.
This includes all: QObject children, QActions owned by the object, and QModelIndexes if applicable
"""
if isinstance(obj, QtCore.QObject):
yield from obj.children()
if isinstance(obj, QtWidgets.QWidget):
yield from obj.actions()
if isinstance(obj, (QtWidgets.QAbstractItemView, QtCore.QModelIndex)):
model = obj.model()
if model is None:
return
# For a QAbstractItemView (e.g. QTreeView, QListView), the parent index
# will be an invalid QModelIndex(), which will use find all indexes on the root.
# For a QModelIndex, we use the actual QModelIndex as the parent_index so that
# it will find any child indexes under it
parent_index = QtCore.QModelIndex()
if isinstance(obj, QtCore.QModelIndex):
parent_index = obj
yield from get_child_indexes(model, parent_index)
def _get_parents_to_search(obj_entry_or_list):
"""
Helper function, turns obj_entry_or_list into a list of parents to search
If obj_entry_or_list is None, returns all visible top level widgets
If obj_entry_or_list is iterable, return it as a list
Otherwise, return a list containing obj_entry_or_list
"""
if obj_entry_or_list is None:
return [widget for widget in QtWidgets.QApplication.topLevelWidgets() if widget.isVisible()]
try:
return list(obj_entry_or_list)
except TypeError:
return [obj_entry_or_list]
def find_children_by_pattern(obj=None, pattern=None, recursive=True, **kw):
"""
Finds the children of an object that match a given pattern.
See find_child_by_pattern for more information on usage.
"""
pattern = _pattern_to_dict(pattern, **kw)
parents_to_search = _get_parents_to_search(obj)
while len(parents_to_search) > 0:
parent = parents_to_search.pop(0)
for child in _get_children(parent):
if _match_pattern(child, pattern):
yield child
if recursive:
parents_to_search.append(child)
def find_child_by_pattern(obj=None, pattern=None, recursive=True, **kw):
"""
Finds the child of an object that matches a given pattern.
A "child" in this context is not necessarily a QObject child.
QActions are also considered children, as are the QModelIndex children of QAbstractItemViews.
obj: The object to search - should be either a QObject or a QModelIndex, or a list of them
If None this will search all top level windows.
pattern: The pattern to match, the first child that matches all of the criteria specified will
be returned. This is a dictionary with any combination of the following:
- "text": generic text to match, will search object names for QObjects, display role text
for QModelIndexes, or action text() for QActions
- "type": a class type, e.g. QtWidgets.QMenu, a child will only match if it's of this type
- "row" / "column": integer row and column indices of a QModelIndex
- "type": type class (e.g. PySide.QtWidgets.QComboBox) that the object must inherit from
- A Qt.ItemDataRole: matches for QModelIndexes with data of a given value
- Any other fields will fall back on being looked up on the object itself by name, e.g.
{"windowTitle": "Foo"} would match a windowTitle named "Foo"
Any instances where a field is specified as text can also be specified as a regular expression:
find_child_by_pattern(obj, {text: re.compile("Foo_.*")}) would find a child with text starting
with "Foo_"
For convenience, these parameter types may also be specified as keyword arguments:
find_child_by_pattern(obj, text="foo", type=QtWidgets.QAction)
is equivalent to
find_child_by_pattern(obj, {"text": "foo", "type": QtWidgets.QAction})
If pattern is specified as a string, it will turn into a pattern matching "text":
find_child_by_pattern(obj, "foo")
is equivalent to
find_child_by_pattern(obj, {"text": "foo"})
If a pattern is specified as an (int, int) tuple, it will turn into a row/column match:
find_child_by_pattern(obj, (0, 2))
is equivalent to
find_child_by_pattern(obj, {"row": 0, "column": 2})
If a pattern is specified as a type, like PySide.QtWidgets.QLabel, it will turn into a type match:
find_child_by_pattern(obj, PySide.QtWidgets.QLabel)
is equivalent to
find_child_by_pattern(obj, {"type": PySide.QtWidgets.QLabel})
"""
# Return the first match result, if found
for match in find_children_by_pattern(obj, pattern=pattern, recursive=recursive, **kw):
return match
return None
def find_child_by_hierarchy(parent, *patterns):
"""
Searches for a hierarchy of children descending from parent.
parent: The Qt object (or list of Qt obejcts) to search within
If none, this will search all top level windows.
patterns: A list of patterns to match to find a hierarchy of descendants.
These patterns will be tested in order.
For example, to look for the QComboBox in a hierarchy like the following:
QWidget (window)
-QTabWidget
-QWidget named "m_exampleTab"
-QComboBox
One might invoke:
find_child_by_hierarchy(window, QtWidgets.QTabWidget, "m_exampleTab", QtWidgets.QComboBox)
Alternatively, "..." may be specified in place of a parent, where the hierarchy will match any
ancestors along the path, so the above might be shortened to:
find_child_by_hierarchy(window, ..., "m_exampleTab", QtWidgets.QComboBox)
"""
search_recursively = False
current_objects = _get_parents_to_search(parent)
for pattern in patterns:
# If it's an ellipsis, do the next search recursively as we're looking for any number of intermediate ancestors
if pattern is ...:
search_recursively = True
continue
candidates = []
for parent_candidate in current_objects:
candidates += find_children_by_pattern(parent_candidate, pattern=pattern, recursive=search_recursively)
if len(candidates) == 0:
return None
current_objects = candidates
search_recursively = False
return current_objects[0]
async def wait_for_child_by_hierarchy(parent, *patterns, timeout=1.0):
"""
Searches for a hierarchy of children descending from parent until timeout occurs.
Returns a future that will result in either the found child or an EventLoopTimeoutException.
See find_child_by_hierarchy for usage information.
"""
match = None
def condition():
nonlocal match
match = find_child_by_hierarchy(parent, *patterns)
return match is not None
await wait_for_condition(condition, timeout)
return match
async def wait_for_child_by_pattern(obj=None, pattern=None, recursive=True, timeout=1.0, **kw):
"""
Finds the child of an object that matches a given pattern.
Returns a future that will result in either the found child or an EventLoopTimeoutException.
See find_child_by_hierarchy for usage information.
"""
match = None
def condition():
nonlocal match
match = find_child_by_pattern(obj, pattern, recursive, **kw)
return match is not None
await wait_for_condition(condition, timeout)
return match
def find_child_by_property(obj, obj_type, property_name, property_value, reg_exp_search=False):
"""
Finds the child of an object which has the property name matching the property value
of type obj_type
obj: The property value is searched through obj children
obj_type: Type of object to be matched
property_name: Property of the child which should be verified for the required value.
property_value: Property value that needs to be matched
reg_exp_search: If True searches for the property_value based on re search. Defaults to False.
"""
for child in obj.children():
if reg_exp_search and re.search(property_value, getattr(child, property_name)()):
return child
if not reg_exp_search and isinstance(child, obj_type) and getattr(child, property_name)() == property_value:
return child
return None
def get_item_view_index(item_view, row, column=0, parent=QtCore.QModelIndex()):
"""
Retrieve the index for a specified row/column, with optional parent
This is necessary when needing to reference into nested hierarchies in a QTreeView
item_view: The QAbstractItemView instance
row: The requested row index
column: The requested column index (defaults to 0 in case of single column)
parent: Parent index (defaults to invalid)
"""
item_model = item_view.model()
model_index = item_model.index(row, column, parent)
return model_index
def get_item_view_index_rect(item_view, index):
"""
Gets the QRect for a given index in a QAbstractItemView (e.g. QTreeView, QTableView, QListView).
This is helpful because for sending mouse events to a QAbstractItemView, you have to send them to
the viewport() widget of the QAbstractItemView.
item_view: The QAbstractItemView instance
index: A QModelIndex for the item index
"""
return item_view.visualRect(index)
def item_view_index_mouse_click(item_view, index, button=QtCore.Qt.LeftButton, modifier=QtCore.Qt.NoModifier):
"""
Helper method version of QTest.mouseClick for injecting mouse clicks on a QAbstractItemView
item_view: The QAbstractItemView instance
index: A QModelIndex for the item index to be clicked
"""
item_index_rect = get_item_view_index_rect(item_view, index)
item_index_center = item_index_rect.center()
# For QAbstractItemView widgets, the events need to be forwarded to the actual viewport() widget
QTest.mouseClick(item_view.viewport(), button, modifier, item_index_center)
def item_view_mouse_click(item_view, row, column=0, button=QtCore.Qt.LeftButton, modifier=QtCore.Qt.NoModifier):
"""
Helper method version of 'item_view_index_mouse_click' using a row, column instead of a QModelIndex
item_view: The QAbstractItemView instance
row: The requested row index
column: The requested column index (defaults to 0 in case of single column)
"""
index = get_item_view_index(item_view, row, column)
item_view_index_mouse_click(item_view, index, button, modifier)
async def wait_for_action_in_menu(menu, pattern, timeout=1.0):
"""
Finds a QAction inside a menu, based on the specified pattern.
menu: The QMenu to search
pattern: The action text or pattern to match (see find_child_by_pattern)
If pattern specifies a QWidget, this will search for the associated QWidgetAction
"""
action = await wait_for_child_by_pattern(menu, pattern, timeout=timeout)
if action is None:
raise TimeoutError(f"Failed to find context menu action for {pattern}")
# If we've found a valid QAction, we're good to go
if hasattr(action, 'trigger'):
return action
# If pattern matches a widget and not a QAction, look for an associated QWidgetAction
widget_actions = find_children_by_pattern(menu, type=QtWidgets.QWidgetAction)
underlying_widget_action = None
for widget_action in widget_actions:
widgets_to_check = [widget_action.defaultWidget()] + widget_action.createdWidgets()
for check_widget in widgets_to_check:
if action in _get_children(check_widget):
underlying_widget_action = widget_action
break
if underlying_widget_action is not None:
action = underlying_widget_action
break
if not hasattr(action, 'trigger'):
raise RuntimeError(f"Failed to find action associated with widget {action}")
return action
def queue_hide_event(widget):
"""
Explicitly post a hide event for the next frame, this can be used to ensure modal dialogs exit correctly.
widget: The widget to hide
"""
qApp.postEvent(widget, QtGui.QHideEvent())
async def wait_for_destroyed(obj, timeout=1.0):
"""
Waits for a QObject (including a widget) to be fully destroyed
This can be used to wait for a modal dialog to shut down properly
obj: The object to wait on destruction
timeout: The time, in seconds to wait. 0 for an indefinite wait.
"""
was_destroyed = False
def on_destroyed():
nonlocal was_destroyed
was_destroyed = True
obj.destroyed.connect(on_destroyed)
return await wait_for_condition(lambda: was_destroyed, timeout=timeout)
async def close_modal(modal_widget, timeout=1.0):
"""
Closes a modal dialog and waits for it to be cleaned up.
This attempts to ensure the modal event loop gets properly exited.
modal_widget: The widget to close
timeout: The time, in seconds, to wait. 0 for an indefinite wait.
"""
queue_hide_event(modal_widget)
return await wait_for_destroyed(modal_widget, timeout=timeout)
def trigger_context_menu_entry(widget, pattern, pos=None, index=None):
"""
Trigger a context menu event on a widget and activate an entry
widget: The widget to trigger the event on
pattern: The action text or pattern to match (see find_child_by_pattern)
pos: Optional, the QPoint to set as the event origin
index: Optional, the QModelIndex to click in widget
widget must be a QAbstractItemView
"""
async def async_wrapper():
menu = await open_context_menu(widget, pos=pos, index=index)
action = await wait_for_action_in_menu(menu, pattern)
action.trigger()
queue_hide_event(menu)
result = async_wrapper()
# If we have an event loop, go ahead and just return the coroutine
# Otherwise, do a synchronous wait
if event_loop.is_running():
return result
else:
return run_async(result)
async def open_context_menu(widget, pos=None, index=None, timeout=1.0):
"""
Trigger a context menu event on a widget
widget: The widget to trigger the event on
pos: Optional, the QPoint to set as the event origin
index: Optional, the QModelIndex to click in widget
widget must be a QAbstractItemView
Returns the menu that was created.
"""
if index is not None:
if pos is not None:
raise RuntimeError("Error: 'index' and 'pos' are mutually exclusive")
pos = widget.visualRect(index).center()
parent = widget
widget = widget.viewport()
pos = widget.mapFrom(parent, pos)
if pos is None:
pos = widget.rect().center()
# Post both a mouse event and a context menu to let the widget handle whichever is appropriate
qApp.postEvent(widget, QtGui.QContextMenuEvent(QtGui.QContextMenuEvent.Mouse, pos))
QtTest.QTest.mouseClick(widget, Qt.RightButton, Qt.NoModifier, pos)
menu = None
# Wait for a menu popup
def menu_has_focus():
nonlocal menu
for fw in [qApp.activePopupWidget(), qApp.activeModalWidget(), qApp.focusWidget(), qApp.activeWindow()]:
if fw and isinstance(fw, QtWidgets.QMenu) and fw.isVisible():
menu = fw
return True
return False
await wait_for_condition(menu_has_focus, timeout)
return menu
def move_mouse(widget, position):
"""
Helper method to move the mouse to a specified position on a widget
widget: The widget to trigger the event on
position: The QPoint (local to widget) to move the mouse to
"""
# For some reason, Qt wouldn't register the mouse movement correctly unless both of these ways are invoked.
# The QTest.mouseMove seems to update the global cursor position, but doesn't always result in the MouseMove event being
# triggered, which prevents drag/drop being able to be simulated.
# Similarly, if only the MouseMove event is sent by itself to the core application, the global cursor position wasn't
# updated properly, so drag/drop logic that depends on grabbing the globalPos didn't work.
QtTest.QTest.mouseMove(widget, position)
event = QtGui.QMouseEvent(QtCore.QEvent.MouseMove, position, widget.mapToGlobal(position), QtCore.Qt.LeftButton, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier)
QtCore.QCoreApplication.sendEvent(widget, event)
def drag_and_drop(source, target, source_point = QtCore.QPoint(), target_point = QtCore.QPoint()):
"""
Simulate a drag/drop event from a source object to a specified target
This has special case handling if the source is a QDockWidget (for docking) vs normal drag/drop
source: The source object to initiate the drag from
This is either a QWidget, or a tuple of (QAbstractItemView, QModelIndex) for dragging an item view item
target: The target object to drop on after dragging
This is either a QWidget, or a tuple of (QAbstractItemView, QModelIndex) for dropping on an item view item
source_point: Optional, The QPoint to initiate the drag from. If none is specified, the center of the source will be used.
target_point: Optional, The QPoint to drop on. If none is specified, the center of the target will be used.
"""
# Flag if this drag/drop is for docking, which has some special cases
docking = False
# If the source is a tuple of (QAbstractItemView, QModelIndex), we need to use the
# viewport() as the source, and find the location of the index
if isinstance(source, tuple) and len(source) == 2:
source_item_view = source[0]
source_widget = source_item_view.viewport()
source_model_index = source[1]
source_rect = source_item_view.visualRect(source_model_index)
else:
# There are some special case actions if we are doing this drag for docking,
# so figure this out by checking if the source is a QDockWidget
if isinstance(source, QtWidgets.QDockWidget):
docking = True
source_widget = source
source_rect = source.rect()
# If the target is a tuple of (QAbstractItemView, QModelIndex), we need to use the
# viewport() as the target, and find the location of the index
if isinstance(target, tuple) and len(target) == 2:
target_item_view = target[0]
target_widget = target_item_view.viewport()
target_model_index = target[1]
target_rect = target_item_view.visualRect(target_model_index)
else:
# If we are doing a drag for docking, we actually want all the mouse events
# to still be directed through the source widget
if docking:
target_widget = source_widget
else:
target_widget = target
target_rect = target.rect()
# If no source_point is specified, we need to find the center point of
# the source widget
if source_point.isNull():
# If we are dragging for docking, initiate the drag from the center of the
# dock widget title bar
if docking:
title_bar_widget = source.titleBarWidget()
if title_bar_widget:
source_point = title_bar_widget.geometry().center()
else:
raise RuntimeError("No titleBarWidget found for QDockWidget")
# Otherwise, can just find the center of the rect
else:
source_point = source_rect.center()
# If no target_point was specified, we need to find the center point of the target widget
if target_point.isNull():
target_point = target_rect.center()
# If we are dragging for docking and we aren't dragging within the same source/target,
# the mouse movements need to be directed to the source_widget, so we need to use the
# difference in global positions of our source and target widgets to adjust the target_point
# to be relative to the source
if docking and source != target:
source_top_left = source.mapToGlobal(QtCore.QPoint(0, 0))
target_top_left = target.mapToGlobal(QtCore.QPoint(0, 0))
offset = target_top_left - source_top_left
target_point += offset
# Move the mouse to the source spot where we will start the drag
move_mouse(source_widget, source_point)
# Press the left-mouse button to begin the drag
QtTest.QTest.mousePress(source_widget, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier, source_point)
# If we are dragging for docking, we first need to drag the mouse past the minimum distance to
# trigger the docking system properly
if docking:
drag_distance = QtWidgets.QApplication.startDragDistance() + 1
docking_trigger_point = source_point + QtCore.QPoint(drag_distance, drag_distance)
move_mouse(source_widget, docking_trigger_point)
# Drag the mouse to the target widget over the desired point
move_mouse(target_widget, target_point)
# Release the left-mouse button to complete the drop.
# If we are docking, we need to delay the actual mouse button release because the docking system has
# a delay before the drop zone becomes active after it has been hovered, which can be found here:
# FancyDockingDropZoneConstants::dockingTargetDelayMS = 110 ms
# So we need to delay greater than dockingTargetDelayMS after the final mouse move
# over the intended target.
delay = -1
if docking:
delay = 200
QtTest.QTest.mouseRelease(target_widget, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier, target_point, delay)
# Some drag/drop events have extra processing on the following event tick, so let those processEvents
# first before we complete the drag/drop operation
QtWidgets.QApplication.processEvents()
def trigger_action_async(action):
"""
Convenience function. Triggers an action asynchronously.
This can be used if calling action.trigger might block (e.g. if it opens a modal dialog)
action: The action to trigger
"""
return run_soon(lambda: action.trigger())
def click_button_async(button):
"""
Convenience function. Clicks a button asynchronously.
This can be used if calling button.click might block (e.g. if it opens a modal dialog)
button: The button to click
"""
return run_soon(lambda: button.click())
async def wait_for_modal_widget(timeout=1.0):
"""
Waits for an active modal widget and returns it.
"""
return await wait_for(lambda: qApp.activeModalWidget(), timeout=timeout)
async def wait_for_popup_widget(timeout=1.0):
"""
Waits for an active popup widget and returns it.
"""
return await wait_for(lambda: qApp.activePopupWidget(), timeout=timeout)