"""
Utility functions and classes for working with PyQt
"""
import math
from contextlib import contextmanager
import decorator
import pyhelp
import schrodinger
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt import IS_PYQT6
from schrodinger.Qt.QtCore import Qt
from schrodinger.structutils import analyze
from schrodinger.ui import maestro_ui
from schrodinger.ui.qt.swidgets import SComboBox as EnhancedComboBox # noqa
from schrodinger.utils import deprecation
# The following functions and classes used to be defined here but have since
# been moved to qt_utils. We import them so that older code can still
# access them from here.
from schrodinger.utils.qt_utils import SignalAndSlot # noqa: F401
from schrodinger.utils.qt_utils import get_signals # noqa: F401
from schrodinger.utils.qt_utils import suppress_signals # noqa: F401
from . import borderless_popup_ui
maestro = schrodinger.get_maestro()
# Used as a spacer between images in join_images()
BOUNDARY_OFFSET = 10
[docs]def to_float(val):
try:
return float(val), True
except ValueError:
return 0.0, False
PLURALIZE_DEPRECATION_MESSAGE = "function deprecated: schrodinger.ui.qt.utils.pluralize_text\nThe requested call is deprecated in the Schrodinger suite and is no longer available in this release.\nPlease update as:\n inflect.engine().plural"
[docs]@deprecation.deprecated(to_remove_in="2021-4",
msg=PLURALIZE_DEPRECATION_MESSAGE)
def pluralize_text(noun, count, suffix="s"):
"""
Helper function to pluralize simple nouns when necessary
:param noun: singular form of the noun
:type noun: str
:param count: number of objects `noun` is describing
:type count: int
:param suffix: letters to add to the word to make the plural form
:type suffix: str
"""
return noun if count == 1 else noun + suffix
[docs]def image_to_string(image):
"""
:param image: QImage instance
:type image: QtGui.QImage
:return: string representation of the given image
:rtype: str
"""
buffer = QtCore.QBuffer()
buffer.open(QtCore.QIODevice.WriteOnly)
image.save(buffer, format="PNG")
return bytes(buffer.data().toBase64()).decode()
WAIT_CURSOR_SHAPE = QtCore.Qt.WaitCursor
@decorator.decorator
def _wait_cursor_decorator(method, *args, **kwargs):
"""
Decorator to manage the wait cursors. Use via the {wait_cursor} API.
"""
# Any other function/method; show Maestro-wide wait cursor:
app = QtWidgets.QApplication.instance()
app.setOverrideCursor(QtGui.QCursor(WAIT_CURSOR_SHAPE))
try:
return method(*args, **kwargs)
finally:
app.restoreOverrideCursor()
class _WaitCursorContextDecorator(object):
"""
Decorator and context manager for managining wait cursors. Use via the
{wait_cursor} API.
Add this decorator to a function that should show the wait cursor
throughout its execution. The cursor will be applied to the whole
QApplication (Maestro instance).
Example::
@wait_cursor
def func():
<code>
Use as a context manager to show the wait cursor while the indented code
is executed.
Example::
with wait_cursor:
<code>
"""
# This method allows wait_cursor to be used as a signature-preserving
# function/method decorator:
def __call__(self, func):
dec = _wait_cursor_decorator
return decorator.FunctionMaker.create(func,
'return decfunc(%(signature)s)',
dict(decfunc=dec(func)),
__wrapped__=func)
# These 2 methods allow wait_cursor to be used as a context manager:
def __enter__(self):
QtWidgets.QApplication.instance().setOverrideCursor(
QtGui.QCursor(WAIT_CURSOR_SHAPE))
def __exit__(self, cls, value, tb):
QtWidgets.QApplication.instance().restoreOverrideCursor()
# Create a wait_cursor decorator/context manager instance. See class
# documentation for usage and examples.
wait_cursor = _WaitCursorContextDecorator()
[docs]class JobLaunchWaitCursorContext:
"""
Context manager for showing a wait cursor while waiting for a job to launch.
Times out after 5 seconds.
Should not be used for anything else, as it relies on the code inside of
the context running a QEventLoop to not block the single shot timer in
`__enter__`.
"""
[docs] def __init__(self):
self._timer = QtCore.QTimer()
def __enter__(self):
QtWidgets.QApplication.instance().setOverrideCursor(
QtGui.QCursor(WAIT_CURSOR_SHAPE))
self._timer.singleShot(5000, self._restoreOverrideCursor)
def __exit__(self, exc_type, exc_val, exc_tb):
self._timer.stop()
self._restoreOverrideCursor()
def _restoreOverrideCursor(self):
app_instance = QtWidgets.QApplication.instance()
if app_instance.overrideCursor():
app_instance.restoreOverrideCursor()
@decorator.decorator
def _remove_wait_cursor_decorator(method, *args, **kwargs):
"""
Decorator to manage temporarily removing the wait cursor. Use via the
{remove_wait_cursor} API. Has no effect if the current cursor is not the
wait cursor.
"""
# Any other function/method; show Maestro-wide wait cursor:
app = QtWidgets.QApplication.instance()
num_wait_cursors = 0
override = app.overrideCursor()
while override and override.shape() == WAIT_CURSOR_SHAPE:
num_wait_cursors += 1
app.restoreOverrideCursor()
override = app.overrideCursor()
try:
return method(*args, **kwargs)
finally:
for cursor in range(num_wait_cursors):
app.setOverrideCursor(QtGui.QCursor(WAIT_CURSOR_SHAPE))
class _RemoveWaitCursorContextDecorator(object):
"""
Decorator and context manager for temporarily removing a wait cursor such as
when posting a dialog during a method that uses a wait cursor. Use via the
{restore_cursor} API.
Add this decorator to a function that should show the remove the wait cursor
throughout its execution. The cursor will be applied to the whole
QApplication (Maestro instance). This will have no effect if the current
cursor is not the wait cursor.
Example::
@remove_wait_cursor
def func():
<code>
Use as a context manager to remove the wait cursor while the indented code
is executed.
Example::
with remove_wait_cursor:
<code>
"""
# This method allows remove_wait_cursor to be used as a signature-preserving
# function/method decorator:
def __call__(self, func):
dec = _remove_wait_cursor_decorator
return decorator.FunctionMaker.create(func,
'return decfunc(%(signature)s)',
dict(decfunc=dec(func)),
__wrapped__=func)
# These 2 methods allow remove_wait_cursor to be used as a context manager:
def __enter__(self):
app = QtWidgets.QApplication.instance()
self.num_wait_cursors = 0
override = app.overrideCursor()
while override and override.shape() == WAIT_CURSOR_SHAPE:
self.num_wait_cursors += 1
app.restoreOverrideCursor()
override = app.overrideCursor()
def __exit__(self, cls, value, tb):
for cursor in range(self.num_wait_cursors):
QtWidgets.QApplication.instance().setOverrideCursor(
QtGui.QCursor(WAIT_CURSOR_SHAPE))
# Create a remove_wait_cursor decorator/context manager instance. See class
# documentation for usage and examples.
remove_wait_cursor = _RemoveWaitCursorContextDecorator()
[docs]@decorator.decorator
def maestro_required(func, *args, **kwargs):
"""
A decorator for functions that should only be run when inside of Maestro.
When run outside of Maestro, the decorated function will be a no-op.
"""
if maestro:
return func(*args, **kwargs)
[docs]class MaestroPythonBannerManager(QtCore.QObject):
"""
Show one Maestro PythonBanner at a time
"""
bannerClosed = QtCore.pyqtSignal() # Banner closed from maestro
modalBannerClosed = QtCore.pyqtSignal()
[docs] def __init__(self):
super().__init__()
self._banner_widget = None
self._pybanner = None
[docs] def showBanner(self, widget, is_modal=True, show_close_button=True):
"""
Show `widget` in a banner in Maestro. Any previously shown banner will
be closed.
:param widget: Widget to show in a Maestro banner
:type widget: QtWidgets.QWidget
:param is_modal: Whether to show banner as modal. Maestro can only show
one modal banner at a time. Multiple non-modal banners can be
displayed and they auto-dismiss after a time.
:type is_modal: bool
:param show_close_button: Whether to show graphical close button on the
right side of the notification
:type show_close_button: bool
"""
self.closeBanner()
self._banner_widget = widget
self._pybanner = maestro_ui.PythonBanner(self._banner_widget, is_modal)
self._pybanner.showClose(show_close_button)
self._pybanner.aboutToRemoveBanner.connect(self.bannerClosed)
self._pybanner.aboutToRemoveBanner.connect(
lambda: self._setBannerWidgetVisible(False))
if is_modal:
self._pybanner.aboutToRemoveBanner.connect(self.modalBannerClosed)
self._setBannerWidgetVisible(True)
maestro_ui.MaestroHub.instance().emitAddPythonBanner(
self._pybanner, True)
[docs] def closeBanner(self):
"""
Close the currently-shown banner in Maestro.
"""
if self._pybanner is not None:
self._pybanner.aboutToRemoveBanner.disconnect(self.bannerClosed)
try:
self._pybanner.aboutToRemoveBanner.disconnect(
self.modalBannerClosed)
except TypeError:
pass
self._pybanner.close()
self._pybanner = None
self._setBannerWidgetVisible(False)
self._banner_widget = None
def _setBannerWidgetVisible(self, visible):
"""
Set the visibility of the banner widget.
:param visible: Visibility of the banner widget.
:type visible: bool
"""
if self._banner_widget:
self._banner_widget.setVisible(visible)
[docs]@contextmanager
def undo_block():
"""
A context manager for putting all Maestro commands into an undo block,
which can also be used as a function decorator.
"""
if maestro:
maestro.command("beginundoblock")
yield
if maestro:
maestro.command("endundoblock")
[docs]class LineEditWithSampleText(QtWidgets.QLineEdit):
"""
A line edit that uses sample text to determine its horizontal size hint.
:note: You may need to change the horizontal size policy of this line edit
depending on your usage. If you're using the sample data to make sure the
line edit is wide enough, keep the size policy at its default setting of
Expanding. If you're using the sample data to make sure the line edit isn't
too wide, you'll likely need to change the size policy to Preferred. Note
that the size policy change can be done through Designer if desired.
"""
[docs] def __init__(self, *args, **kwargs):
super(LineEditWithSampleText, self).__init__(*args, **kwargs)
self._sample_text = None
self._enlarge = 0
[docs] def setSampleText(self, sample_text, enlarge=0.2):
"""
Specify the sample data used to set the horizontal size hint
:param sample_text: The sample text
:type sample_text: str
:param enlarge: The sample data will be widened by this percent so that
there's extra width in the line edit. Defaults to 0.2 (or a 20%
increase)
:type enlarge: float
"""
self._sample_text = sample_text
self._enlarge = enlarge
self.updateGeometry()
[docs] def sizeHint(self):
# See Qt documentation for method documentation
size_hint = super(LineEditWithSampleText, self).sizeHint()
if not self._sample_text:
return size_hint
font_metric = QtGui.QFontMetrics(self.font())
width = font_metric.horizontalAdvance(self._sample_text)
width *= (1 + self._enlarge)
left_tm, right_tm, top_tm, bottom_tm = self.getTextMargins()
left_cm, right_cm, top_cm, bottom_cm = self.getContentsMargins()
# The "+ 4" comes from 2 * QLineEditPrivate::horizontalMargin
width += left_tm + right_tm + left_cm + right_cm + 4
size_hint.setWidth(width)
return size_hint
[docs]def checkStructurePrep(st, parent=None):
"""
Make sure that the specified structure passes a force field check (i.e. has
hydrogens, proper bond orders, and atomic charges). If it doesn't, prompt
the user to run the Protein Preparation Wizard. Launch the Prep Wizard if
requested.
:param st: The structure to check
:type st: `schrodinger.structure.Structure`
:param parent: The widget that should be the parent of the Protein Prep
dialog
:type parent: `PyQt5.QtWidgets.QWidget`
:return: True if the structure passes the force field check. False
otherwise.
:rtype: bool
"""
if analyze.hydrogens_present(st):
return True
msg = ("Force field check failed. Make sure that the structure contains "
"hydrogens, as well as valid bond orders and atomic charges.\n\n"
"Would you like to use the Protein Preparation Wizard to prepare "
"your structure?")
response = QtWidgets.QMessageBox.question(parent, "Error", msg)
if response == QtWidgets.QMessageBox.Yes:
maestro.command("pythonrunbuiltin prepwizard_gui.panel")
return False
[docs]class ErrorForDialog(Exception):
"""
An exception that will be caught by `catch_error_for_dialog`. This
exception should be raised with the error message to display to the user. If
raised without an error message, then no error dialog will be displayed.
(This assumes that the user has already been notified of the error in
another way, e.g., a question dialog.)
"""
# This class intentionally left blank
[docs]@decorator.decorator
def catch_error_for_dialog(func, self, *args, **kwargs):
"""
A decorator that catches `ErrorForDialog` exceptions and displays the
exception message in an error dialog.
"""
try:
return func(self, *args, **kwargs)
except ErrorForDialog as err:
msg = str(err)
if msg:
self.error(msg)
[docs]def join_images(pic1, pic2, side_by_side=True):
"""
Given two QPictures, join them into one image either side by side, or
one below the other. If either image is smaller than the other,
the smaller one will be centered accordingly.
:param pic1: the first picture to place into the larger image
:type pic1: QtGui.QImage or QtGui.QPicture
:param pic2: the second picture to place into the larger image
:type pic2: QtGui.QImage or QtGui.QPicture
:param side_by_side: whether to place the two pictures side by side,
or one below the other.
:type side_by_side: bool
:return: the final constructed image.
:rtype: QtGui.QImage
"""
x_origin_offset = y_origin_offset = 0 # where to start drawing pic1
center_offset = 0 # offset to center pic2
# Calculate width, height and any offsets to center image
if side_by_side:
width = pic1.width() + pic2.width() + BOUNDARY_OFFSET
height = max(pic1.height(), pic2.height())
diff = pic1.height() - pic2.height()
offset = int(math.ceil(abs(diff) / 2))
if diff >= 0:
center_offset = offset
else:
y_origin_offset = offset
else:
width = max(pic1.width(), pic2.width())
height = pic1.height() + pic2.height() + BOUNDARY_OFFSET
diff = pic1.width() - pic2.width()
offset = int(math.ceil(abs(diff) / 2))
if diff >= 0:
center_offset = offset
else:
x_origin_offset = offset
width = int(math.ceil(width))
height = int(math.ceil(height))
# Set up image with correct dimensions
image = QtGui.QImage(width, height, QtGui.QImage.Format_ARGB32)
image.fill(QtGui.QColor("white"))
# Set up painter to draw on the above image
painter = QtGui.QPainter(image)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
painter.setRenderHint(QtGui.QPainter.TextAntialiasing)
painter.setRenderHint(QtGui.QPainter.SmoothPixmapTransform)
_draw_img_or_pic(painter, QtCore.QPointF(x_origin_offset, y_origin_offset),
pic1)
if side_by_side:
_draw_img_or_pic(
painter,
QtCore.QPointF(pic1.width() + BOUNDARY_OFFSET, center_offset), pic2)
else:
_draw_img_or_pic(
painter,
QtCore.QPointF(center_offset,
pic1.height() + BOUNDARY_OFFSET), pic2)
painter.end()
return image
def _draw_img_or_pic(painter, point, pic):
"""
Draw either the image or picture using the appropriate method.
:param painter: the painter to draw the image or picture
:type painter: QtGui.QPainter
:param point: where to start the drawing
:type point: QtCore.QPointF
:param pic: the image or picture to be drawn
:type pic: QtGui.QImage or QtGui.QPicture
"""
if isinstance(pic, QtGui.QImage):
painter.drawImage(point, pic)
elif isinstance(pic, QtGui.QPicture):
painter.drawPicture(point, pic)
PYHELP_ERROR_MSG = 'The help topic for this panel could not be found.'
[docs]def help_dialog(topic, product="Maestro", parent=None):
"""
Display a help dialog (or a warning dialog if no help can be found).
:param topic: The topic to display help for
:type topic: str
:param help_product: The help product to access. Defaults to "Maestro".
:type product: str
:param parent: The parent of the warning dialog. If not given, no parent
is used.
:type parent: QWidget
"""
# Cast to str in order to allow unicode strings as input:
pyhelp.mmpyhelp_set_help_product(str(product))
status = pyhelp.mmpyhelp_show_help_topic(str(topic))
if status != pyhelp.MMHELP_OK:
QtWidgets.QMessageBox.warning(parent, "Warning", PYHELP_ERROR_MSG)
[docs]def wrap_qt_tag(text):
"""
Returns text wrapped in the <qt> tag. Used to create rich-text tooltips.
"""
return wrap_html_tag(text)
[docs]def wrap_html_tag(text, html_tag='qt'):
"""
Returns text wrapped in a custom html tag.
"""
return f'<{html_tag}>{text}</{html_tag}>'
[docs]def traverse_model_rows(model, parent=QtCore.QModelIndex()): # noqa: M511
"""
Traverse the rows of a model, including children (e.g. for a QTreeView)
:param model: Item model
:type model: QtCore.QAbstractItemModel
:param parent: Parent index to start traversal
:type parent: QtCore.QModelIndex
:return: All row indices
:rtype: generator(QtCore.QModelIndex)
"""
for row_idx in range(0, model.rowCount(parent=parent)):
index = model.index(row_idx, 0, parent=parent)
yield index
if model.hasChildren(index):
yield from traverse_model_rows(model=model, parent=index)
[docs]def linux_setVisible_positioning_workaround(obj, super_obj, set_visible):
"""
Call this from the setVisible method of a QWidget that needs to reappear in
the same location it was last hidden from. This works around a bug on Linux
where the widget fails to correctly set its position. To use add the
following code to the widget class:
if sys.platform.startswith("linux"):
def setVisible(self, set_visible):
qt_utils.linux_setVisible_positioning_workaround(self, super(),
set_visible)
:param obj: the widget that needs to be positioned correctly on Linux. This
is generally "self" in the calling method.
:type obj: QWidget
:param super_obj: the return value of super() from the calling method
:type super_obj: QWidget proxy
:param set_visible: whether to set it visible or invisible
:type set_visible: bool
"""
if set_visible == obj.isVisible():
super_obj.setVisible(set_visible)
return
super_obj.setVisible(set_visible)
pos = obj.pos()
if set_visible and not pos.isNull():
# The value of pos() is correct, but the actual position does not match.
# We need to force the widget to move to pos.
x, y = pos.x(), pos.y()
# Qt will ignore obj.move(pos) because it thinks the widget is already
# at that position and optimizes the call into a no-op. So we supply
# different coordinates to force a move.
obj.move(x + 1, y + 1)
obj.move(x, y)
[docs]def add_items_to_glayout(glayout, items):
"""
Add the specified items to a grid layout.
:type glayout: QtWidgets.QGridLayout
:param items: List of lists of items to add to the layout, or None to leave
a gap in that position
:type items: list[list[Union[QtWidgets.QWidget, QtWidgets.QLayout, None]]]
"""
for row_num, item_row in enumerate(items):
for col_num, item in enumerate(item_row):
if item is None:
continue
elif isinstance(item, QtWidgets.QWidget):
add_item = glayout.addWidget
elif isinstance(item, QtWidgets.QLayout):
add_item = glayout.addLayout
else:
raise TypeError(f"Unrecognized layout item type {type(item)}")
add_item(item, row_num, col_num)
[docs]def show_job_launch_failure_dialog(err, parent=None):
"""
Show dialog for informing the user of the reason why job failed to launch.
:param err: Exception raised by JobHandler
:type err: jobcontrol.JobLaunchFailure
:param parent: Optional parent for the dialog.
:type parent: QWidget or None
"""
# Note this dialog is similar to one raised by jobcontrol.launch_job()
msg = 'Launch failed. Click "Show Details" for more information.'
# Import needs to be here for test collections to work right, as
# messagebox.py imports appframework2/settings.py
from schrodinger.ui.qt import messagebox
messagebox.show_warning(parent, msg, detailed_text=str(err))
[docs]def get_view_item_options(
view: QtWidgets.QAbstractItemView) -> QtWidgets.QStyleOptionViewItem:
"""
Retrieve the view item options for the provided table/tree view. This
function will work under both Qt 5 and Qt 6.
"""
if IS_PYQT6:
view_options = QtWidgets.QStyleOptionViewItem()
view.initViewItemOption(view_options)
return view_options
else:
return view.viewOptions()