Source code for schrodinger.ui.qt.utils

"""
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]class BorderlessPopUpFrame(QtWidgets.QFrame): """ Class for creating borderless popup frames that have a flat close button style. Additional widgets can be added to the frame's self.ui.vertical_layout to customize the frame. """
[docs] def __init__(self, parent=None, ui=None): """ :param parent: parent to attach widget to. :type parent: `QtWidgets.QWidget` :param ui: UI form to use for this widget. Must define a QFrame and have a QPushButton widget named close_btn. If None, will use the default form for this frame. :type ui: module """ super(BorderlessPopUpFrame, self).__init__(parent) if ui: self.ui = ui.Ui_Frame() else: self.ui = borderless_popup_ui.Ui_Frame() self.ui.setupUi(self) # setup close button self.ui.close_btn.setFlat(True) close_img = QtWidgets.QStyle.SP_TitleBarCloseButton style = QtWidgets.QApplication.style() close_icon = style.standardIcon(close_img) self.ui.close_btn.setIcon(close_icon) self.ui.close_btn.clicked.connect(self.close)
[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)
[docs]class ButtonAcceptsFocusMixin(object): """ A mixin to ensure that a button takes focus when it's pressed. This forces any open table delegates to lose focus, which triggers the data to be committed. This won't happen by default with QPushButtons on Mac and with QToolButtons on all platforms. (See PANEL-1113.) """
[docs] def __init__(self, *args, **kwargs): super(ButtonAcceptsFocusMixin, self).__init__(*args, **kwargs) self.pressed.connect(self.setFocus)
[docs]class AcceptsFocusPushButton(ButtonAcceptsFocusMixin, QtWidgets.QPushButton): pass
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 add_widget_to_menu(widget, menu=None): """ Add `widget` as an action to `menu`. Example with button:: btn = QtWidgets.QPushButton() popup_widget = QtWidgets.QLabel("This text pops up from a button") menu = add_widget_to_menu(popup_widget) btn.setMenu(menu) Example with menu:: menu = QtWidgets.QMenu() popup_widget = QtWidgets.QLabel("This text appears as a menu action") add_widget_to_menu(popup_widget, menu) :param widget: The widget to show as a menu action :type widget: QtWidgets.QWidget :param menu: The menu to show the widget in. If None, a new menu will be created. :type menu: QtWidgets.QMenu or NoneType :return: The menu :rtype: QtWidgets.QMenu """ if menu is None: menu = QtWidgets.QMenu() wa = QtWidgets.QWidgetAction(menu) wa.setDefaultWidget(widget) menu.addAction(wa) return menu
[docs]def update_widget_style(widget): """ Utility function to update widget style. When user applies stylesheet widget does not update immediately unless user explicitly calls polish()/unpolish on widget. :param widget: The widget to be updated. :type parent: `QtWidgets.QWidget` """ if widget: widget.style().unpolish(widget) widget.style().polish(widget)
[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 get_widgets_in_layout(layout, recursive=True): widgets = set() for idx in range(layout.count()): layout_item = layout.itemAt(idx) if isinstance(layout_item, QtWidgets.QLayout): if recursive: widgets |= get_widgets_in_layout(layout_item, recursive=recursive) else: item_widget = layout_item.widget() if item_widget is not None: # item_widget can be None if there are spacers in the layout widgets.add(item_widget) return widgets
[docs]def hide_widgets_in_layout(layout): """ Hide all QWidgets in the given layout recursively """ layout_widgets = get_widgets_in_layout(layout) for widget in layout_widgets: widget.hide()
[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]class HeaderViewWithMenuIndicator(QtWidgets.QHeaderView): """ A horizontal header view that shows a down arrow indicator on hover for a context menu. :cvar _MENU_INDICATOR_WIDTH: width of the menu indicator. :vartype _MENU_INDICATOR_WIDTH: int :cvar _MENU_INDICATOR_HEIGHT: height of the menu indicator. :vartype _MENU_INDICATOR_WIDTH: int :cvar _MENU_INDICATOR_RIGHT_MARGIN: margin to the right of the menu indicator. :vartype _MENU_INDICATOR_WIDTH: int :cvar _MENU_INDICATOR_PERCENTAGE: relative depth of the menu indicator from the parent rectangle. :vartype _MENU_INDICATOR_WIDTH: float :ivar contextMenuIndicatorClicked: emitted when their is a left mouse button click on the context menu indicator. Emitted with: - position of the event. :vartype contextMenuIndicatorClicked: QtCore.pyqtSignal """ _MENU_INDICATOR_WIDTH = 8 _MENU_INDICATOR_HEIGHT = 8 _MENU_INDICATOR_RIGHT_MARGIN = 5 _MENU_INDICATOR_PERCENTAGE = 0.7 contextMenuIndicatorClicked = QtCore.pyqtSignal(QtCore.QPoint)
[docs] def __init__(self, parent=None): super().__init__(Qt.Horizontal, parent) self.setSectionsClickable(True) # The bounding rectangle for the menu indicator. self._menu_indicator_rect = QtCore.QRect(0, 0, 0, 0) self._current_header_hover_index = -1
[docs] def getMenuIndicatorRect(self, parent_rect): """ Returns a rectangle at the right bottom corner of the parent rectangle. :param parent_rect: parent rectangle. :type parent_rect: QtCore.QRect :return: bounding rectangle of the visual indicator. :rtype: QtCore.QRect """ menu_point = QtCore.QPoint( parent_rect.x() + parent_rect.width() - self._MENU_INDICATOR_WIDTH - self._MENU_INDICATOR_RIGHT_MARGIN, parent_rect.y() + self._MENU_INDICATOR_PERCENTAGE * parent_rect.height()) self._menu_indicator_rect.setRect(menu_point.x(), menu_point.y(), self._MENU_INDICATOR_WIDTH, self._MENU_INDICATOR_HEIGHT) return self._menu_indicator_rect
[docs] def paintSection(self, painter, rect, logical_index): """ Overrides QHeaderView.paintSection. Calls the parent method to paint the section and paints a down arrow at the right bottom edge of the section on hover. """ painter.save() super().paintSection(painter, rect, logical_index) painter.restore() visual_index = self.visualIndex(logical_index) if (self._current_header_hover_index < 0 or self._current_header_hover_index != visual_index): return painter.save() option = QtWidgets.QStyleOptionHeader() self.initStyleOption(option) option.rect = self.getMenuIndicatorRect(rect) qpolygon = QtGui.QPolygon() qpolygon.fill(QtCore.QPoint(), 3) qpolygon.setPoint(0, QtCore.QPoint(option.rect.x(), option.rect.y())) qpolygon.setPoint( 1, QtCore.QPoint(option.rect.x() + option.rect.width(), option.rect.y())) qpolygon.setPoint( 2, QtCore.QPoint(option.rect.x() + option.rect.width() / 2, option.rect.y() + option.rect.height())) painter.setPen(QtGui.QPen(QtCore.Qt.darkGray)) painter.setBrush(QtGui.QBrush(QtCore.Qt.darkGray)) painter.drawPolygon(qpolygon) painter.restore()
[docs] def mouseMoveEvent(self, event): """ Overrides QHeaderView.mouseMoveEvent. Updates the current hover section index and calls super method on all received events. """ logical_index = self.logicalIndexAt(event.pos()) self._current_header_hover_index = self.visualIndex(logical_index) self.viewport().update() super().mouseMoveEvent(event)
[docs] def leaveEvent(self, event): """ Overrides QWidget.leaveEvent. Updates the current hover section index and calls super method on all received events. """ self._current_header_hover_index = -1 self.viewport().update() super().leaveEvent(event)
[docs] def mousePressEvent(self, event): """ Overrides QHeaderView.mousePressEvent. Emits contextMenuIndicatorClicked signal when their is a left button press in the menu indicator bounding rectangle and calls super method on all received events """ if (event.button() == Qt.LeftButton and self._menu_indicator_rect.contains(event.pos())): self.contextMenuIndicatorClicked.emit(event.pos()) super().mousePressEvent(event)
[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()