Source code for schrodinger.ui.qt.swidgets

"""
Contains modifications of Qt widgets to allow for easier creation and use, and
also general functions for dealing with Qt widgets.
"""
# Copyright Schrodinger, LLC. All rights reserved.

import enum
import os
import sys
from collections import OrderedDict
from collections import namedtuple
from html.parser import HTMLParser

import numpy
import pyhelp

from schrodinger import adapter
from schrodinger.infra import canvas2d
from schrodinger.infra import mm
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.structutils import analyze
from schrodinger.structutils import color
from schrodinger.test import ioredirect
from schrodinger.ui.qt import icons
from schrodinger.ui.qt.standard.icons import icons as std_icons
from schrodinger.utils import fileutils

ANGSTROM = u'\u00C5'
# The little circle symbol for degrees
DEGREES = u'\u00B0'

# Greek letters
GL_epsilon = u'\u03B5'
GL_Gamma = u'\u0393'
GL_gamma = u'\u03B3'
GL_eta = u'\u03B7'
GL_mu = u'\u03BC'
GL_sigma = u'\u03C3'
GL_theta = u'\u0398'
GL_alpha = u'\u03B1'
GL_beta = u'\u03B2'
GL_lambda = u'\u03BB'
GL_rho = u'\u03C1'
GL_tau = u'\u03C4'
GL_UPPER_PSI = u'\u03A8'
GL_UPPER_PHI = u'\u03A6'
GL_UPPER_DELTA = u'\u0394'
GL_PLUS_MINUS = u'\u00B1'

# superscripts
SUPER_SQUARED = u'\u00B2'
SUPER_CUBED = u'\u00B3'
SUPER_EIGHT = u'\u2078'
SUPER_MINUS1 = u'\u207B\u00b9'
SUPER_MINUS2 = u'\u207B\u00b2'
SUP_TEXT = lambda x: '<sup>%s</sup>' % x

# subscripts
SUB_ZERO = u'\u2080'
SUB_ONE = u'\u2081'
SUB_TWO = u'\u2082'
SUB_THREE = u'\u2083'
SUB_FOUR = u'\u2084'
SUB_FIVE = u'\u2085'
SUB_SIX = u'\u2086'
SUB_SEVEN = u'\u2087'
SUB_EIGHT = u'\u2088'
SUB_NINE = u'\u2089'
SUB_DIGITS = {
    0: SUB_ZERO,
    1: SUB_ONE,
    2: SUB_TWO,
    3: SUB_THREE,
    4: SUB_FOUR,
    5: SUB_FIVE,
    6: SUB_SIX,
    7: SUB_SEVEN,
    8: SUB_EIGHT,
    9: SUB_NINE
}
SUB_TEXT = lambda x: '<sub>%s</sub>' % x

RIGHT_ARROW = chr(10132)
TRIPLE_BOND = u'\u2261'
# Hyphen that prevents line-break between both words (MATSCI-11246)
NON_BREAKING_HYPHEN = '&#8209;'

STANDARD_INDENT = 18
# Paths with backslashes don't work in QSS and need to be converted
UI_QT_DIR = os.path.dirname(os.path.abspath(__file__)).replace("\\", "/")
CLEAR_BUTTON_FILE = os.path.join(UI_QT_DIR, "clearbutton.png")
SEARCH_ICON_FILE = os.path.join(UI_QT_DIR, "search_icon.png")
SPINNER_ICON_BASE = ":/schrodinger/ui/qt/icons_dir/spinner/"
VERTICAL = 'vertical'
HORIZONTAL = 'horizontal'
GRID = 'grid'

INDICATOR_VALID = 0
INDICATOR_INTERMEDIATE = 1
INDICATOR_INVALID = 2

# this is blue
DEFAULT_COLOR = (0, 0, 255)

PIXMAP_SIZE = 16

TOOLTIP_HEIGHT = 150
TOOLTIP_WIDTH = 200

CALLBACK_ROLE = Qt.UserRole + 99

# widget type is QWidget
SubWidget = namedtuple('SubWidget', ['label', 'widget', 'layout_type'],
                       defaults=[HORIZONTAL])

QTOOLBUTTON_SIZE = """QToolButton {
            width: 18px;
            height: 18px;
            border: none;
            padding: 1px;
            margin-left: 4px;
        }"""


[docs]class SLabel(QtWidgets.QLabel): """ A QLabel that can pack itself into a layout upon creation """
[docs] def __init__(self, *args, layout=None, tip=None, **kwargs): """ Create a SLabel instance :param `QLayout` layout: layout to place the label in :param str tip: The tooltip for this label """ super().__init__(*args, **kwargs) if layout is not None: layout.addWidget(self) if tip: self.setToolTip(tip) try: self.default_text = str(self.text()) except UnicodeEncodeError: self.default_text = self.text()
[docs] def reset(self): """ Reset the label to its original text """ self.setText(self.default_text)
[docs]class ElidedLabel(QtWidgets.QLabel): """ Just like a QLabel, except that the text is truncated at the end if it doesn't fit. Full text is shown in the tooltip. """
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred) self.setMinimumSize(5, self.minimumSize().height()) self._text = self.text() self._updateElidedText()
[docs] def setText(self, text): self._text = text self.setToolTip(text) self._updateElidedText()
[docs] def resizeEvent(self, event): self._updateElidedText()
def _updateElidedText(self): """ Adjust the text of the QLabel according to the current widget size. Calculates the width without the html identifiers, and adds the elided text back into the html formatted text. """ font = self.font() font.setBold(True) font_metrics = QtGui.QFontMetrics(font) super().setText(elide_rich_text(self._text, font_metrics, self.width()))
[docs]def elide_rich_text(rich_text: str, font_metrics: QtGui.QFontMetrics, width: int, elide_type=Qt.ElideRight): """ Using the given width and font metrics, generate the elided text and fit it into the parsed html. :param rich_text: the source rich text that needs to be elided :param font_metrics: the metrics to calculate the horizontal advance :param width: maximum width before cutting off the html with ellipsis :param elide_type: where to place the ellipsis :return: new formatted html string to display """ parser = ElidedTextHTMLParser() parser.feed(rich_text) return parser.elideRichText(font_metrics, width, elide_type)
[docs]class ElidedTextHTMLParser(HTMLParser): """ HTML Parser used for parsing rich text and then eliding it given a width and font metrics. This parser should only be fed single line html. """
[docs] class HTMLType(enum.Enum): START_TAG = 1 END_TAG = 2 TEXT = 3
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._elided_list = []
[docs] def handle_starttag(self, tag, attrs): attrs_str = ' '.join([f'{name}="{value}"' for name, value in attrs]) tag_str = f'{tag}' if attrs_str: tag_str += f' {attrs_str}' self._elided_list.append((self.HTMLType.START_TAG, tag_str))
[docs] def handle_endtag(self, tag): self._elided_list.append((self.HTMLType.END_TAG, tag))
[docs] def handle_data(self, data): self._elided_list.append((self.HTMLType.TEXT, data))
[docs] def elideRichText(self, font_metrics: QtGui.QFontMetrics, width: int, elide_type=Qt.ElideRight): """ Elides the rich text inside the html. See documentation for `elide_rich_text`. """ text_list = [ data for (html_type, data) in self._elided_list if html_type is self.HTMLType.TEXT ] raw_text = ''.join(text_list) elided_text = font_metrics.elidedText(raw_text, elide_type, width) elided_str_list = [] text_pointer = 0 for html_type, data in self._elided_list: if html_type is self.HTMLType.START_TAG: elided_str_list.append(f'<{data}>') elif html_type is self.HTMLType.END_TAG: elided_str_list.append(f'</{data}>') elif html_type is self.HTMLType.TEXT: # if the current text data contains the ellipsis if text_pointer + len(data) >= len( elided_text) - 1 and text_pointer < len(elided_text): # add the text data up to the ellipsis, include it elided_str_list.append(elided_text[text_pointer:]) text_pointer = len(elided_text) elif text_pointer < len(elided_text): # add the text data to the list elided_str_list.append(data) text_pointer += len(data) # Qt does not offset the padding for ending italics # manually adding a space, see [QTBUG-53502] for html_type, data in reversed(self._elided_list): if html_type is self.HTMLType.END_TAG: if data == 'i': elided_str_list.append(' ') break else: break return ''.join(elided_str_list)
[docs]class ImageLabel(QtWidgets.QLabel): """ A label that draws an image. High resolution images will be scaled down to the specified size. Works as expected on high resolution (e.g. Retina) displays. """
[docs] def __init__(self, *args, **kwargs): """ Create a label. Optionally specify the image by passing in image, width, and height arguments. :param image: Image to draw in the label. :type image: QImage :param width: Width to scale down the image to. :type width: int or float :param height: Height to scale down the image to. :type height: int or float """ self._image = kwargs.pop('image', None) self._width = kwargs.pop('width', None) self._height = kwargs.pop('height', None) image_args = (self._image, self._width, self._height) if any(image_args) and not all(image_args): raise ValueError('Must specify image, width, and height options.') super().__init__(*args, **kwargs)
[docs] def setImage(self, image, width, height): """ Set the image to draw, and the dimensions to scale it down to. :param image: Image to draw in the label. :type image: QImage :param width: Width to scale down the image to. :type width: int or float :param height: Height to scale down the image to. :type height: int or float """ if image is not None and not isinstance(image, QtGui.QImage): raise TypeError(f"image must be a QImage, not {type(image)}") self._image = image self._width = width self._height = height
[docs] def setText(self, text, *args, **kwargs): # Clear image before setting text self._image = self._width = self._height = None super().setText(text, *args, **kwargs)
[docs] def paintEvent(self, event): """ If an image is set, paint it in the widget. Otherwise, use default paintEvent. """ if not self._image: super().paintEvent(event) return target = QtCore.QRectF(0, 0, self._width, self._height) painter = QtGui.QPainter(self) painter.drawImage(target, self._image) painter.end()
[docs] def sizeHint(self): """ If an image is set, force the label to be the size of the image. """ if not self._image: return super().sizeHint() return QtCore.QSize(self._height, self._width)
[docs] def sizePolicy(self): """ If an image is set, force the label to be fixed size. """ if not self._image: return super().sizePolicy() QSP = QtWidgets.QSizePolicy return QSP(QSP.Fixed, QSP.Fixed)
[docs]class ResizablePictureLabel(QtWidgets.QLabel): """ Label that automatically resizes its QPicture as the widget is resized. To keep the aspect ratio, padding is added to top&bottom or left&right sides as needed. To set a background color, use the "background-color" style sheet. """
[docs] def paintEvent(self, event): pic = self.picture() if pic is None: super().paintEvent(event) return cr = self.contentsRect() painter = QtGui.QPainter(self) draw_picture_into_rect(painter, pic, cr) painter.end()
[docs]class Spinner(QtWidgets.QLabel): """ A QLabel that shows a rotating spinner icon. """
[docs] def __init__(self, *args, **kwargs): """ Create a Spinner instance. """ QtWidgets.QLabel.__init__(self, *args, **kwargs) self.setAlignment(Qt.AlignCenter) self.current_icon = 0 # TODO this icon doesn't render in high resolution on Retina displays self.icons = [ QtGui.QPixmap(SPINNER_ICON_BASE + '%i.png' % num) for num in range(1, 9) ] self.standard_icon = QtGui.QPixmap(SPINNER_ICON_BASE + '0.png') self.setPixmap(self.standard_icon) self.timer = QtCore.QTimer() self.timer.timeout.connect(self.rotateIcon)
[docs] def startAnimation(self): """ Show an animated spinner icon. """ if not self.timer.isActive(): self.timer.start(100)
[docs] def stopAnimation(self): """ Stop animation of the spinner icon. """ if self.timer.isActive(): self.timer.stop() self.setPixmap(self.standard_icon)
[docs] def rotateIcon(self): self.current_icon += 1 if self.current_icon == len(self.icons): self.current_icon = 0 self.setPixmap(self.icons[self.current_icon])
[docs]class SpinnerIcon(QtGui.QMovie): """ Animates a "loading" icon that can be used by any Qt object that has the `setIcon` method. Example: def startTask(self): self.button = QtWidgets.QPushButton('Button') self.loading_icon = bwidgets.SpinnerIcon(self) self.loading_icon.startSpinner(self.button) self.launchTask() def taskCallback(self): self.loading_icon.stopSpinner() """ GIF_FNAME = 'blue-loading.gif'
[docs] def __init__(self, parent=None): super(SpinnerIcon, self).__init__(parent) # Get the path to the blue spinner gif_path = os.path.join(os.path.dirname(__file__), 'icons_dir', self.GIF_FNAME) self.setFileName(os.path.abspath(gif_path)) self.setCacheMode(QtGui.QMovie.CacheAll) self.setSpeed(100) self.frameChanged.connect(self.cycle) self.qt_obj = None
[docs] def startSpinner(self, qt_obj): """ Set the `qt_obj`'s icon to a loading icon. :param qt_obj: Object to set icon for. Must have `setIcon` method. """ if not hasattr(qt_obj, 'setIcon'): raise AttributeError('No setIcon method for %s' % qt_obj) # Grab the starting icon to change back to it after self.starting_icon = qt_obj.icon() self.qt_obj = qt_obj self.start()
[docs] def cycle(self): """ The callback for the `frameChanged` signal. This will grab the pixmap of the current frame and create an icon from it. It then sets the `self.qt_obj`'s icon to this icon. """ pix = self.currentPixmap() icon = QtGui.QIcon(pix) self.qt_obj.setIcon(icon)
[docs] def stopSpinner(self): """ Removes the loading icon and sets the object's icon back to the original icon """ if self.qt_obj: self.qt_obj.setIcon(self.starting_icon) self.qt_obj = None self.stop()
class _LargeSpinnerIcon(SpinnerIcon): """ A version of SpinnerIcon with a large spinner. """ GIF_FNAME = 'loading-transparent.gif'
[docs]class LargeSpinner(QtWidgets.QLabel): """ A QLabel that shows a large rotating spinner icon. """
[docs] def __init__(self, *args, **kwargs): QtWidgets.QLabel.__init__(self, *args, **kwargs) self.setAlignment(Qt.AlignCenter) self.loading_icon = _LargeSpinnerIcon(self) self.setMinimumSize(0, 66)
[docs] def startAnimation(self): """ Start the spinner """ self.loading_icon.startSpinner(self)
[docs] def stopAnimation(self): """ Stop the spinner """ self.loading_icon.stopSpinner()
[docs] def setIcon(self, icon): """ For compatibility with the SpinnerIcon class. :param icon: Icon to be set :type icon: QtGui.QIcon or QtGui.QMovie """ if isinstance(icon, QtGui.QMovie): self.setMovie(icon) elif isinstance(icon, QtGui.QIcon): self.setPixmap(icon.pixmap(self.width(), self.height()))
[docs] def icon(self): """ For compatibility with the SpinnerIcon class. """ return self.loading_icon
[docs]class SValidIndicator(SLabel, object): """ An SLabel that easily changes between a valid icon and an invalid icon """
[docs] def __init__(self, state=INDICATOR_VALID, layout=None, tips=None): """ Create an SValidIndicator instance :type state: int :param state: The initial state of the indicator. Should be one of the module constants INDICATOR_VALID, INDICATOR_INTERMEDIATE, INDICATOR_INVALID :type layout: `QtWidgets.QBoxLayout` :param layout: The layout to place the SValidIndicator in :type tips: dict :param tips: The tooltips for the indicator in its various states. Keys should be the possible states (INDICATOR_VALID, etc.) and values should be the corresponding tooltip. Tooltips are optional and may also be provided directly by setState. If no tooltips are provided in either place, the icon will have no tooltip. """ SLabel.__init__(self, layout=layout) self.createPixmaps() self._default_state = state self.tips = tips self.setState(state) self.setFocusPolicy(QtCore.Qt.NoFocus)
def __bool__(self): """ Allow the True/False check of the indicator to indicate a valid (True) or intermediate/invalid (False) state :rtype: bool :return: Whether the indicator is in a valid state or not """ return not self._state
[docs] def createPixmaps(self): """ Create the pixmaps for the indicator states. """ ok_pixmap, warn_pixmap, crit_pixmap = icons.get_validation_pixmaps() self.icons = { INDICATOR_VALID: ok_pixmap, INDICATOR_INTERMEDIATE: warn_pixmap, INDICATOR_INVALID: crit_pixmap }
[docs] def setState(self, state, tip=None): """ Set the state of the indicator :type state: int :param state: The new state of the indicator. Should be one of the module constants INDICATOR_VALID, INDICATOR_INTERMEDIATE, INDICATOR_INVALID :type tip: str :param tip: The new tooltip for the indicator. If not provided, the tooltips, if any, provided to the constructor will be used. :raise RuntimeError: If no icon corresponds to state """ self._state = state try: icon = self.icons[state] except KeyError: raise RuntimeError('%s is an invalid state for the indicator. No ' 'corresponding icon exists.') self.setPixmap(icon) if not tip: try: tip = self.tips[state] except (TypeError, KeyError): # No tooltip exists for this state pass if tip: self.setToolTip(tip)
[docs] def getState(self): """ Get the state of the indicator :rtype: int :return: The state of the indicator. Will be one of the module constants INDICATOR_VALID, INDICATOR_INTERMEDIATE, INDICATOR_INVALID """ return self._state
[docs] def reset(self): """ Set the indicator to its initial state """ self.setState(self._default_state)
[docs]class VerticalLabel(SLabel): """ This creates a vertical label with property size, position, and enabled/disabled states. """
[docs] def __init__(self, *args, **kwargs): """ Create a label and set default state. """ super(VerticalLabel, self).__init__(*args, **kwargs) self.state = True
[docs] def paintEvent(self, event): """ Overwrite parent method. Create a painter with drawn text and tunable colors. See parent class for additional documentation. """ painter = QtGui.QPainter(self) painter.setPen(QtCore.Qt.black if ( self.state and self.parent().isEnabled()) else QtCore.Qt.gray) # FIXME remove these hard-coded values: painter.translate(10, 115) painter.rotate(-90) painter.drawText(0, 0, self.default_text) painter.end()
[docs] def setEnabled(self, state): """ Enable/disable the label by changing the color. :type state: bool :param state: True, if the label widget is enabled. """ self.state = state self.repaint()
[docs] def minimumSizeHint(self): """ Overwrite parent method. """ # FIXME use the height & width of the drawn text size = SLabel.minimumSizeHint(self) return QtCore.QSize(size.height(), size.width())
[docs] def sizeHint(self): """ Overwrite parent method. """ size = SLabel.sizeHint(self) return QtCore.QSize(size.height(), size.width())
[docs]class SLineEdit(QtWidgets.QLineEdit): """ A QLineEdit that accepts a validator as one of its arguments """ AV_ERROR_MSG = ('always_valid must be used in conjuction with a ' 'QValidator that has a restoreLastValidValue method.')
[docs] def __init__(self, *args, **kwargs): """ Create a SLineEdit instance. The initial text of this edit will be used by the reset method to reset the SLineEdit. :type validator: QValidator :keyword validator: The validator used for this LineEdit :type layout: QLayout :keyword layout: layout to place the LineEdit in :type width: int :keyword width: limit on the maximum width of the QLineEdit :param int min_width: limit on the minimum width of the QLineEdit :type read_only: bool :keyword read_only: Whether the edit is read only :type always_valid: bool :keyword always_valid: If True, the SLineEdit will always restore the last valid value when it loses focus. This prevents blank SLineEdits or SLineEdits with only partially valid values at run time. If False (default), no modification of the SLineEdit value will occur when it loses focus. :param show_clear: Whether to show a clear button in the line edit :type show_clear: bool :param show_search_icon: Whether to show a magnifying glass icon. :type show_search_icon: bool :type placeholder_text: str :param placeholder_text: The placeholder text for the edit. This is the text that shows up when the line edit is empty and gives a hint to the user of what to enter. """ QtWidgets.QLineEdit.__init__(self, *args) validator = kwargs.pop('validator', None) if validator: self.setValidator(validator) width = kwargs.pop('width', None) min_width = kwargs.pop('min_width', None) if min_width: self.setMinimumWidth(min_width) if width: self.setMaximumWidth(width) layout = kwargs.pop('layout', None) if layout is not None: layout.addWidget(self) self.always_valid = kwargs.pop('always_valid', False) if self.always_valid: self.setToAlwaysValid() self.clear_btn = QtWidgets.QToolButton(self) show_clear = kwargs.pop('show_clear', False) self.setClearButton(show_clear) show_search = kwargs.pop('show_search_icon', False) if show_search: self.setStyleSheet(''' background-image: url('%s'); background-repeat: no-repeat; background-position: left; padding-left: 17px; border: 1px solid #D3D3D3; height: 20px; ''' % SEARCH_ICON_FILE) ptext = kwargs.pop('placeholder_text', None) if ptext: self.setPlaceholderText(ptext) read_only = kwargs.pop('read_only', False) self.setReadOnly(read_only) tip = kwargs.pop('tip', None) if tip: self.setToolTip(tip) if kwargs: raise TypeError('Unrecognized keyword arguments: ' + str(list(kwargs))) self.default_text = str(self.text())
[docs] def setText(self, text, only_if_valid=False): """ Set the text of the edit. :param str text: The text to set in the edit :param bool only_if_valid: Only set the text if it validates as acceptable via the edit's validator. """ text = str(text) if self.always_valid or only_if_valid: dator = self.validator() if dator: # Calling validate will store this value as the last_valid_value # for always_valid cases (if it is valid). The '0' in the call # below is a fake cursor position necessary for the validate # API. result = dator.validate(text, 0) if only_if_valid and result[0] != dator.Acceptable: return super().setText(text)
[docs] def setToAlwaysValid(self): """ Set this instance to always replace invalid text with valid text per validation performed by self.validator NOTE: The original value is never validated, so we need to set the validator last_valid_value property by hand. Note that forcing a validation here doesn't work - appears to be a PyQt bug (or feature) where validation doesn't work inside the __init__method. store it as the last valid value. """ self.always_valid = True validator = self.validator() if (not validator or not hasattr(validator, 'restoreLastValidValue')): raise RuntimeError(self.AV_ERROR_MSG) if self.text(): validator.last_valid_value = self.text() self.installEventFilter(self)
[docs] def setClearButton(self, show_clear): """ This function sets up the clearButton depending on whether the line edit is using it or not. :param show_clear: Whether to show the button or not (at any time) :type show_clear: bool """ self.show_clear = show_clear if self.show_clear: pixmap = QtGui.QPixmap(CLEAR_BUTTON_FILE) self.clear_btn.setIcon(QtGui.QIcon(pixmap)) self.clear_btn.setIconSize(pixmap.size()) self.clear_btn.setCursor(QtCore.Qt.ArrowCursor) self.clear_btn.setStyleSheet( "QToolButton { border: none; padding: 0px; }") self.clear_btn.clicked.connect(self.clear) self.textChanged.connect(self.updateClearButton) self.updateClearButton(self.text()) else: self.clear_btn.hide()
[docs] def resizeEvent(self, resize_event): """ This is used to place the clear text button """ if self.show_clear: sz = self.clear_btn.sizeHint() framewidth = self.style().pixelMetric( QtWidgets.QStyle.PM_DefaultFrameWidth) self.clear_btn.move(self.rect().right() - framewidth - sz.width(), (self.rect().bottom() + 1 - sz.height()) / 2)
[docs] def updateClearButton(self, text): """ Show the clear button if it's turned on, and the text is non-empty """ if self.show_clear: visible = bool(text) self.clear_btn.setVisible(visible)
[docs] def reset(self): """ Reset the LineEdit to its original text """ self.setText(self.default_text)
[docs] def float(self): """ Returns the current value of the LineEdit as a float :rtype: float :return: Current float value of the LineEdit """ return float(self.text())
[docs] def int(self): """ Returns the current value of the LineEdit as a float :rtype: int :return: Current int value of the LineEdit """ return int(self.text())
[docs] def getString(self): """ Returns the current value of the LineEdit as a string :rtype: str :return: The current string value of the LineEdit """ return str(self.text())
[docs] def eventFilter(self, edit, event): """ Filter FocusOut events so we can restore the last valid value Note: This could have been done in the event() method rather than eventFilter() method, but doing it this way allows us to NOT process events for line edits that don't always_valid validity, which saves on overhead. :type edit: QWidget :param edit: The widget that generated this event - will be this instance (self) :type event: QEvent :param event: The QEvent object generated by the event :rtype: bool :return: Whether event processing should stop for this event """ if not self.always_valid: return False if event.type() == event.FocusOut: validator = self.validator() if validator: state = validator.validate(self.text(), 0)[0] if state != validator.Acceptable: try: validator.restoreLastValidValue(self) except AttributeError: raise AttributeError(self.AV_ERROR_MSG) return QtWidgets.QLineEdit.eventFilter(self, edit, event)
[docs]class SLabeledEdit(SLineEdit): """ An SLineEdit that has a label attached to it. A SLabledEdit instance has a .label property that is an SLabel widget. The SLabeledEdit instance also has a .mylayout property that the label and edit are placed into. This .mylayout should be added to another layout in order to place the widgets into the GUI if the layout= keyword is not used during construction. """
[docs] def __init__(self, text, **kwargs): """ Create a SLabeledEdit instance. The initial text of this edit and labels will be used by the reset method to reset the SLineEdit and labels. :type text: str :keyword text: The text of the label :type edit_text: str :keyword edit_text: The initial text of the LineEdit :type side: str :keyword side: 'left' if the label should appear to the left of the Line Edit (defaul), or 'top' if the label should appear above it :type validator: QValidator :keyword validator: The validator used for this LineEdit :type stretch: bool :keyword stretch: Whether to put a stretch after the LineEdit (or after the after_label). Default is True, even if side='top'. :type after_label: str :keyword after_label: Label text to put after the LineEdit - default is None :type width: int :keyword width: limit on the maximum width of the QLineEdit :param int min_width: limit on the minimum width of the QLineEdit :type layout: QLayout :keyword layout: layout to place the LabeledEdit in :type always_valid: bool :keyword always_valid: If True, the SLineEdit will always restore the last valid value when it loses focus. This prevents blank SLineEdits or SLineEdits with only partially valid values at run time. If False (default), no modification of the SLineEdit value will occur when it loses focus. :param show_clear: Whether to show a clear button in the line edit :type show_clear: bool :type tip: str :param tip: The tooltip to apply to the labels and edit :type indicator: bool :param indicator: True if a valid/invalid indicator should be appended :type indicator_state: int :param indicator_state: The default state of the indicator (INDICATOR_VALID, INDICATOR_INVALID or INDICATOR_INTERMEDIATE) :type indicator_tips: dict :param indicator_tips: The tooltips for the different states of the indicator - see the SValidIndicator class for more information :type placeholder_text: str :param placeholder_text: The placeholder text for the edit. This is the text that shows up when the line edit is empty and gives a hint to the user of what to enter. """ # Set up the layout for this widget side = kwargs.pop('side', 'left') if side == 'left': self.mylayout = SHBoxLayout() else: self.mylayout = SVBoxLayout() parent_layout = kwargs.pop('layout', None) if parent_layout is not None: parent_layout.addLayout(self.mylayout) self.label = SLabel(text, layout=self.mylayout) # Pull out any other non-SLineEdit keywords after = kwargs.pop('after_label', "") stretch = kwargs.pop('stretch', True) tip = kwargs.pop('tip', None) indicator = kwargs.pop('indicator', False) indicator_state = kwargs.pop('indicator_state', True) indicator_tips = kwargs.pop('indicator_tips', None) edit_text = kwargs.pop('edit_text', "") SLineEdit.__init__(self, edit_text, layout=self.mylayout, **kwargs) # Fill in the rest of the layout if after: self.after_label = SLabel(after, layout=self.mylayout) if indicator: self.indicator = SValidIndicator(state=indicator_state, layout=self.mylayout, tips=indicator_tips) if stretch: self.mylayout.addStretch() if tip: self.label.setToolTip(tip) self.setToolTip(tip) if after: self.after_label.setToolTip(tip)
[docs] def reset(self): """ Reset the label and line edit to their default values """ self.label.reset() SLineEdit.reset(self) try: self.after_label.reset() except AttributeError: pass try: self.indicator.reset() except AttributeError: pass
[docs] def setEnabled(self, state): """ Set all child widgets to enabled state of state :type state: bool :param state: True if widgets should be enabled, False if not """ SLineEdit.setEnabled(self, state) self.label.setEnabled(state) try: self.after_label.setEnabled(state) except AttributeError: pass try: self.indicator.setEnabled(state) except AttributeError: pass
[docs] def setVisible(self, state): """ Set all child widgets to visible state of state :type state: bool :param state: True if widgets should be visible, False if not """ SLineEdit.setVisible(self, state) self.label.setVisible(state) try: self.after_label.setVisible(state) except AttributeError: pass try: self.indicator.setVisible(state) except AttributeError: pass
[docs] def setIndicator(self, state, tip=None): """ Set the state of the indicator icon :type state: int :param state: The new state of the indicator. Should be one of the module constants INDICATOR_VALID, INDICATOR_INTERMEDIATE, INDICATOR_INVALID :type tip: str :param tip: The new tooltip for the indicator icon - if not provided, the tooltips, if any, given to the constructor will be used. """ try: self.indicator.setState(state, tip=tip) except AttributeError: raise RuntimeError('Cannot set the state of an indicator that ' 'does not exist')
[docs]class SPushButton(QtWidgets.QPushButton): """ A QPushButton that accepts the command callback for the clicked() signal as an argument """
[docs] def __init__(self, *args, **kwargs): """ Accepts all arguments normally given for a QPushButton :type command: function :keyword command: function to call when the button emits a 'clicked()' signal :type fixed: bool :keyword fixed: if True (default), the button horizontal size policy will be 'fixed'. If false, it will be the QtWidgets.QPushButton default. Otherwise, the horizontal policy will be set to the value given by this parameter. :type layout: QLayout :keyword layout: layout to place the button in """ QtWidgets.QPushButton.__init__(self, *args) command = kwargs.pop('command', None) if command: self.clicked.connect(command) layout = kwargs.pop('layout', None) if layout is not None: layout.addWidget(self) policy = kwargs.pop('fixed', True) if policy is True: size_policy = self.sizePolicy() size_policy.setHorizontalPolicy(size_policy.Fixed) self.setSizePolicy(size_policy) elif policy is not False: size_policy = self.sizePolicy() size_policy.setHorizontalPolicy(policy) self.setSizePolicy(size_policy) tip = kwargs.pop('tip', None) if tip: self.setToolTip(tip) if kwargs: raise TypeError('Unrecognized keyword arguments: ' + str(list(kwargs)))
[docs]class SCheckBox(QtWidgets.QCheckBox): """ A QCheckBox that accepts the command callback for the clicked() signal as an argument """
[docs] def __init__(self, *args, **kwargs): """ Accepts all arguments normally given for a QCheckBox :type command: function :keyword command: function to call when the checkbox emits a 'clicked()' signal :type layout: QLayout :keyword layout: layout to place the CheckBox in :type checked: bool :keyword checked: True if checked to start with, False (default) if not. This is also the state the reset method restores. :type disabled_checkstate: bool :keyword disabled_checkstate: The check state the box should assume when it is disabled. Upon being re-enabled, the box will return to the same state it had prior to being disabled. Calling setChecked() on a disabled box will not actually change the state, but will change the state the box returns to upon being enabled. Use forceSetChecked() to change the current and re-enabled state of a disabled checkbox. The default of None avoids special behavior for disabled checkboxes. :type tip: str :param tip: The tooltip for the checkbox """ self.disabled_checkstate = kwargs.pop('disabled_checkstate', None) QtWidgets.QCheckBox.__init__(self, *args) command = kwargs.pop('command', None) if command: self.clicked.connect(command) layout = kwargs.pop('layout', None) if layout is not None: layout.addWidget(self) self.setChecked(kwargs.pop('checked', False)) self.default_state = self.isChecked() self.reable_checkstate = self.isChecked() tip = kwargs.pop('tip', None) if tip: self.setToolTip(tip) if kwargs: raise TypeError('Unrecognized keyword arguments: ' + str(list(kwargs)))
[docs] def reset(self): """ Reset the checkbox to its initial state """ self.setChecked(self.default_state)
[docs] def trackAbleState(self): """ Check whether we need to track the checkstate of this widget when it is enabled/disabled :rtype: bool :return: Whether we should track the checkstate """ return self.disabled_checkstate is not None
[docs] def changeEvent(self, event): """ Overrides the parent method to save & change the checkstate when the widget is enabled/disabled (if requested) """ if self.trackAbleState() and event.type() == event.EnabledChange: if self.isEnabled(): self.setChecked(self.reable_checkstate) else: current_state = self.isChecked() self.forceSetChecked(self.disabled_checkstate) self.reable_checkstate = current_state return QtWidgets.QCheckBox.changeEvent(self, event)
[docs] def setChecked(self, state): """ Overrides the parent method to not actually change the check state if the widget is disabled AND we're controlling the checkstate of disabled widgets. In that case, we only store the desired state for when the widget is re-enabled. """ if self.trackAbleState() and not self.isEnabled(): self.reable_checkstate = state else: QtWidgets.QCheckBox.setChecked(self, state)
[docs] def forceSetChecked(self, state): """ Change the checkstate of the widget even if it is disabled and we are controlling the checkstate of disabled widgets. Also sets this state to be the state when the widget re-enabled :type state: bool :param state: Whether the checkbox should be checked """ self.reable_checkstate = state QtWidgets.QCheckBox.setChecked(self, state)
[docs]class SCheckBoxToggle(SCheckBox): """ A QCheckBox that accepts the command callback for the toggled() signal as an argument, in comparison to SCheckBox that has command callback bound to clicked() signal. """
[docs] def __init__(self, *args, nocall=True, **kwargs): """ Accepts all arguments normally given for a QCheckBox :param bool nocall: True if command should not be called during initialization, False otherwise :type command: function :keyword command: function to call when the checkbox emits a 'clicked()' signal :type layout: QLayout :keyword layout: layout to place the CheckBox in :type checked: bool :keyword checked: True if checked to start with, False (default) if not. This is also the state the reset method restores. :type disabled_checkstate: bool :keyword disabled_checkstate: The check state the box should assume when it is disabled. Upon being re-enabled, the box will return to the same state it had prior to being disabled. Calling setChecked() on a disabled box will not actually change the state, but will change the state the box returns to upon being enabled. Use forceSetChecked() to change the current and re-enabled state of a disabled checkbox. The default of None avoids special behavior for disabled checkboxes. :type tip: str :param tip: The tooltip for the checkbox """ command = kwargs.pop('command', None) super().__init__(*args, **kwargs) if command: self.toggled.connect(command) if not nocall: self.toggled.emit(self.isChecked())
[docs]class SCheckBoxWithSubWidget(SCheckBoxToggle): """ This creates a checkbox that controls the enabled/disabled state of a subwidget. The subwidget can be a single widget or a combination widget such as a frame containing multiple widgets. The default behavior is to enable the subwidget when the checkbox is checked and disable the widget when the checkbox is unchecked. This behavior can be reversed providing reverse_state=True to the constructor. """
[docs] def __init__(self, text, subwidget, *args, **kwargs): """ Create a SCheckBoxEdit instance. :type text: str :param text: The text label of the checkbox :type subwidget: QWidget :param subwidget: The widget whose enabled state should be controlled by the checkbox :type reverse_state: bool :param reverse_state: Default behavior is to set the enabled state of the subwidget to the checked state of the checkbox. Set this parameter to True to set the enabled state of the subwidget to the opposite of the checked state of the checkbox. :type layout_type: module constant HORIZONTAL or VERTICAL, or None :param layout_type: The layout type for the checkbox and subwidget layout. Use None to avoid placing the checkbox and subwidget into a new layout. :type stretch: bool :param stretch: Whether to add a stretch to the new layout after adding the widgets to it. The default is True if a sublayout is created. Additional arguments can be taken from the SCheckBox class """ self.subwidget = subwidget self.reverse_state = kwargs.pop('reverse_state', False) # Set up the layout stretch = kwargs.pop('stretch', True) ltype = kwargs.pop('layout_type', HORIZONTAL) if ltype is not None: layout = kwargs.pop('layout', None) if ltype == HORIZONTAL: self.mylayout = SHBoxLayout(layout=layout) else: self.mylayout = SVBoxLayout(layout=layout) kwargs['layout'] = self.mylayout else: self.mylayout = None # Add the checkbox and the widget to the layout super().__init__(text, *args, **kwargs) if ltype is not None: self.mylayout.addWidget(self.subwidget) # Set up the signal/slot to enable/disable the subwidget self.toggled.connect(self._toggleStateChanged) self._toggleStateChanged(self.isChecked()) if stretch and self.mylayout: self.mylayout.addStretch()
def _toggleStateChanged(self, state): """ React to the checkbox changing checked state :type state: bool :param state: The new checked state of the checkbox """ if self.reverse_state: self.subwidget.setEnabled(not state) else: self.subwidget.setEnabled(state)
[docs] def setEnabled(self, state): """ Set checkbox enabled/disabled state. :type state: bool :param state: the desired state of the checkbox """ super().setEnabled(state) self.subwidget.setEnabled(state) if state: self._toggleStateChanged(self.isChecked())
[docs] def setVisible(self, state): """ Set both the checkbox and subwidget visible :type state: bool :param state: The desired visible state of both the checkbox and subwidget """ self.subwidget.setVisible(state) super().setVisible(state)
[docs] def reset(self): """ Reset all the widgets """ if hasattr(self.subwidget, 'reset'): self.subwidget.reset() super().reset()
[docs]class SCheckBoxEdit(SCheckBox): """ A combined checkbox and line edit widget where the checkbox controls the enabled/disabled state of the edit. For use in cases where you want:: [x] some text ______ more text where [x] is the checkbox and ______ is the line edit and the line edit should be disabled when the checkbox is unchecked. The parent widget of this widget is the SCheckBox. The widget also contains an SLabeledEdit in the self.edit property and the layout containing both in the self.mylayout property. You can place the widget directly in a layout at creation using the layout keyword argument, or later but adding the .mylayout property to an existing layout. Attribute requests will be fulfilled by the SCheckBox class first, and then the SLabeledEdit next. Thus, accessing self.isEnabled() will give the enabled state of the checkbox. Accessing self.validator() will give the validator set on the edit. To access an attribute on the edit where the checkbox has an attribute of the same name, you can always use self.edit.attribute. The exceptions to the attribute hierarchy are the text and setText methods. These give the text and setText methods of the edit, because that is overwhelmingly going to be the widget of interest for those methods. To access the checkbox methods, use checkBoxText and setCheckBoxText methods. The class constructor can be given keywords for either the SCheckBox or SLabeledEdit class. They will be passed to the proper class. Note: The first label of the SLabeledEdit is hidden by default, so the 'some text' to the edit in the example above should be set as the checkbox label. """
[docs] def __init__(self, text, *args, **kwargs): """ Create a SCheckBoxEdit instance. Keyword arguments can be taken from the SCheckBox, SLineEdit or SLabeledEdit classes. """ checked = kwargs.pop('checked', False) command = kwargs.pop('command', None) dstate = kwargs.pop('disabled_checkstate', None) layout = kwargs.pop('layout', None) tip = kwargs.get('tip', None) self.mylayout = SHBoxLayout(layout=layout) SCheckBox.__init__(self, text, checked=checked, command=command, disabled_checkstate=dstate, layout=self.mylayout, tip=tip) kwargs['layout'] = self.mylayout self.edit = SLabeledEdit("", **kwargs) self.edit.label.hide() self.toggled.connect(self._toggleStateChanged) self._toggleStateChanged(self.isChecked())
def __getattr__(self, attribute): """ Pass on any requests for unknown attributes to the line edit. If the attribute is still unknown, raise an AttributeError for this class """ try: return getattr(self.edit, attribute) except AttributeError: raise AttributeError('SCheckBoxEdit has no attribute %s' % attribute)
[docs] def text(self): """ Override the checkbox method to get the text from the edit instead. Use checkBoxText to get the checkbox text. :rtype: str :return: The text contained in the edit """ return self.edit.text()
[docs] def setText(self, text): """ Override the checkbox method to set the text in the edit instead. Use setCeckBoxText to get the checkbox text. :type text: str :param text: The new text for the edit """ self.edit.setText(text)
[docs] def checkBoxText(self): """ Get the current text of the checkbox label :rtype: str :return: The text of the checkbox label """ return SCheckBox.text(self)
[docs] def setCheckBoxText(self, text): """ Set the text of the checkbox label :type text: str :param text: The new text for the label """ SCheckBox.setText(self, text)
[docs] def reset(self): """ Reset all the widgets """ self.edit.reset() SCheckBox.reset(self)
def _toggleStateChanged(self, state): """ React to the checkbox changing checked state :type state: bool :param state: The new checked state of the checkbox """ # We don't call setEnabled on the entire LabeledEdit because we don't # want to enable/disable the text labels SLineEdit.setEnabled(self.edit, state)
[docs] def setEnabled(self, state): """ Set both the checkbox and line edit enabled/disabled :type state: bool :param state: The desired enabled state of both the checkbox and edit """ self.edit.setEnabled(state) SCheckBox.setEnabled(self, state)
[docs] def setVisible(self, state): """ Set both the checkbox and line edit visible/invisible :type state: bool :param state: The desired visible state of both the checkbox and edit """ self.edit.setVisible(state) super().setVisible(state)
[docs] @staticmethod def getValuesFromStr(str_in): """ Helper function for converting from string to bool and text. :type str_in: str :param str_in: String describing state of the widget :rtype: (bool, str) :return: Bool is True if checkbox is checked, otherwise - False. Str is edit text value. """ is_checked, text = str_in.split('_', 1) return bool(int(is_checked)), text
[docs] def af2SettingsGetValue(self): """ This function adds support for the settings mixin. It allows to save checked item states and text in case this combo box is included in a settings panel. :return: String of the form: '0_text', where first number can be 0 or 1 (for the checkbox), 'text' is where text from edit goes. :rtype: str """ return str(int(self.isChecked())) + '_' + self.text()
[docs] def af2SettingsSetValue(self, str_in): """ This function adds support for the settings mixin. It allows to set combo box check states and edit text when this widget is included in a settings panel. :type str_in: str :param str_in: String of the form: '0_text', where first number can be 0 or 1 (for the checkbox), 'text' is where text from edit goes. """ is_checked, text = SCheckBoxEdit.getValuesFromStr(str_in) self.setChecked(is_checked) self.setText(text)
[docs]class SRadioButton(QtWidgets.QRadioButton): """ A QRadioButton that accepts the command callback for the clicked() signal as an argument """
[docs] def __init__(self, *args, command=None, layout=None, checked=False, group=None, tip=None): """ Accepts all arguments normally given for a QRadioButton :param function command: function to call when the radio button emits a 'clicked()' signal :param QLayout layout: layout to place the RadioButton in :param bool checked: True if checked to start with, False (default) if not :param `SRadioButtonGroup` group: A radio button group to add this button to :param str tip: The tooltip for this radiobutton """ super().__init__(*args) if command: self.clicked.connect(command) if layout is not None: layout.addWidget(self) self.setChecked(checked) self.default_state = self.isChecked() if group: group.addExistingButton(self) if tip: self.setToolTip(tip)
[docs] def reset(self): """ Reset the radiobutton to its initial state """ self.setChecked(self.default_state)
[docs]class SRadioButtonWithSubWidget(SRadioButton): """ This creates a radio button that controls the enabled/disabled state of a subwidget. The subwidget can be a single widget or a combination widget such as a frame containing multiple widgets. """
[docs] def __init__(self, subwidget, *args, layout=None, layout_type=HORIZONTAL, stretch=True, **kwargs): """ :type subwidget: QWidget :param subwidget: the widget whose enabled state should be controlled by the radio button :type layout: QLayout or None :param layout: the layout to put this widget in if there is one :type layout_type: str :param layout_type: the layout type for the radio button and subwidget, module constant HORIZONTAL or VERTICAL :type stretch: bool :param stretch: whether to add a stretch to the new layout after adding the widgets to it """ self.subwidget = subwidget if layout_type == HORIZONTAL: self.mylayout = SHBoxLayout(layout=layout) else: self.mylayout = SVBoxLayout(layout=layout) kwargs['layout'] = self.mylayout super().__init__(*args, **kwargs) # handle swidgets like SLabeled* with a self.mylayout but not SFrame subwidget_layout = getattr(self.subwidget, 'mylayout', None) if subwidget_layout and not isinstance(self.subwidget, SFrame): self.mylayout.addLayout(subwidget_layout) else: self.mylayout.addWidget(self.subwidget) if stretch: self.mylayout.addStretch() self.toggled.connect(self._toggleStateChanged) self._toggleStateChanged(self.isChecked())
def _toggleStateChanged(self, state): """ React to a change in toggle state. :type state: bool :param state: the toggle state """ self.subwidget.setEnabled(state)
[docs] def setEnabled(self, state): """ Set enabled state. :type state: bool :param state: the desired state """ super().setEnabled(state) if state: self._toggleStateChanged(self.isChecked()) else: self.subwidget.setEnabled(state)
[docs] def setVisible(self, state): """ Set visible state. :type state: bool :param state: the visible state """ super().setVisible(state) self.subwidget.setVisible(state)
[docs] def reset(self): """ Reset. """ super().reset() if hasattr(self.subwidget, 'reset'): self.subwidget.reset()
[docs]class SRadioButtonGroup(QtWidgets.QButtonGroup): """ A QButtonGroup that accepts the command callback for the buttonClicked() signal as an argument. Also accepts the texts of the radio buttons to create and puts them in the optionally supplied layout. Button ID's are the order in which the buttons are created. """
[docs] def __init__(self, parent=None, labels=None, layout=None, command=None, command_clicked=None, nocall=False, default_index=0, tips=None, radio=True, keep_one_checked=False, subwidgets=None): """ Create a SRadioButtonGroup instance :type parent: QObject :param parent: the parent object of this ButtonGroup :type labels: list :param labels: list of str, the items are the labels of the buttons to create. The ID of each button will be the index of its label in this list. :type layout: QLayout :param layout: If supplied, the buttons created will be added to this layout in the order they are created. :type command: python callable :param command: The callback for each button's toggled(bool) signal :type command_clicked: python callable :param command_clicked: The callback for each button's buttonClicked signal :type nocall: bool :param nocall: False if the command parameter should be run when setting the default button checked during initialization, True if not :type default_index: int :param default_index: The button ID (index in the labels list) of the button that should be set checked during initialization. If using checkboxes and no checkbox should be checked, use None. :type tips: list :param tips: list of str, the items are tooltips for each button created by the labels list. Must be the same length and in the same order as labels. :type radio: bool :param radio: Type of the buttons to use in the group. If True, radio buttons are used, otherwise, checkbox buttons :type keep_one_checked: bool :param keep_one_checked: Only applies if radio=False so that checkboxes are used. If True, as least one checkbox will be enforced checked at all times. If the user unchecks the only checked box, it will immediately be re-checked. Note that any signals that emit when a button changes state will emit when the button is unchecked and then immediately emit again when the button is re-checked. :type subwidgets: dict or None :param subwidgets: if a dict then keys are labels and values are instances of SubWidget """ QtWidgets.QButtonGroup.__init__(self, parent) self._last_button_id = -1 self.layout = layout self.radio = radio self.keep_one_checked = keep_one_checked and not radio if not self.radio: # Set checkboxes to be not exclusive self.setExclusive(False) self.command = command self.command_clicked = command_clicked if command_clicked: self.buttonClicked.connect(command_clicked) if tips and labels: if len(tips) != len(labels): raise RuntimeError('Number of tooltips must be the same as the' 'number of labels if both are given.') subwidgets = subwidgets or {} # Being a member of this group does not actually keep the button from # being deleted. Something else has to hold a reference to them - adding # them to a layout would suffice, but we need to hold a reference in # case they are not immediately added to a layout. self._button_holder = [] # Create any buttons requested if labels: for name in labels: subwidget = subwidgets.get(name) bid = self.addButton(name, connect=not nocall, subwidget=subwidget) button = self.button(bid) if bid == default_index: button.setChecked(True) button.default_state = True if command and nocall: # Connect the slot AFTER checking the button to avoid # calling the callback. button.toggled.connect(command) if tips: tip = tips.pop(0) button.setToolTip(tip) self.default_index = default_index if self.keep_one_checked: # Button toggled is an overloaded signal, we want the one that # passes in the actual button object self.buttonToggled[(QtWidgets.QAbstractButton, bool)].connect(self.enforceOneChecked)
[docs] def enforceOneChecked(self, button, state): """ Ensure that at least one checkbox is checked. If none are checked the toggled checkbox will be set back to checked. :param QCheckBox button: The button that was toggled and resulted in this slot being called :param state: The current state of the checkbox after toggling. """ if not state: if not self.allCheckedText(): button.setChecked(True)
[docs] def allCheckedText(self): """ Get the text of all the checked boxes. In the case of exclusive radiobuttons, the list will be one item long. :rtype: list of str :return: Each item is label of a checked box """ return [x.text() for x in self.buttons() if x.isChecked()]
[docs] def addButton(self, text, connect=True, tip=None, subwidget=None): """ Create and add a new radio button to the group with the label text. :type text: str :param text: the label for the newly added button :type connect: bool :param connect: True if the button should have its toggled(bool) signal connected to the Group's command property (default), False if not :type tip: str :param tip: The tooltip for the button :type subwidget: SubWidget or None :param subwidget: the SubWidget whose enabled state should be controlled by the button :rtype: int :return: the ID of the new button """ if subwidget: if self.radio: aclass = SRadioButtonWithSubWidget else: aclass = SCheckBoxWithSubWidget button = aclass(subwidget.widget, text, layout=self.layout, layout_type=subwidget.layout_type) else: if self.radio: aclass = SRadioButton else: aclass = SCheckBox button = aclass(text, layout=self.layout) bid = self.addExistingButton(button, connect=connect) if tip: button.setToolTip(tip) return bid
[docs] def addExistingButton(self, button, connect=True): """ Add an existing radio button to the group. The button is not placed in the GroupBox layout. :type button: QRadioButton :param button: The button to add to the group :type connect: bool :param connect: True if the button should have its toggled(bool) signal and buttonClicked signal connected to the Group's command and command_clicked property respectively, False if not :rtype: int :return: the ID of the new button """ self._button_holder.append(button) bid = self._last_button_id + 1 QtWidgets.QButtonGroup.addButton(self, button, bid) self._last_button_id = bid if self.command and connect: button.toggled.connect(self.command) return bid
[docs] def removeButton(self, button): """ Remove the button from this group :type button: QAbstractButton :param button: The button to remove """ QtWidgets.QButtonGroup.removeButton(self, button) try: self._button_holder.remove(button) except ValueError: pass
[docs] def setTextChecked(self, text): """ Check the button with the given text :type text: str :param text: The text of the button to set checked :raises ValueError: If no such button exists """ button = self.getButtonByText(text) if button: button.setChecked(True) else: raise ValueError('No button with text "%s" exists' % text)
[docs] def checkedText(self): """ Return the text of the checked button :rtype: str :return: the text of the checked button """ if not self.exclusive(): # Even though only one button might be checked, still raise for # caution raise ValueError('More than one button might be checked, use ' 'allCheckedText instead.') try: return self.checkedButton().text() except AttributeError: return ""
[docs] def isChecked(self, text=None, id=0): """ Return whether the specified button is checked or not. The button can (must) be specified by either its text label or ID. :type text: str :param text: the label of the button to check :type id: int :param id: the ID of the button to check :rtype: bool :return: True if the specified button is checked, False if not """ if text: return text == self.checkedText() else: return self.button(id).isChecked()
[docs] def getButtonByText(self, text): """ Returns the button with the given text :type text: str :param text: The label of the desired button :rtype: QRadioButton or None :return: The button with the label text, or None if no such button is found """ for button in self.buttons(): if str(button.text()) == text: return button return None
[docs] def reset(self): """ Check the default radio button """ if self.radio: for idx, button in enumerate(self.buttons()): checked = self.default_index == idx button.setChecked(checked) else: for button in self.buttons(): button.reset()
[docs] def setEnabled(self, state): """ Set all child buttons to enabled state of state :type state: bool :param state: True if buttons should be enabled, False if not """ for button in self.buttons(): button.setEnabled(state)
[docs] def setVisible(self, state): """ Set the visibility of all child buttons :param bool state: Whether the widgets should be visible """ for button in self.buttons(): button.setVisible(state)
[docs] def setExclusive(self, state, uncheckable=False): """ Uncheck all the buttons except the button clicked :type state: bool :param state: True if buttons should be exclusive and uncheckable. False if its not exclusive. :type uncheckable: bool :param uncheckable: If true, when setExclusive is true it will allow to deselect the selected option. Won't have any effect if setExclusive is False, since uncheckability is already allowed in that case. if False, the function behaves like the usual setExclusive function. """ if uncheckable: QtWidgets.QButtonGroup.setExclusive(self, False) if state: command_clicked = self.command_clicked # Disconnect command_clicked and then reconnect after # connecting checkState. This will ensure execution of # checkState before command_clicked # importing locally to avoid circular import from schrodinger.ui.qt import utils as qtutils with qtutils.suppress_signals(self): self.buttonClicked.connect(self.checkState) if command_clicked: self.buttonClicked.connect(command_clicked) else: QtWidgets.QButtonGroup.setExclusive(self, state) # When state is false, always try to disconnect checkState if not state: try: self.buttonClicked.disconnect(self.checkState) # TypeError occurs when setExclusive state is false and checkState # was not connected before. Disconnect cannot happen since # connection never happened. except TypeError: pass
[docs] def checkState(self, button): """ Uncheck all the buttons except the button clicked :type button: QCheckBox or QRadioButton :param button: The button that was clicked """ [x.setChecked(False) for x in self.buttons() if x != button]
[docs]class SLabeledRadioButtonGroup(SRadioButtonGroup): """ Convenience class for adding all SRadioButtonGroup buttons to a layout with an optional group label """
[docs] def __init__(self, group_label=None, layout_type=HORIZONTAL, layout=None, stretch=True, **kwargs): """ Create an instance. :param str group_label: The label for the button group :param str layout_type: Module constants VERTICAL or HORIZONTAL for the type of internal layout :param `QLayout` layout: The layout to add the internal layout to :param bool stretch: Whether to add a stretch after the last button """ if layout_type == HORIZONTAL: self.mylayout = SHBoxLayout() else: self.mylayout = SVBoxLayout() if layout is not None: layout.addLayout(self.mylayout) if group_label: self.group_label = SLabel(group_label, layout=self.mylayout) else: self.group_label = None super().__init__(layout=self.mylayout, **kwargs) if stretch: self.mylayout.addStretch()
[docs] def setEnabled(self, state): """ Set the enabled state of radio buttons and group label :param bool state: Whether the widgets should be enabled """ super().setEnabled(state) if self.group_label: self.group_label.setEnabled(state)
[docs] def setVisible(self, state): """ Set the visibility of all child buttons and group label :param bool state: Whether the widgets should be visible """ super().setVisible(state) if self.group_label: self.group_label.setVisible(state)
[docs] def reset(self): """ Reset radio buttons and group label """ super().reset() if self.group_label: self.group_label.reset()
[docs]def set_layout_margins(layout, indent=False): """ Sets all layout margins to be 0, with the possible exception of indenting the left margin. :type layout: QBoxLayout :param layout: The layout to set the margins on :type indent: bool or int :param indent: False for a 0 left margin, True for a STANDARD_INDENT left margin, or an int for the value of the left margin """ if indent is True: left_indent = STANDARD_INDENT else: left_indent = int(indent) layout.setContentsMargins(left_indent, 0, 0, 0)
[docs]class SHBoxLayout(QtWidgets.QHBoxLayout): """ A QHBoxLayout with the proper margins for Schrodinger layouts. :type indent: bool or int :keyword indent: False for a 0 left margin, True for a STANDARD_INDENT left margin, or an int for the value of the left margin - False by default. :type layout: QBoxLayout :keyword layout: The layout this layout should be placed in """
[docs] def __init__(self, *args, **kwargs): """ Accepts all arguments normally given for a QHBoxLayout """ QtWidgets.QHBoxLayout.__init__(self, *args) indent = kwargs.get('indent', False) set_layout_margins(self, indent=indent) self.setSpacing(3) layout = kwargs.get('layout', None) if layout is not None: layout.addLayout(self)
[docs]class SVBoxLayout(QtWidgets.QVBoxLayout): """ A QVBoxLayout with the proper margins for Schrodinger layouts. :type indent: bool or int :keyword indent: False for a 0 left margin, True for a STANDARD_INDENT left margin, or an int for the value of the left margin - False by default. :type layout: QBoxLayout :keyword layout: The layout this layout should be placed in """
[docs] def __init__(self, *args, **kwargs): """ Accepts all arguments normally given for a QVBoxLayout """ QtWidgets.QVBoxLayout.__init__(self, *args) indent = kwargs.get('indent', False) set_layout_margins(self, indent=indent) self.setSpacing(1) layout = kwargs.get('layout', None) if layout is not None: layout.addLayout(self)
[docs]class SGridLayout(QtWidgets.QGridLayout): """ A QGridLayout with the proper margins for Schrodinger layouts :type indent: bool or int :keyword indent: False for a 0 left margin, True for a STANDARD_INDENT left margin, or an int for the value of the left margin - False by default. :type layout: QBoxLayout :keyword layout: The layout this layout should be placed in """
[docs] def __init__(self, *args, **kwargs): """ Accepts all arguments normally given for a QGridLayout """ QtWidgets.QGridLayout.__init__(self, *args) indent = kwargs.get('indent', False) set_layout_margins(self, indent=indent) self.setHorizontalSpacing(3) self.setVerticalSpacing(1) layout = kwargs.get('layout', None) if layout is not None: layout.addLayout(self)
[docs]class SGroupBox(QtWidgets.QGroupBox): """ A QGroupBox that includes a layout with the proper margins and spacing for Schrodinger layouts. By default, this is a vertical layout. """
[docs] def __init__(self, *args, **kwargs): """ Accepts all arguments normally given for a QGroupBox :type layout: string :keyword layout: Module constants VERTICAL, HORIZONTAL or GRID, for the type of internal layout :type parent_layout: QBoxLayout :keyword parent_layout: The layout to place this SGroupBox into :type checkable: bool :keyword checkable: Whether the groupbox is checkable, False by default :type checked: bool :keyword checked: Whether the groupbox should be checked if checkable. Default is True for checkable groupboxes. :type tip: str :param tip: The tooltip to apply to the group box :type flat: bool :param flat: whether to apply FlatStyle :type command: function :keyword command: function to call when the checkbox emits a 'clicked()' signal :type toggled_command: function :keyword toggled_command: function to call when the checkbox emits a 'toggled()' signal """ QtWidgets.QGroupBox.__init__(self, *args) layout = kwargs.pop('layout', None) if not layout or layout == VERTICAL: self.layout = SVBoxLayout(self) elif layout == HORIZONTAL: self.layout = SHBoxLayout(self) elif layout == GRID: self.layout = SGridLayout(self) self.layout.setContentsMargins(3, -1, 3, 3) parent_layout = kwargs.pop('parent_layout', None) if parent_layout is not None: parent_layout.addWidget(self) checkable = kwargs.pop('checkable', False) if checkable: self.setCheckable(checkable) checked = kwargs.pop('checked', True) self.setChecked(checked) self.default_checkstate = checked command = kwargs.pop('command', None) if command: self.clicked.connect(command) toggled_command = kwargs.pop('toggled_command', None) if toggled_command: self.toggled.connect(toggled_command) tip = kwargs.pop('tip', None) if tip: self.setToolTip(tip) flat = kwargs.pop('flat', False) self.setFlat(flat) if kwargs: raise TypeError('Unrecognized keyword arguments: ' + str(list(kwargs)))
[docs] def reset(self): """ Reset the GroupBox to the default check state if checkable """ if self.isCheckable(): self.setChecked(self.default_checkstate)
[docs]class SNoCommaMinMaxMixin: """ This is a mixin class to bound value to top and bottom. It validates the absence of comma in the input. Further, it will validate whether the value is in between min and max. Custom Validation classes can inherit from this class first, then the base Validator class. i.e. class MyValidator(SNoCommaMinMaxMixin, QtGui.QIntValidator): """
[docs] def minMaxValidate(self, value, pos, datatype=float, bottom=-numpy.inf, top=numpy.inf): """ Method is a validator :type value: int or float :param value: any int or float data to be validated :type pos: int :param pos: position of the cursor :type datatype: type :param datatype: datatype of value, Default is float :type bottom: int or float :param bottom: minimum value :type top: int or float :param top: maximum value :rtype: tuple :return: tuple of QtValidator definition of valid, value and pos. See PyQt QValidator documentation for definition of valid. """ if ',' in value: return self.Invalid, value, pos # If bottom > 0, '-' is not acceptable if value == '-': if bottom >= 0: return self.Invalid, value, pos else: return self.Intermediate, value, pos # Handle error in case value entered is not a number try: dvalue = datatype(value) except ValueError: # MATSCI-9867: Catch values with scientific notation. Any value # ending with E, E+ and E- returns a intermediate state. However, # user allowed value in scientific notation is significantly # limited. We will not allow user to input value larger than top # and lower than bottom even though user's intention is to add # exponent later to make it lower than top or bigger than bottom. try: datatype(value + "1") except ValueError: return self.Invalid, value, pos else: return self.Intermediate, value, pos # If bottom is greater than zero, all values are positive if bottom >= 0 and dvalue < 0: return self.Invalid, value, pos # If bottom is less than zero then value shouldn't be less than bottom if bottom < 0 and dvalue < bottom: return self.Invalid, value, pos # if top is less than zero then all values are negative if top < 0 and dvalue > 0: return self.Invalid, value, pos # if top is greater than zero then value shouldn't be greater than top if top > 0 and dvalue > top: return self.Invalid, value, pos return self.Acceptable, value, pos
[docs]class EnforcingValidatorMixin(object): """ This is a mixin class to be added to a QValidator subclass. It tracks the last valid value for a QLineEdit and can restore it when asked. Adding this mixin to a QValidator subclass will automatically enable it to work with the SLineEdit class "always_valid" parameter. Custom Validation classes should inherit from this class first, then the base Validator class. i.e. class MyValidator(EnforcingValidatorMixin, QtGui.QIntValidator): If a custom class overwrites the validate method, that method must store valid strings in self.last_valid_value before returning self.Acceptable. """
[docs] def restoreLastValidValue(self, edit): """ Restores edit to the last valid value validated by this Validator :type edit: QLineEdit :param edit: The QLineEdit to restore the value to """ if hasattr(self, 'last_valid_value'): if self.last_valid_value is not None: edit.setText(self.last_valid_value)
[docs] def validate(self, *args): """ Validate, and store the current value if it is valid See PyQt documentation for argument and return value documentation. If a custom class overwrites the validate method, that method must store valid strings in self.last_valid_value before returning self.Acceptable. """ results = super().validate(*args) if results[0] == self.Acceptable: self.last_valid_value = str(args[0]) return results
[docs]class FileBaseNameValidator(EnforcingValidatorMixin, QtGui.QValidator): """ Manage a file base name validator. """
[docs] def __init__(self, *args, **kwargs): QtGui.QValidator.__init__(self, *args, **kwargs) self.last_valid_value = None
[docs] def validate(self, value, position): """ See PyQt documentation for arguments and return values. """ if not value: return self.Intermediate, value, position if fileutils.is_valid_jobname(value): self.last_valid_value = value return self.Acceptable, value, position else: return self.Invalid, value, position
[docs]class SAsciiOnlyValidator(QtGui.QValidator): """ Ensures that the line edit contains only ASCII characters """
[docs] def validate(self, value, position): """ Do not accept strings that can't be converted to type 'str' See PyQt documentation for argument and return value documentation. """ try: value.encode('ascii') except UnicodeEncodeError: return self.Invalid, value, position return self.Acceptable, value, position
[docs]class SASLValidator(QtGui.QValidator): """ Ensures that the line edit contains valid ASL """
[docs] def validate(self, value, position): """ Mark not valid ASL strings as intermediate. """ with ioredirect.IOSilence(): if not len(value) or analyze.validate_asl(value): res = self.Acceptable else: res = self.Intermediate return res, value, position
[docs]class SNonEmptyStringValidator(EnforcingValidatorMixin, QtGui.QValidator): """ A QValidator that ensures that only accepts non-empty strings and can enforce validity if the validated widget loses focus and is empty. Unicode characters are not accepted. """
[docs] def __init__(self, parent=None): """ Create an SNonEmptyStringValidator object :type parent: QObject :param parent: the object that owns the validator :type bottom: float :param bottom: the least positive/most negative number accepted :type top: float :param top: the most positive/least negative number accepted. If top=None, then there is no limit at the top end of the scale. """ QtGui.QValidator.__init__(self, parent) # last_valid_value is used by the LineEdit if it needs to restore the # most recent valid value self.last_valid_value = None
[docs] def validate(self, value, position): """ We only want to accept non-empty strings. Empty strings are considered Intermediate as this allows the edit to be cleared. See PyQt documentation for argument and return value documentation. """ if value: try: test_value = value.encode('ascii') except UnicodeEncodeError: return self.Invalid, value, position test_value = test_value.decode('ascii') self.last_valid_value = test_value return self.Acceptable, value, position else: return self.Intermediate, value, position
[docs]class SNoCommaDoubleValidator(SNoCommaMinMaxMixin, QtGui.QDoubleValidator): """ A double validator that does not accept comma, which is used and accepted as a group separator in QDoubleValidator. """
[docs] def validate(self, value, pos): if not value: return self.Intermediate, value, pos # If top and bottom is not defined, set top and bottom top = self.top() if self.top() is not None else numpy.inf bottom = self.bottom() if self.bottom() is not None else -numpy.inf # if top < -1 and bottom is > 0, '-.' is not acceptable if value == '-.': if top <= -1 or bottom >= 0: return self.Invalid, value, pos else: return self.Intermediate, value, pos # If top is less than 0 , '.' is not not acceptable if value == '.': if top < 0 or bottom >= 1: return self.Invalid, value, pos else: return self.Intermediate, value, pos result = self.minMaxValidate(value, pos, datatype=float, bottom=bottom, top=top) if result[0] != self.Acceptable: return result return super(SNoCommaDoubleValidator, self).validate(value, pos)
[docs]class SRealValidator(EnforcingValidatorMixin, SNoCommaDoubleValidator): """ A QValidator that only accepts real numbers and can enforce validity if the validated widget loses focus and does not have valid text. """
[docs] def __init__(self, parent=None, bottom=None, top=None, decimals=None): """ :type parent: QObject :param parent: the object that owns the validator :type bottom: float :param bottom: the least positive/most negative number accepted :type top: float :param top: the most positive/least negative number accepted. If top=None, then there is no limit at the top end of the scale. :type decimals: int :param decimals: Maximum number of digits after the deciaml point. If decimals is None, default decimals from QDoubleValidator (1000) is used. """ super().__init__(parent) if bottom is not None: self.setBottom(bottom) if top is not None: self.setTop(top) if decimals: self.setDecimals(decimals) self.last_valid_value = None
[docs]class SNonNegativeRealValidator(SRealValidator): """ A QValidator that only accepts non-negative (0.0 to +infinity) real numbers and can enforce validity if the validated widget loses focus and does not have valid text. """
[docs] def __init__(self, parent=None, bottom=0.0, top=None, decimals=None): """ :type parent: QObject :param parent: the object that owns the validator :type bottom: float :param bottom: the least positive/most negative number accepted :type top: float :param top: the most positive/least negative number accepted. If top=None, then there is no limit at the top end of the scale. :type decimals: int :param decimals: Maximum number of digits after the deciaml point. If decimals is None, default decimals from QDoubleValidator (1000) is used. """ super().__init__(parent, bottom=bottom, top=top, decimals=decimals)
[docs]class SNoCommaIntValidator(SNoCommaMinMaxMixin, QtGui.QIntValidator): """ An int validator that does not accept comma, which is used and accepted as a group separator in QIntValidator. """
[docs] def validate(self, value, pos): if not value: return self.Intermediate, value, pos # if top and bottom are not defined set top and bottom top = self.top() if self.top() is not None else numpy.inf bottom = self.bottom() if self.bottom() is not None else -numpy.inf result = self.minMaxValidate(value, pos, datatype=int, bottom=bottom, top=top) if result[0] != self.Acceptable: return result return super(SNoCommaIntValidator, self).validate(value, pos)
[docs]class SNonNegativeIntValidator(EnforcingValidatorMixin, SNoCommaIntValidator): """ A QValidator that only accepts non-negative (0 to +infinity) int numbers and can enforce validity if the validated widget loses focus and does not have valid text. """
[docs] def __init__(self, parent=None, bottom=0, top=None): """ :type parent: QObject :param parent: the object that owns the validator :type bottom: int :param bottom: the least positive/most negative number accepted :type top: int :param top: the most positive/least negative number accepted. If top=None, then there is no limit at the top end of the scale. """ super().__init__(parent) self.setBottom(bottom) if top: self.setTop(top) self.last_valid_value = None
[docs]class SNumberListValidator(EnforcingValidatorMixin, QtGui.QValidator): """ A Validator that only accepts a list of numbers (int or floats) with a delimiter. Minimum accepted number can be defined in 'bottom' parameter. """
[docs] def __init__(self, parent=None, delimiter=None, number_type=int, bottom=0): """ Create a SNumberListValidator object. :type delimiter: str :param delimiter: The delimiter for the list of numbers. If not given, any whitespace is considered a delimiter :type number_type: type (int or float allowed) :param number_type: Type to convert to, int or float :type bottom: int or float :param bottom: the least positive/most negative number accepted See parent class for additional documentation """ QtGui.QValidator.__init__(self, parent) self.delimiter = delimiter self.number_type = number_type self.bottom = self.number_type(bottom) self.last_valid_value = None self.allowed_symbols = [''] if number_type.__name__ == 'float': self.allowed_symbols.append('.') if self.bottom < 0.0: self.allowed_symbols.append('-') self.allowed_symbols = set(self.allowed_symbols)
[docs] def validate(self, value, position): """ Only accept a list of comma-delimited positive integers See PyQt documentation for argument and return value documentation. """ value = str(value) # Empty edit is OK in the middle of editing if not value: return self.Intermediate, value, position tokens = value.split(self.delimiter) for token in tokens: # Values must be of the correct type try: val = self.number_type(token.strip()) # The user is probably still entering input if the token is empty # or comprises only digits and allowed symbols. except ValueError: if all(char in self.allowed_symbols or char.isdigit() for char in token): return self.Intermediate, value, position # Otherwise, the token is actually invalid return self.Invalid, value, position # Values must not be below bottom if val < self.bottom: return self.Invalid, value, position self.last_valid_value = value return self.Acceptable, value, position
[docs]class SPosIntListValidator(SNumberListValidator): """ A Validator that only accepts a list of positive ints with a delimiter. """
[docs] def __init__(self, parent=None, delimiter=None): """ Create a PosIntListValidator object :type delimiter: str :param delimiter: The delimitor for the list of ints. If not given, any whitespace is considered a delimiter See parent class for additional documentation """ SNumberListValidator.__init__(self, parent=parent, delimiter=delimiter, number_type=int, bottom=1)
[docs]class SRegularExpressionValidator(EnforcingValidatorMixin, QtGui.QRegularExpressionValidator): """ A validator that only accepts strings that match the regexp. """
[docs] def __init__(self, regexp: QtCore.QRegularExpression, parent: QtCore.QObject = None): """ Create an SRegularExpressionValidator object. :param regexp: Regular expression to validate :param parent: the object that owns the validator """ super().__init__(regexp, parent=parent) self.last_valid_value = None
[docs]class SComboBox(QtWidgets.QComboBox): """ A QComboBox widget with several Pythonic enhancements. It can also set its items and select a default value at creation time; and can be easily reset to its default value via reset. A slot can be specified that will be hooked up with the currentIndexChanged[str] signal. This command will be called when the default value is set unless nocall=True Action items can be added via the addAction() method. These items are not selectable. When one is chosen, its callback is executed and previously selected item is re-selected. """ currentIndexChanged = QtCore.pyqtSignal([int], [str])
[docs] def __init__(self, parent=None, items=None, default_item="", default_index=0, command=None, nocall=False, layout=None, tip="", itemdict=None, adjust_to_contents=True, min_width=None): """ Create an SComboBox object. :type parent: QWidget :param parent: The parent widget of this SComboBox :type items: list :param items: list of items (str) to be added to the ComboBox - see also itemdict. :type default_item: str :param default_item: text of the item to select initially and upon reset :type default_index: int :param default_index: the index of the item to select initially and upon reset. default_item overrides this parameter. :type command: callable :param command: The slot to connect to the currentIndexChanged[str] signal. This command will be called when the default item is selected during initialization unless nocall=True. :type nocall: bool :param nocall: True if command should not be called during initialization, False otherwise :type layout: QLayout :param layout: The layout to place this widget in :type tip: str :param tip: The tooltip for the combo :type itemdict: `collections.OrderedDict` :param itemdict: An OrderedDictionary with ComboBox items as keys and data as values. The data for the current ComboBox item is returned by the currentData method. If both items and itemdict are given, items are added first. :param bool adjust_to_contents: True if the size adjust policy should be set to "Adjust to contents" :type min_width: int :param min_width: this property holds the minimum number of characters that should fit into the combobox """ super().__init__(parent) if items: self.addItems(items) if itemdict: self.addItemsFromDict(itemdict) self.default_item = default_item self.default_index = default_index self.reset() if command: self.currentIndexChanged[str].connect(command) if not nocall: # Normally, we could just connect this SIGNAL before setting the # default value and let that process emit the signal naturally. # However, if the default index is 0, then the combobox does not # emit the signal since the value never actually changed. So # instead, we set the value, then connect the slot, then emit the # signal by hand. self.currentIndexChanged[str].emit(self.currentText()) if layout is not None: layout.addWidget(self) if tip: self.setToolTip(tip) self.activated.connect(self._actionTriggered) if adjust_to_contents: self.setSizeAdjustPolicy(self.AdjustToContents) if min_width: self.setMinimumContentsLength(min_width)
[docs] def addItemsFromDict(self, items): """ Add items to the combo box from a dictionary of {text: user_data}. Note that setting the order of elements requires a `collections.OrderedDict` :param items: A dictionary of {text: user_data} to add to the combo box :type items: dict """ for key, val in items.items(): self.addItem(key, val)
[docs] def currentData(self, role=Qt.UserRole): """ Get the user data for the currently selected item :param role: The role to retrieve data for :type role: int :return: The user data """ index = self.currentIndex() data = self.itemData(index, role) return data
[docs] def setCurrentData(self, data, role=Qt.UserRole): """ Select the index with the specified user data :param text: The user data to select :type data: object :param role: The role to search :type role: int :raise ValueError: If the specified data is not found in the combo box """ index = self.findDataPy(data, role) self.setCurrentIndex(index)
[docs] def findDataPy(self, data, role=Qt.UserRole): """ Get the index of the item containing the specified data. Comparisons are done in Python rather than in C++ (as they are in `findData`), so Python types without a direct C++ mapping can be compared correctly. :param data: The data to search for :type data: object :param role: The role to search :type role: int :raise ValueError: If the specified data cannot be found """ for index in range(self.count()): if data == self.itemData(index, role): return index raise ValueError("Data not in combo box: %s" % data)
[docs] def currentString(self): """ Obsolete. Use QComboBox.currentText() instead. :rtype: str :return: The currentText() value of the ComboBox converted to a string """ return str(self.currentText())
[docs] def reset(self): """ Reset the current ComboBox item to the default item. """ if self.default_item: # findText returns -1 if the text isn't found index = max(0, self.findText(self.default_item)) else: index = self.default_index self.setCurrentIndex(index)
[docs] def setCurrentText(self, text): """ Set the combobox to the item with the supplied text :type text: str :param text: The text of the item to make the current item :raise: ValueError if no item with text exists """ index = self.findText(text) if index < 0: raise ValueError('No item with text=%s was found' % text) self.setCurrentIndex(index)
[docs] def addItem(self, *args, **kwargs): """ Add given items and emit signals as needed. """ was_empty = (self.count() == 0) super().addItem(*args, **kwargs) if was_empty: self.currentIndexChanged[int].emit(self.currentIndex()) self.currentIndexChanged[str].emit(self.currentText())
[docs] def addItems(self, *args, **kwargs): """ Add given items and emit signals as needed. """ was_empty = (self.count() == 0) super(SComboBox, self).addItems(*args, **kwargs) if was_empty: self.currentIndexChanged[int].emit(self.currentIndex()) self.currentIndexChanged[str].emit(self.currentText())
[docs] def addAction(self, text, callback): """ Add an "action" menu item. When selected by the user, it will invoke the callback and re-select the previously selected item. """ self.addItem(text) index = self.count() - 1 self.setItemData(index, callback, CALLBACK_ROLE)
[docs] def setCurrentIndex(self, index): # For documentation, see QComboBox.setCurrentIndex(). if self.itemData(index, CALLBACK_ROLE): raise ValueError("Can not select item %i because it's an action" % index) prev_selection = self.currentIndex() if index == prev_selection: return self._previous_index = index super(SComboBox, self).setCurrentIndex(index) self.currentIndexChanged[int].emit(index) self.currentIndexChanged[str].emit(self.currentText())
def _actionTriggered(self, index): """ Called when a menu item is selected. If it's an action item, the callback is invoked. If it's a regular selectable item, it is selected. """ callback = self.itemData(index, CALLBACK_ROLE) if callback: # An action item was selected # Revert the selection to the previously-selected item: super(SComboBox, self).setCurrentIndex(self._previous_index) # Trigger the callback, but don't emit currentIndexChanged: callback() else: # This is a regular menu item self._previous_index = index self.currentIndexChanged[int].emit(index) self.currentIndexChanged[str].emit(self.currentText()) def _actionsPresent(self): """ Returns True if there is at least one action item in the menu. """ for index in range(self.count()): if self.itemData(index, CALLBACK_ROLE): return True return False
[docs] def keyPressEvent(self, event): """ Ignore Up/Down key press events if any action items are present. In the future, consider re-implementing these handlers; it's a non- trivial task. See http://code.metager.de/source/xref/lib/qt/src/gui/widgets/qcombobox.cpp#2931 """ key = event.key() if key == Qt.Key_Up or key == Qt.Key_Down: if self._actionsPresent(): return super(SComboBox, self).keyPressEvent(event)
[docs] def items(self): """ Return the current list of items in the combo box. :rtype: list :return: the current list of items """ return [self.itemText(i) for i in range(self.count())]
[docs]class SLabeledComboBox(SComboBox): """ An SComboBox that has a label attached to it. A SLabeledComboBox instance has a .label property that is an SLabel widget. The SLabeledComboBox instance also has a .mylayout property that the label and ComboBox are placed into. This .mylayout should be added to another layout in order to place the widgets into the GUI if the layout= keyword is not used during construction. """
[docs] def __init__(self, text, **kwargs): """ Create a SLabledComboBox instance :type text: str :keyword text: The text of the label :type side: str :keyword side: 'left' if the label should appear to the left of the ComboBox (default), or 'top' if the label should appear above it :type stretch: bool :keyword stretch: Whether to put a stretch after the ComboBox (or after the after_label). Default is True, even if side='top'. :type after_label: str :keyword after_label: Label text to put after the ComboBox - default is None :type items: list :param items: list of items (str) to be added to the ComboBox :type default_item: str :param default_item: text of the item to select initially and upon reset :type default_index: int :param default_index: the index of the item to select initially and upon reset. default_item overrides this parameter. :type command: callable :param command: The slot to connect to the currentIndexChanged[str] signal. This command will be called when the default item is selected during initialization unless nocall=True. :type nocall: bool :param nocall: True if command should not be called during initialization, False otherwise :type layout: QLayout :keyword layout: layout to place the LabeledEdit in :type tip: str :param tip: The tooltip for the labels and combobox :type itemdict: `collections.OrderedDict` :param itemdict: An OrderedDictionary with ComboBox items as keys and data as values. The data for the current ComboBox item is returned by the currentData method. If both items and itemdict are given, items are added first. :type min_width: int :param min_width: this property holds the minimum number of characters that should fit into the combobox """ side = kwargs.pop('side', 'left') if side == 'left': #: Place this layout into the parent GUI, not the ComboBox self.mylayout = SHBoxLayout() else: self.mylayout = SVBoxLayout() self.label = SLabel(text, layout=self.mylayout) items = kwargs.pop('items', None) default_item = kwargs.pop('default_item', "") default_index = kwargs.pop('default_index', 0) command = kwargs.pop('command', None) nocall = kwargs.pop('nocall', False) itemdict = kwargs.pop('itemdict', None) min_width = kwargs.pop('min_width', None) SComboBox.__init__(self, items=items, layout=self.mylayout, default_item=default_item, default_index=default_index, command=command, nocall=nocall, itemdict=itemdict, min_width=min_width) after = kwargs.pop('after_label', "") tip = kwargs.pop('tip', "") if after: self.after_label = SLabel(after, layout=self.mylayout) if tip: self.after_label.setToolTip(tip) if tip: self.setToolTip(tip) self.label.setToolTip(tip) if kwargs.pop('stretch', True): self.mylayout.addStretch() parent_layout = kwargs.pop('layout', None) if parent_layout is not None: parent_layout.addLayout(self.mylayout) if kwargs: raise TypeError('Unrecognized keyword arguments: ' + str(list(kwargs)))
[docs] def reset(self): """ Reset the labels and ComboBox to their default values """ self.label.reset() SComboBox.reset(self) try: self.after_label.reset() except AttributeError: pass
[docs] def setEnabled(self, state): """ Set all child widgets to enabled state of state :type state: bool :param state: True if widgets should be enabled, False if not """ SComboBox.setEnabled(self, state) self.label.setEnabled(state) try: self.after_label.setEnabled(state) except AttributeError: pass
[docs] def setVisible(self, state): """ Set all child widgets to visible state of state :type state: bool :param state: True if widgets should be visible, False if not """ SComboBox.setVisible(self, state) self.label.setVisible(state) try: self.after_label.setVisible(state) except AttributeError: pass
[docs]class ActionComboBox(QtWidgets.QComboBox): """ A combo box with a few notable changes: 1) "action" signal is emitted when a new item is selected, and callback is called when one is provided for the selected menu. 2) The label of the combo box never changes - there is no concept of a currently selected item. 3) The label of the combo box is not shown in the pop-up menu. For custom behavior (e.g. disabling items), use the ActionComboBox.menu attribute to get the QMenu object. :param itemSelected: signal emitted when a menu item is selected. The only argument is the text of the selected item. :type itemSelected: `PyQt5.QtCore.pyqtSignal` """ itemSelected = QtCore.pyqtSignal(str)
[docs] def __init__(self, parent, text=''): """ :param text: Text/label to show in the combo menu. This is what the user sees until they click on the widget. :param text: str """ super(ActionComboBox, self).__init__(parent) super(ActionComboBox, self).addItem(text) self.menu = QtWidgets.QMenu() self.menu.triggered.connect(self.actionTriggered)
[docs] def addItem(self, text, callback=None): """ Add a menu item with the given action callback. When this item is selected, the specified callback will be called in addition to emitting the itemSelected signal. """ if callback: self.menu.addAction(text, callback) else: self.menu.addAction(text)
[docs] def addAction(self, action): self.menu.addAction(action)
[docs] def addSeparator(self): return self.menu.addSeparator()
[docs] def count(self): return len(self.menu.actions())
[docs] def setText(self, combo_txt): """ Sets texts that will be shown in combo box label widget. :parama combo_txt: combo box default text :type combo_txt: str """ self.setItemText(0, combo_txt)
[docs] def reset(self): self.menu.clear()
[docs] def showPopup(self): global_pos = self.parent().mapToGlobal(self.geometry().topLeft()) self.menu.exec(global_pos)
[docs] def actionTriggered(self, action): """ This function is called when item is selected in the popup. :param item_index: Index (position) of the selected menu item. :type item_index: int """ self.itemSelected.emit(action.text())
[docs]class ActionPushButton(QtWidgets.QPushButton): """ A push button that pops-up a menu when clicked. Similar to ActionComboBox except that there is no concept of selection / current item. """ # Paths with backslashes don't work in QSS and need to be converted icon = UI_QT_DIR + "/pushbutton-menu-arrow.png" if sys.platform.startswith('darwin'): # Move the icon into the button (without this, the left edge of the # icon gets rendered near the right edge of the button): platform_specific = 'left: -13px;' else: # Increase the width of the button icon area slightly, to fit the # whole icon platform_specific = 'width: 13px;' STYLE_SHEET = """ QPushButton::menu-indicator { image: url("%s"); subcontrol-position: center right; subcontrol-origin: padding; %s } """ % (icon, platform_specific)
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.menu = QtWidgets.QMenu() self.setMenu(self.menu) self.setStyleSheet(self.STYLE_SHEET)
[docs] def addItem(self, text, function): return self.menu.addAction(text, function)
[docs] def addAction(self, action): self.menu.addAction(action)
[docs] def addSeparator(self): return self.menu.addSeparator()
[docs]class MinMaxSpinBoxTether(QtCore.QObject): """ A QObject that ties two spinboxes together and ensures that the max spinbox never has a value less than the min spinbox. This restriction is enforced when one of the spinbox changes by changing the value of the other spinbox if necessary. """
[docs] def __init__(self, minbox, maxbox, parent=None): """ Create a MinMaxSpinboxTether object :type minbox: QSpinBox :param minbox: The spinbox controlling the minimum value :type maxbox: QSpinBox :param maxbox: The spinbox controlling the maximum value :type parent: QWidget :param parent: The parent widget """ super().__init__(parent=parent) # Ensure that bounds are correct assert (maxbox.maximum() >= minbox.minimum() and minbox.minimum() <= maxbox.minimum()) self.minbox = minbox self.maxbox = maxbox self.minbox.valueChanged.connect(self.minimumChanged) self.maxbox.valueChanged.connect(self.maximumChanged)
[docs] def minimumChanged(self, minval): """ React to a change in the minimum value :type minval: int or float :param minval: The new minimum value """ maxval = self.maxbox.value() if minval > maxval: # Delayed import to avoid circular imports from schrodinger.ui.qt import utils as qtutils with qtutils.suppress_signals(self.maxbox): self.maxbox.setValue(minval)
[docs] def maximumChanged(self, maxval): """ React to a change in the maximum value :type maxval: int or float :param maxval: The new maximum value """ minval = self.minbox.value() if minval > maxval: # Delayed import to avoid circular imports from schrodinger.ui.qt import utils as qtutils with qtutils.suppress_signals(self.minbox): self.minbox.setValue(maxval)
[docs]class SSpinBox(QtWidgets.QSpinBox): """ A QSpinBox that can set it's min, max and value in the constructor plus pack itself in a layout """
[docs] def __init__(self, parent=None, minimum=None, maximum=None, value=None, layout=None, command=None, nocall=False, stepsize=None, width=None, tip=None): """ Create an SSpinBox object :type parent: QObject :param parent: the parent object of this SSpinBox :type minimum: int :param minimum: The minimum value of this SSpinBox :type maximum: int :param maximum: The maximum value of this SSpinBox :type value: int :param value: The initial value of this SSpinBox. This value will also be used as the default value by the reset method. :type layout: QLayout :param layout: If supplied, the SSpinBox created will be added to this layout :type command: python callable :param command: The callback for the valueChanged signal. This command will not be called during initialization unless nocall is set to False. :type nocall: bool :param nocall: True if the callback command should not be called during initialization, False (default) if the command should be called. :type stepsize: int :param stepsize: The stepsize each arrow click changes the value by :type width: int :param width: The maximum width of the spinbox :param `str` tip: The tooltip """ QtWidgets.QSpinBox.__init__(self, parent) if minimum is not None: self.setMinimum(minimum) if maximum is not None: self.setMaximum(maximum) if not nocall and command is not None: self.valueChanged.connect(command) if value is not None: self.setValue(value) if stepsize is not None: self.setSingleStep(stepsize) if width: self.setMaximumWidth(width) self.default_value = value if nocall and command is not None: self.valueChanged.connect(command) if layout is not None: layout.addWidget(self) if tip: self.setToolTip(tip)
[docs] def reset(self): """ Reset the spin box to its default value """ if self.default_value is not None: self.setValue(self.default_value)
[docs]class SDoubleSpinBox(QtWidgets.QDoubleSpinBox): """ A QDoubleSpinBox that can set it's min, max and value in the constructor plus pack itself in a layout """
[docs] def __init__(self, parent=None, minimum=None, maximum=None, value=None, layout=None, command=None, nocall=False, stepsize=None, decimals=None, width=None, tip=None): """ Create an SDoubleSpinBox object :type parent: QObject :param parent: the parent object of this SDoubleSpinBox :type minimum: float :param minimum: The minimum value of this SDoubleSpinBox :type maximum: float :param maximum: The maximum value of this SDoubleSpinBox :type value: float :param value: The initial value of this SDoubleSpinBox. This value will also be used as the default value by the reset method. :type stepsize: float :param stepsize: The stepsize each arrow click changes the value by :type decimals: int :param decimals: Number of decimal places to display :type layout: QLayout :param layout: If supplied, the SDoubleSpinBox created will be added to this layout :type command: python callable :param command: The callback for the valueChanged signal. This command will be called during initialization unless nocall is set to True. :type nocall: bool :param nocall: True if the callback command should not be called during initialization, False (default) if the command should be called. :type width: int :param width: The maximum width of the spinbox :param str tip: The tooltip """ QtWidgets.QDoubleSpinBox.__init__(self, parent) if minimum is not None: self.setMinimum(minimum) if maximum is not None: self.setMaximum(maximum) if not nocall and command is not None: self.valueChanged.connect(command) if stepsize is not None: self.setSingleStep(stepsize) if decimals is not None: self.setDecimals(decimals) # Preventing default decimals from rounding the initial value if value is not None: self.setValue(value) self.default_value = value if nocall and command is not None: self.valueChanged.connect(command) if layout is not None: layout.addWidget(self) if width: self.setMaximumWidth(width) if tip: self.setToolTip(tip)
[docs] def reset(self): """ Reset the spin box to its default value """ if self.default_value is not None: self.setValue(self.default_value)
[docs]class SLabeledDoubleSpinBox(SDoubleSpinBox): """ An SDoubleSpinBox that has a label attached to it. A SLabledDoubleSpinBox instance has a .label property that is an SLabel widget. The SLabeledComboBox instance also has a .mylayout property that the label and SDoubleSpinBox are placed into. This .mylayout should be added to another layout in order to place the widgets into the GUI if the layout= keyword is not used during construction. """
[docs] def __init__(self, text, **kwargs): """ Create a SLabledDoubleSpinBox instance :type text: str :keyword text: The text of the label :type side: str :keyword side: 'left' if the label should appear to the left of the SDoubleSpinBox (default), or 'top' if the label should appear above it :type stretch: bool :keyword stretch: Whether to put a stretch after the SDoubleSpinBox (or after the after_label). Default is True, even if side='top'. :type after_label: str :keyword after_label: Label text to put after the SDoubleSpinBox - default is None :type parent: QObject :param parent: the parent object of this SDoubleSpinBox :type minimum: float :param minimum: The minimum value of this SDoubleSpinBox :type maximum: float :param maximum: The maximum value of this SDoubleSpinBox :type value: float :param value: The initial value of this SDoubleSpinBox :type stepsize: float :param stepsize: The stepsize each arrow click changes the value by :type decimals: int :param decimals: Number of decimal places to display :type layout: QLayout :param layout: If supplied, the SDoubleSpinBox created will be added to this layout :type command: python callable :param command: The callback for the valueChanged signal. This command will not be called during initialization unless nocall is set to False. :type nocall: bool :param nocall: True if the callback command should not be called during initialization, False (default) if the command should be called. :type tip: str :param tip: The tooltip to apply to the labels and spinbox """ side = kwargs.pop('side', 'left') if side == 'left': #: Place this layout into the parent GUI, not the ComboBox self.mylayout = SHBoxLayout() else: self.mylayout = SVBoxLayout() self.label = SLabel(text, layout=self.mylayout) after = kwargs.pop('after_label', "") stretch = kwargs.pop('stretch', True) parent_layout = kwargs.pop('layout', None) tip = kwargs.pop('tip', None) SDoubleSpinBox.__init__(self, layout=self.mylayout, **kwargs) if after: self.after_label = SLabel(after, layout=self.mylayout) if stretch: self.mylayout.addStretch() if parent_layout is not None: parent_layout.addLayout(self.mylayout) if tip: self.label.setToolTip(tip) self.setToolTip(tip) if after: self.after_label.setToolTip(tip)
[docs] def reset(self): """ Reset the labels and ComboBox to their default values """ self.label.reset() SDoubleSpinBox.reset(self) try: self.after_label.reset() except AttributeError: pass
[docs] def setEnabled(self, state): """ Set all child widgets to enabled state of state :type state: bool :param state: True if widgets should be enabled, False if not """ SDoubleSpinBox.setEnabled(self, state) self.label.setEnabled(state) try: self.after_label.setEnabled(state) except AttributeError: pass
[docs] def setVisible(self, state): """ Set all child widgets to visible state of state :type state: bool :param state: True if widgets should be visible, False if not """ SDoubleSpinBox.setVisible(self, state) self.label.setVisible(state) try: self.after_label.setVisible(state) except AttributeError: pass
[docs]class SLabeledSpinBox(SSpinBox): """ An SSpinBox that has a label attached to it. A SLabledSpinBox instance has a .label property that is an SLabel widget. The SLabeledSpinBox instance also has a .mylayout property that the label and SSpinBox are placed into. This .mylayout should be added to another layout in order to place the widgets into the GUI if the layout= keyword is not used during construction. """
[docs] def __init__(self, text, **kwargs): """ Create a SLabledSpinBox instance :type text: str :keyword text: The text of the label :type side: str :keyword side: 'left' if the label should appear to the left of the SSpinBox (default), or 'top' if the label should appear above it :type stretch: bool :keyword stretch: Whether to put a stretch after the SSpinBox (or after the after_label). Default is True, even if side='top'. :type after_label: str :keyword after_label: Label text to put after the SSpinBox - default is None :type parent: QObject :param parent: the parent object of this SSpinBox :type minimum: int :param minimum: The minimum value of this SSpinBox :type maximum: int :param maximum: The maximum value of this SSpinBox :type value: int :param value: The initial value of this SSpinBox :type layout: QLayout :param layout: If supplied, the SSpinBox created will be added to this layout :type command: python callable :param command: The callback for the valueChanged signal. This command will not be called during initialization unless nocall is set to False. :type nocall: bool :param nocall: True if the callback command should not be called during initialization, False (default) if the command should be called. :type tip: str :param tip: The tooltip to apply to the labels and edit """ side = kwargs.pop('side', 'left') if side == 'left': #: Place this layout into the parent GUI, not the ComboBox self.mylayout = SHBoxLayout() else: self.mylayout = SVBoxLayout() self.label = SLabel(text, layout=self.mylayout) after = kwargs.pop('after_label', '') stretch = kwargs.pop('stretch', True) parent_layout = kwargs.pop('layout', None) SSpinBox.__init__(self, layout=self.mylayout, **kwargs) if after: self.after_label = SLabel(after, layout=self.mylayout) if stretch: self.mylayout.addStretch() if parent_layout is not None: parent_layout.addLayout(self.mylayout) self.setToolTip(kwargs.pop('tip', ''))
[docs] def reset(self): """ Reset the labels and ComboBox to their default values """ self.label.reset() SSpinBox.reset(self) try: self.after_label.reset() except AttributeError: pass
[docs] def setEnabled(self, state): """ Set all child widgets to enabled state of state :type state: bool :param state: True if widgets should be enabled, False if not """ SSpinBox.setEnabled(self, state) self.label.setEnabled(state) try: self.after_label.setEnabled(state) except AttributeError: pass
[docs] def setVisible(self, state): """ Set all child widgets to visible state of state :type state: bool :param state: True if widgets should be visible, False if not """ super().setVisible(state) self.label.setVisible(state) try: self.after_label.setVisible(state) except AttributeError: pass
[docs] def setToolTip(self, tip): """ Set the tooltip for this widget :type tip: str :param tip: The tooltip to apply to the labels and spinbox """ if tip is None: return self.label.setToolTip(tip) super().setToolTip(tip) try: self.after_label.setToolTip(tip) except AttributeError: pass
[docs]class SListWidget(QtWidgets.QListWidget): """ A QListWidget that can set its items and set up a callback at creation. """
[docs] def __init__(self, parent=None, items=None, command=None, nocall=False, layout=None, emit='row', mode='single', drag=False, selection_command=None): """ :type parent: QObject :param parent: the parent object of this SListWidget :type items: list :param items: list of str, the items are the entries in the ListWidget. :type command: python callable :param command: The callback for when the current item is changed signal. The callback may be triggered before selected command signal. :type emit: str :param emit: 'row' if the signal emitted should be 'currentRowChanged('int')' or else the signal will be 'currentTextChanged[str]. The default is 'row' :type mode: str or QAbstractItemView.SelectionMode :param mode: The selection mode - either 'single', 'extended', or a SelectionMode enum. The default is 'single'. :type drag: bool :param drag: True if items can be dragged around in the listwidget. Note that if using actual QListWidgetItems, those items must have ItemIsDragEnabled and ItemIsSelectable flags turned on :type nocall: bool :param nocall: False if the command parameter should be run when filling the widget during initialization, True if not. The default is False :type layout: QLayout :param layout: If supplied, the ListWidget created will be added to this layout :type selection_command: python callable :param selection_command: The callback for whenever the selection changes :note: Do not create a method named reset that calls self.clear() as that causes recursion errors. It's probably best to avoid naming a method "reset" altogether as it appears to be some sort of private method of the class (that the public clear() method calls). """ def connect_signal(): if emit == 'row': self.currentRowChanged.connect(command) else: self.currentTextChanged[str].connect(command) QtWidgets.QListWidget.__init__(self, parent) if command and not nocall: connect_signal() if selection_command: self.itemSelectionChanged.connect(selection_command) if items: self.addItems(items) if command and nocall: connect_signal() # Selection mode if mode == 'single': self.setSelectionMode(self.SingleSelection) elif mode == 'extended': self.setSelectionMode(self.ExtendedSelection) else: self.setSelectionMode(mode) # Internal dragging if drag: self.setDragEnabled(True) self.setDropIndicatorShown(True) self.setDragDropMode(self.InternalMove) if layout is not None: layout.addWidget(self)
[docs] def allItems(self): """ Return all the items in the list widget in order, including hidden ones :rtype: list :return: list of all QListWidgetItems in the list widget in order """ return [self.item(x) for x in range(self.count())]
[docs] def allItemsIter(self, role=Qt.UserRole): """ Return a generator over text and data of the list widget, inspired by .items() method of dict. :type role: int :param role: User role to pass do .data function :rtype: generator(tuple(str, type)) :return: Generator over tupple of text and data of the list widget """ return ((str(self.item(x).text()), self.item(x).data(role)) for x in range(self.count()))
[docs] def allText(self): """ Return the list of all text values, including hidden items :rtype: list :return: List of strings """ return [str(x.text()) for x in self.allItems()]
[docs] def removeItem(self, item): """ Removes and returns the given item :type item: QListWidgetItem or 0 :param item: The item to be removed from the list widget or 0 if it is not found in the list widget """ row = self.row(item) return self.takeItem(row)
[docs] def removeItemByText(self, text): """ Removes and returns the item with the given text :type text: str :param text: the text of the item to be removed :rtype: QListWidgetItem or None :return: The removed item, or None if an item with the given text is not found. """ try: item = self.findItems(text, QtCore.Qt.MatchExactly)[0] self.takeItem(self.row(item)) return item except IndexError: return None
[docs] def removeSelectedItems(self): """ Removes selected items. """ for item in self.selectedItems(): taken_item = self.takeItem(self.row(item)) # takeItem does NOT delete the item from memory del taken_item
[docs] def setTextSelected(self, text, selected=True): """ Selects the item with the given text. Will select hidden items too. :type text: str :param text: the text of the item to be selected :type selected: bool :param selected: True if the item should be selected, False if it should be deselected. :rtype: bool :return: True if an item with the given text is found, False if not """ try: item = self.findItems(text, QtCore.Qt.MatchExactly)[0] item.setSelected(selected) return True except IndexError: return False
[docs] def rowByText(self, text, match=QtCore.Qt.MatchExactly): """ Returns the row that a given text is in. Also accounts for hidden items. :type text: str :param text: The text to find :type match: Qt.MatchFlag :param match: Type of match to search for - default is exact :rtype: int or False :return: Row that holds matched text (can be 0), if text is not matched, returns False """ try: item = self.findItems(text, match)[0] return self.row(item) except IndexError: return False
[docs] def selectedText(self): """ Return the list of selected text values, including those of hidden items :rtype: list :return: List of strings, each member is the text of a selected item """ return [str(x.text()) for x in self.selectedItems()]
[docs]class SFilteredListWidget(SListWidget): """ An SListWidget with an additional line edit that can be used to filter the items within the ListWidget The SFilteredListWidget instance has a .mylayout property that all the widgets are placed into. This .mylayout should be added to another layout in order to place the widgets into the GUI - if the widget is not placed into the GUI directly via the layout= keyword. Items are set to hidden when they don't match the filter. Selected items that are set to hidden are unselected. Since items are hidden instead of deleted, currentRow() and rowByText() return the true index of the item, not the index among visible items. """
[docs] def __init__(self, label=None, filterlabel='Filter:', filter='within', filterside='bottom', case=False, **kwargs): """ See the SListWidget documentation for additional keyword parameters. :type label: str :param label: the text that will appear directly above the list widget :type filterlabel: str :param filterlabel: the label above the filter line edit. :type filter: str :param filter: One of 'within', 'start', 'end'. If within (default), the filter will find any item that has the filter text anywhere within its text. 'start' will only check if the item text starts with the filter ext, 'end' will only check if the item text ends with. :type filterside: str :param filterside: One of 'top' or 'bottom' (default) - the location of the filter edit relative to the list widget :type case: bool :param case: True if filter is case sensitive, False if not """ self.case = case layout = kwargs.pop('layout', None) super().__init__(**kwargs) # The mylayout layout is what should be placed into the parent GUI self.mylayout = SVBoxLayout() if label is not None: self.label = QtWidgets.QLabel(label) if filterside == 'bottom': if label is not None: self.mylayout.addWidget(self.label) self.mylayout.addWidget(self) self.edit = SLabeledEdit(filterlabel, side='top', layout=self.mylayout, stretch=False) if filterside != 'bottom': if label is not None: self.mylayout.addWidget(self.label) self.mylayout.addWidget(self) self.edit.textChanged[str].connect(self.filterList) if filter == 'start': self.filter_func = self.startFilter elif filter == 'end': self.filter_func = self.endFilter else: self.filter_func = self.withinFilter if layout is not None: layout.addLayout(self.mylayout)
[docs] def filterList(self, filtervalue): """ Filter the items in the list widget according to the contents of the LineEdit and our filter rules :type filtervalue: str :param filtervalue: The current value to filter the ListWidget by """ for item in self.allItems(): text = item.text() if self.case: visible = self.filter_func(text, filtervalue) else: visible = self.filter_func(text.lower(), filtervalue.lower()) item.setHidden(not visible) if not visible: item.setSelected(False)
[docs] def clear(self): """ Removes all items from the ListWidget """ self.edit.clear() super().clear()
[docs] def withinFilter(self, text, filtertext): """ Checks whether filtertext is contained within text - case insensitive :type text: str :param text: the text to check :type filtertext: str :param filtertext: will check for the existance of this text within text. :rtype: bool :return: True if filtertext is found within text, False if not """ return text.find(filtertext) != -1
[docs] def startFilter(self, text, filtertext): """ Checks whether text starts with filtertext :type text: str :param text: the text to check :type filtertext: str :param filtertext: will check if text starts with filtertext. :rtype: bool :return: True if text starts with filtertext, False if not """ return text.startswith(filtertext)
[docs] def endFilter(self, text, filtertext): """ Checks whether text ends with filtertext :type text: str :param text: the text to check :type filtertext: str :param filtertext: will check if text ends with filtertext. :rtype: bool :return: True if text ends with filtertext, False if not """ return text.endswith(filtertext)
[docs]class SFrame(QtWidgets.QFrame): """ A Frame that can be placed in a layout and is given a layout when created """
[docs] def __init__(self, parent=None, layout=None, layout_type=VERTICAL, indent=False): """ Create an SFrame instance. The frame has a mylayout property that is the internal layout. :type parent: QObject :param parent: the parent object of this SFrame :type layout_type: string :keyword layout_type: Module constants VERTICAL, HORIZONTAL, or GRID, for the type of internal layout :type layout: QBoxLayout :keyword layout: The layout to place this SFrame into :type indent: bool or int :keyword indent: False for a 0 left margin, True for a STANDARD_INDENT left margin, or an int for the value of the left margin - False by default. """ QtWidgets.QFrame.__init__(self, parent) if layout is not None: layout.addWidget(self) if layout_type == HORIZONTAL: self.mylayout = SHBoxLayout(self, indent=indent) elif layout_type == GRID: self.mylayout = SGridLayout(self, indent=indent) else: self.mylayout = SVBoxLayout(self, indent=indent)
[docs] def addWidget(self, widget): """ Add widget to the frame layout. :type widget: QWidget :param widget: Widget to place on the layout """ self.mylayout.addWidget(widget)
[docs] def addStretch(self): """ Add stretch to the frame layout. """ self.mylayout.addStretch()
[docs]class SHLine(QtWidgets.QFrame): """ A simple horizontal line """
[docs] def __init__(self, layout=None): """ Create a SHLine object :type layout: QLayout :param layout: If supplied, the line created will be added to this layout """ QtWidgets.QFrame.__init__(self) self.setFrameShape(self.HLine) self.setFrameShadow(self.Raised) if layout is not None: layout.addWidget(self)
[docs]class STwoButtonBox(QtWidgets.QWidget): """ A bottom button box that consists of a horizontal line over two buttons, an accept button on the left and a close button on the right. """
[docs] def __init__(self, layout=None, accept_text='Accept', accept_command=None, close_text='Close', close_command=None): """ Create a SHLine object :type layout: QLayout :param layout: If supplied, the line created will be added to this layout :type accept_text: str :param accept_text: The label on the accept button ("Accept" by default) :type accept_command: callable :param accept_command: The command the accept button should call. If this is not given or is None, the Accept button will not be created. :type close_text: str :param close_text: The label on the close button ("Close" by default) :type close_command: callable :param close_command: The command the close button should call. If this is not given or is None, the Close button will not be created. """ QtWidgets.QWidget.__init__(self) self.layout = SVBoxLayout() self.setLayout(self.layout) SHLine(layout=self.layout) self.dbb = QtWidgets.QDialogButtonBox() if accept_command: self.accept_button = self.dbb.addButton( accept_text, QtWidgets.QDialogButtonBox.AcceptRole) self.dbb.accepted.connect(accept_command) if close_command: self.close_button = self.dbb.addButton( close_text, QtWidgets.QDialogButtonBox.RejectRole) self.dbb.rejected.connect(close_command) self.layout.addWidget(self.dbb) if layout is not None: layout.addWidget(self)
[docs]class SEmittingScrollBar(QtWidgets.QScrollBar):
[docs] def showEvent(self, event): self.shown.emit(self.orientation())
[docs] def hideEvent(self, event): self.hidden.emit(self.orientation())
[docs]def draw_picture_into_rect(painter, pic, rect, max_scale=None, padding_factor=None): """ Draw a QPicture into a given rectangle. :type painter: QtGui.QPainter object :param painter: QPainter object that will do the drawing into the cell. :type pic: QPicture :param pic: the picture to be drawn into the rectangle :type rect: QRect :param rect: the rectangle that defines the drawing region :type max_scale: float or None :param max_scale: The maximum amount to scale the picture while attempting to make it fit into rect. This value is multipled by the scale factor required to fit a 150(wide) x 100 (high) picture to fit that picture into rect. The resulting product (max_scale * reference picture scale) is the true maximum scale factor allowed. :type padding_factor: float or None :padding_factor: If set, add padding on all 4 sides of QPicture, on the dimensions of the rectangle, to avoid it from "touching" the outer edges of the paint area. For 2D structure images, value of 0.04 is recommended. :return: Rectangle that the QPicture was actually painted into. It's a rectangle within `rect` - after removing padding on each side. :rtype: QRectF """ dest_x = rect.left() dest_y = rect.top() dest_w = rect.width() dest_h = rect.height() picrect = pic.boundingRect() pic_w = picrect.width() pic_h = picrect.height() if padding_factor is not None: padding = min(dest_w, dest_h) * padding_factor dest_x += padding dest_y += padding dest_w -= (2.0 * padding) dest_h -= (2.0 * padding) # Nothing to do if the picture is empty. Avoid ZeroDivisionError and # simply exit without drawing anything: if pic_w == 0 or pic_h == 0: return None # Scaling of specified picture to destination rect: # (using whichever ratio is smaller of width vs height) scale = min(dest_w / pic_w, dest_h / pic_h) # scale is small for large molecules and large for small molecules # (benzenes) # It's the ratio by which to UP scale the QPicture. if max_scale is not None and max_scale != 0: if max_scale <= 0: raise ValueError("max_scale must be more than zero") # Scaling of 150x100 pic to the dest rect: # (using whichever ratio is smaller of width vs height) ref_rect_w = 150 ref_rect_h = 100 ref_scale = min(dest_w / ref_rect_w, dest_h / ref_rect_h) # Reduce the scaling (do not shrink as much) if the molecule # is too small: if scale > ref_scale * max_scale: scale = ref_scale * max_scale xoffset = (dest_w - scale * pic_w) / 2.0 yoffset = (dest_h - scale * pic_h) / 2.0 x = dest_x + xoffset y = dest_y + yoffset painter.translate(x, y) if scale != 0: painter.scale(scale, scale) painter.setBrush(QtGui.QBrush(QtCore.Qt.black, QtCore.Qt.NoBrush)) painter.drawPicture(-picrect.left(), -picrect.top(), pic) if scale != 0: painter.scale(1.0 / scale, 1.0 / scale) painter.translate(-x, -y) return QtCore.QRectF(x, y, pic_w * scale, pic_h * scale)
[docs]class SToolButton(QtWidgets.QToolButton): """ Schrodinger-style QToolButton """
[docs] def __init__(self, *args, text="", layout=None, tip=None, command=None, **kwargs): """ Create an SToolButton instance :param str text: The text of the button :param `QtWidgets.QBoxLayout` layout: The layout for this button :param str tip: The tooltip for this button :param callable command: The command to connect the clicked signal to """ QtWidgets.QToolButton.__init__(self, *args, **kwargs) self.setContentsMargins(0, 0, 0, 0) self.setAutoRaise(True) self.setText(text) if layout is not None: layout.addWidget(self) if tip: self.setToolTip(tip) if command: self.clicked.connect(command)
[docs]class EditButton(SToolButton): """Edit button with custom Schrodinger icon."""
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) edit_icon = ':/schrodinger/ui/qt/icons_dir/edit_button.png' edit_h_icon = ':/schrodinger/ui/qt/icons_dir/edit_button_select.png' EDIT_BUTTON_STYLESHEET = f""" QToolButton {{ width: 16px; height: 16px; border: none; padding: 0px 0px 0px 0px; image: url({edit_icon}); }} QToolButton:hover {{ image: url({edit_h_icon}); }}""" self.setStyleSheet(EDIT_BUTTON_STYLESHEET)
[docs]class HelpButton(SToolButton): HELP_BUTTON_HEIGHT = 22
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setIcon(QtGui.QIcon(":/images/toolbuttons/help.png")) if sys.platform == "darwin": self.setStyleSheet("QToolButton{border:0px;}") self.setShortcut('F1') height = self.HELP_BUTTON_HEIGHT self.setFixedHeight(height) self.setFixedWidth(height) self.setIconSize(QtCore.QSize(height, height))
[docs]class InfoButton(SToolButton): """ Info button, with custom Schrodinger icon. Typically the only purpose of such icon is to display tooltip text when clicked or hovered. """
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) info_icon = ':/schrodinger/ui/qt/icons_dir/info.png' info_h_icon = ':/schrodinger/ui/qt/icons_dir/info_h.png' INFO_BUTTON_STYLESHEET = f""" QToolButton {{ border: none; padding: 0px 0px 0px 0px; image: url({info_icon}); }} QToolButton:hover {{ image: url({info_h_icon}); }}""" self.setStyleSheet(INFO_BUTTON_STYLESHEET) self.clicked.connect(self._showTooltip)
def _showTooltip(self): """ Show the tooltip right away (without waiting for mouse to stay on top of the widget for couple seconds first). """ tip_text = self.toolTip() if tip_text: QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), tip_text, self)
[docs]class ResetButton(QtWidgets.QToolButton): """ Reset button, with custom Schrodinger icon. """
[docs] def __init__(self, *args, layout=None, tip="Reset All", command=None, **kwargs): """ :type layout: QLayout :param layout: the layout that the widget created will be added to :param tip: the tooltip :type tip: str :type command: callable :param command: Slot for the button click signal. """ super().__init__(*args, **kwargs) RESET_BUTTON_STYLESHEET = """ %(QTOOLBUTTON_SIZE)s QToolButton { image: url("%(UI_QT_DIR)s/reset.png"); } QToolButton:hover, QToolButton:pressed { image: url("%(UI_QT_DIR)s/reset-h.png"); } QToolButton:disabled { image: url("%(UI_QT_DIR)s/reset-d.png"); } """ % { 'QTOOLBUTTON_SIZE': QTOOLBUTTON_SIZE, 'UI_QT_DIR': UI_QT_DIR } self.setStyleSheet(RESET_BUTTON_STYLESHEET) self.setToolTip(tip) if layout is not None: layout.addWidget(self) if command: self.clicked.connect(command)
[docs]class SaveButton(QtWidgets.QToolButton): """ Apply button with custom Schrodinger icon. """
[docs] def __init__(self, *args, layout=None, tip=None, command=None, **kwargs): """ :type layout: QLayout :param layout: If supplied, the STableWidget created will be added to the layout. :param tip: the tooltip :type tip: str :type command: callable :param command: Slot for the button click signal. """ super().__init__(*args, **kwargs) RESET_BUTTON_STYLESHEET = """ %(QTOOLBUTTON_SIZE)s QToolButton { image: url("%(UI_QT_DIR)s/icons_dir/save_file.png"); } QToolButton:disabled { image: url("%(UI_QT_DIR)s/icons_dir/save_file-d.png"); } """ % { 'QTOOLBUTTON_SIZE': QTOOLBUTTON_SIZE, 'UI_QT_DIR': UI_QT_DIR } self.setStyleSheet(RESET_BUTTON_STYLESHEET) if tip: self.setToolTip(tip) if command: self.clicked.connect(command) if layout is not None: layout.addWidget(self)
[docs]class TrashButton(QtWidgets.QToolButton): """ Trash button with custom Schrodinger icon. """
[docs] def __init__(self, *args, layout=None, tip=None, command=None, **kwargs): """ :type layout: QLayout :param layout: 'the layout that the widget created will be added to' :param tip: the tooltip :type tip: str :type command: callable :param command: Slot for the button click signal. """ super().__init__(*args, **kwargs) RESET_BUTTON_STYLESHEET = """ %(QTOOLBUTTON_SIZE)s QToolButton { image: url("%(UI_QT_DIR)s/icons_dir/trashcan.png"); } QToolButton:hover, QToolButton:pressed { image: url("%(UI_QT_DIR)s/icons_dir/trashcan-hover.png"); } QToolButton:disabled { image: url("%(UI_QT_DIR)s/icons_dir/trashcan-disabled.png"); } """ % { 'QTOOLBUTTON_SIZE': QTOOLBUTTON_SIZE, 'UI_QT_DIR': UI_QT_DIR } self.setStyleSheet(RESET_BUTTON_STYLESHEET) if tip: self.setToolTip(tip) if command: self.clicked.connect(command) if layout is not None: layout.addWidget(self)
[docs]class SDialog(QtWidgets.QDialog): """ A Base Dialog class for easier Dialog creation """
[docs] def __init__(self, master, user_accept_function=None, standard_buttons=None, nonstandard_buttons=None, help_topic="", title=None): """ Creat an SDialog object :type master: QWidget :param master: The parent of this dialog :type user_accept_function: callable :param user_accept_function: A function to call from a custom accept method implemented in a subclass :type standard_buttons: list :param standard_buttons: List of standard buttons to add - each item must be a QDialogButtonBox.StandardButton constant (i.e. QDialogButtonBox.Ok). The default if no standard buttons or non-standard buttons are provided is Ok and Cancel. :type nonstandard_buttons: list of (QPushButton, QDialogButtonBox.ButtonRole) tuples :param nonstandard_buttons: Each item of the list is a tuple containing an existing button and a ButtonRole such as AcceptRole. The default if no standard buttons or non-standard buttons are provided is Ok and Cancel buttons. :type help_topic: str :param help_topic: The help topic for this panel. If the help topic is provided a help button will automatically be added to the panel. :type title: str :param title: The dialog window title """ self.help_topic = help_topic self.master = master self.user_accept_function = user_accept_function QtWidgets.QDialog.__init__(self, master) if title: self.setWindowTitle(title) self.mylayout = SVBoxLayout(self) self.mylayout.setContentsMargins(6, 6, 6, 6) self.layOut() # Create the requested buttons self.dbb = QtWidgets.QDialogButtonBox() if not standard_buttons and not nonstandard_buttons: standard_buttons = [self.dbb.Ok, self.dbb.Cancel] elif not standard_buttons: standard_buttons = [] if self.help_topic and self.dbb.Help not in standard_buttons: standard_buttons.append(self.dbb.Help) if not nonstandard_buttons: nonstandard_buttons = [] for sbutton in standard_buttons: self.dbb.addButton(sbutton) for nsbutton, role in nonstandard_buttons: self.dbb.addButton(nsbutton, role) # Hook up common standard buttons # Note - self.accept and self.reject are standard Dialog methods and do # not need to be reimplemented if nothing custom is done self.dbb.accepted.connect(self.accept) self.dbb.rejected.connect(self.reject) self.dbb.helpRequested.connect(self.giveHelp) reset_button = self.dbb.button(self.dbb.Reset) if reset_button: reset_button.clicked.connect(self.reset) self.mylayout.addWidget(self.dbb) # see MATSCI-5358 - where it was found in the 18-2 release # that dialogs on Windows by default come with a question mark # help button on the title bar next to the close button, the # following line of code prevents this self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
[docs] def layOut(self): """ Lay out the custom widgets in this dialog. """ layout = self.mylayout raise NotImplementedError('The layOut method must be implemented to ' 'add widgets to dialog.mylayout')
[docs] def giveHelp(self): """ Display the help topic for this dialog """ if self.help_topic: status = pyhelp.mmpyhelp_show_help_topic(self.help_topic) if status != pyhelp.MMHELP_OK: self.warning('The help topic for this panel could not be ' 'found.') else: self.warning('Help was requested but no help topic or no help ' 'product was defined for this panel.')
[docs] def warning(self, msg): """ Display a warning dialog with a message :type msg: str :param msg: The message to display in the warning dialog """ from schrodinger.ui.qt import utils as qtutils with qtutils.remove_wait_cursor: QtWidgets.QMessageBox.warning(self, 'Warning', msg)
[docs] def info(self, msg): """ Display an information dialog with a message :type msg: str :param msg: The message to display in the information dialog """ from schrodinger.ui.qt import utils as qtutils with qtutils.remove_wait_cursor: QtWidgets.QMessageBox.information(self, 'Information', msg)
[docs] def error(self, msg): """ Display an error dialog with a message :type msg: str :param msg: The message to display in the error dialog """ from schrodinger.ui.qt import utils as qtutils with qtutils.remove_wait_cursor: QtWidgets.QMessageBox.critical(self, 'Error', msg)
[docs] def reset(self): """ Reset the panel when the Reset button is pressed. Must be re-implemented in a subclass """ raise NotImplementedError('The reset method must be implemented')
[docs] def question(self, msg, button1='OK', button2='Cancel'): """ Display a question dialog with a message Returns True if first button (default OK) is pressed, False otherwise. :param str msg: The message to display in the question dialog :param str button1: The text to display on first button :param str button2: The text to display on second button """ from schrodinger.ui.qt import messagebox from schrodinger.ui.qt import utils as qtutils with qtutils.remove_wait_cursor: return messagebox.show_question(parent=self, text=msg, yes_text=button1, no_text=button2, add_cancel_btn=False)
[docs]class SMARTSEdit(SLabeledEdit): """ A line edit for entering SMARTS strings with an icon that indicates the SMARTS' validity """ smarts_data_changed = QtCore.pyqtSignal() invalid_smarts_changed = QtCore.pyqtSignal()
[docs] def __init__(self, master, tag=None, empty_ok=True, label='SMARTS:', indicator=True, indicator_state=INDICATOR_VALID, indicator_tips=True, canvas_api=True, rdkit_api=False, **kwargs): """ Create a SMARTSEntry object :type master: QWidget :param master: Something with a warning method for displaying warning dialogs :type tag: str :param tag: A string that defines the edit for use in warning messages. ex. "No valid SMARTS pattern for 'tag'". If not supplied, a generic message will be used. :type empty_ok: bool :param empty_ok: Whether an empty edit is considered VALID or INTERMEDIATE :type label: str :param label: The label preceeding the QLineEdit :type indicator: bool :param indicator: True if a valid/invalid indicator should be appended :type indicator_state: bool :param indicator_state: The default state of the indicator (INDICATOR_VALID, INDICATOR_INVALID or INDICATOR_INTERMEDIATE) :type indicator_tips: dict :param indicator_tips: The tooltips for the different states of the indicator - see the SValidIndicator class for more information. If not provided, they can be given directly in the setIndicator call. If not given in either place, the indicator will have default tooltips. Pass in None to have no tooltips at all. :type canvas_api: bool :param canvas_api: toggle whether to verify with analyze.validate_smarts or analyze.validate_smarts_canvas (defaults to validate_smarts_canvas) :type rdkit_api: bool :param rdkit_api: Whether to verify with adapter.validate_smarts. It cannot be used with canvas_api. See the parent `SLabeledEdit` class for additional keyword arguments """ if indicator_tips is True: indicator_tips = { INDICATOR_VALID: 'SMARTS pattern is valid', INDICATOR_INVALID: 'SMARTS pattern is not valid', INDICATOR_INTERMEDIATE: 'Enter a valid SMARTS pattern' } self.empty_ok = empty_ok dator = SAsciiOnlyValidator() self.canvas_api = canvas_api self.rdkit_api = rdkit_api if self.canvas_api and self.rdkit_api: raise AttributeError( 'Cannot use canvas and rdkit api at the same time.') SLabeledEdit.__init__(self, label, indicator=indicator, indicator_state=indicator_state, indicator_tips=indicator_tips, validator=dator, **kwargs) self.textChanged.connect(self.startedEditingSMARTS) self.editingFinished.connect(self.doneEditingSMARTS) self.last_text = None self.master = master self.tag = tag self.findSMARTSError()
[docs] def setEmptyOK(self, is_ok): """ Set whether an empty edit is considered VALID or INTERMEDIATE :type is_ok: bool :param is_ok: Whether an empty edit is VALID """ self.empty_ok = is_ok
[docs] def isInvalid(self): """ Is the line edit currently colored as invalid? :rtype: bool :return: Whether the line edit has the invalid color """ return self.indicator.getState() == INDICATOR_INVALID
[docs] def isValid(self): """ Is the line edit currently colored as valid? :rtype: bool :return: Whether the line edit has the valid color """ return self.indicator.getState() == INDICATOR_VALID
[docs] def startedEditingSMARTS(self): """ React to the SMARTS pattern changing but not yet finished """ self.setIndicator(INDICATOR_INTERMEDIATE)
[docs] def doneEditingSMARTS(self, force=False): """ The SMARTS pattern is complete, so validate it if necessary :type force: bool :param force: Force the SMARTS pattern to be validated even if the existing state is invalid. """ # If we are already invalid, the text has already been found # to be invalid, so don't go any further. This avoids loops where we # show a dialog, which causes the edit to lose focus, which triggers # this method, which shows a dialog, etc. It also avoids constantly # yelling at the user if they don't change the SMARTS pattern # immediately. Both of the problems can occur if the user hits "return" # while in the edit, which initially leaves the focus in the edit but # still triggers this signal. self.last_text = self.text() if not self.isInvalid() or force: if not self.findSMARTSError(): self.smarts_data_changed.emit() else: self.invalid_smarts_changed.emit()
[docs] def getSMARTSPatterns(self, raise_if_empty=False): """ Get a list of the current SMARTS patterns specified by the user :type raise_if_empty: bool :param raise_if_empty: Whether to raise an error if the checkbox is checked but the line edit is empty :rtype: list :return: An empty list if the checkbox is not checked, or the contents of the edit converted via whitespace to a list of strings if the checkbox is checked. :raise ValueError: if raise_if_empty is True, the checkbox is checked, and the edit is empty """ patterns = self.text().split() if not patterns and raise_if_empty: msg = 'No SMARTS pattern has been defined' if self.tag: msg = msg + 'for ' + self.tag raise ValueError(msg) return patterns
[docs] def validateSMARTS(self, pattern): """ Validate smarts pattern :param pattern: The smarts pattern to validate :type pattern: str :rtype: (bool, str) :return: The bool indicates whether the SMARTS pattern is valid, the string gives the error message if bool is False Note: the return value of this function always evaluates to True since it is a non-empty tuple. """ if self.canvas_api: return analyze.validate_smarts_canvas(pattern) elif self.rdkit_api: try: adapter.validate_smarts(pattern) return True, "" except ValueError as err: return False, str(err) return analyze.validate_smarts(pattern)
[docs] def findSMARTSError(self, verbose=True): """ Find any error in the SMARTS patterns :type verbose: bool :param verbose: Whether to post a warning dialog with the error found :rtype: str :return: An empty string if no error is found, or an error message describing the error that was found """ patterns = self.getSMARTSPatterns() for pattern in patterns: validation = self.validateSMARTS(pattern) if not validation[0]: # Replace the '^' position marker in the message, # because the non-fixed point font of message boxes # messes up its position and "^" isn't critical. verror = validation[1].replace("^", "") msg = ('%s is not a valid SMARTS pattern, error was:\n%s' % (pattern, verror)) self.setIndicator(INDICATOR_INVALID) if verbose: self.master.warning(msg) return msg if not patterns and not self.empty_ok: self.setIndicator(INDICATOR_INTERMEDIATE) else: self.setIndicator(INDICATOR_VALID) return ""
[docs] def setText(self, text): """ Overrides the parent method to also evaluate the new SMARTS string """ SLabeledEdit.setText(self, text) self.doneEditingSMARTS(force=True)
[docs] def clear(self): """ Overrides the parent method to also evaluate the new SMARTS string """ SLabeledEdit.clear(self) self.doneEditingSMARTS(force=True)
[docs]class ColorWidget(SFrame): """ A QtWidgets.QFrame to manage a color widget. """ color_changed = QtCore.pyqtSignal(tuple)
[docs] def __init__(self, parent_layout, text, default_rgb_color=DEFAULT_COLOR, check_box_state=None, pixmap_size=PIXMAP_SIZE, stretch=True, use_maestro_colors=False, command=None): """ Create an instance. :type parent_layout: QtWidgets.QVBoxLayout :param parent_layout: the layout this widget belongs in :type text: str :param text: the text for the widget :type default_rgb_color: tuple :param default_rgb_color: a triple of integers in [0, 255] that give RGB colors :type check_box_state: None or bool :param check_box_state: None indicates that the widget should not be behind a check box, the bool indicates that (1) the widget should be behind a check box and (2) that by default should be checked (True) or unchecked (False) :type pixmap_size: int :param pixmap_size: the size of the pixmap used for the coloring button :type stretch: bool :param stretch: whether to add a stretch space after the color button :type use_maestro_colors: bool :param use_maestro_colors: whether to map selected rgb colors to nearby rgb colors that are also supported by color name, for example "spring green", in Maestro (see names in mmshare/mmlibs/mmcolor/colors.res) :type command: callable :param command: Slot for the color_changed signal, which is emitted when the button color changes. The command is not called when the button is created. command is called with a triple of integers in [0, 255] that give the new RGB colors. """ SFrame.__init__(self, layout=parent_layout, layout_type=HORIZONTAL) self.text = text self.default_rgb_color = default_rgb_color self.use_maestro_colors = use_maestro_colors if self.use_maestro_colors: self.default_rgb_color = \ self.mapToMaestroColor(self.default_rgb_color) self.rgb_color = self.default_rgb_color self.check_box_state = check_box_state self.pixmap_size = pixmap_size if self.check_box_state is None: self.label = SLabel(self.text, layout=self.mylayout) self.check_box = None else: self.check_box = SCheckBox(self.text, layout=self.mylayout, checked=self.check_box_state, command=self.enableButtonViaCheckBox) self.button = QtWidgets.QToolButton() self.mylayout.addWidget(self.button) self.button.clicked.connect(self.openColorSelector) self.reset() if stretch: self.mylayout.addStretch() if command: self.color_changed.connect(command)
[docs] def mapToMaestroColor(self, rgb_color): """ Map the given rgb color to a nearby rgb color that is also supported by color name, for example "spring green", in Maestro. :type rgb_color: tuple :param rgb_color: a triple of integers in [0, 255] that give RGB colors :type: tuple :return: a triple of integers in [0, 255] that give RGB colors for the color that is supported by name in Maestro """ index = mm.mmcolor_vector_to_index(rgb_color) name = mm.mmcolor_index_to_name(index) acolor = color.Color(name) return acolor.rgb
[docs] def setButtonColor(self, rgb_color): """ Set the button color. :type rgb_color: tuple :param rgb_color: a triple of integers in [0, 255] that give RGB colors """ if self.use_maestro_colors: rgb_color = self.mapToMaestroColor(rgb_color) color = QtGui.QColor(*rgb_color) pixmap = QtGui.QPixmap(self.pixmap_size, self.pixmap_size) pixmap.fill(color) icon = QtGui.QIcon(pixmap) self.button.setIcon(icon) self.button.setIconSize(QtCore.QSize(self.pixmap_size, self.pixmap_size)) self.rgb_color = rgb_color self.color_changed.emit(self.rgb_color)
[docs] def enableButtonViaCheckBox(self): """ Enable/disable the color button via the checkbox state. """ if self.check_box is not None: state = self.check_box.isChecked() self.button.setEnabled(state)
[docs] def openColorSelector(self): """ Open the color selector dialog. """ color = QtGui.QColor(*self.rgb_color) color = QtWidgets.QColorDialog.getColor(color) if color.isValid(): rgb_color = color.getRgb()[:3] self.setButtonColor(rgb_color)
[docs] def reset(self): """ Reset it. """ self.setButtonColor(self.default_rgb_color) if self.check_box_state is not None: self.check_box.reset() self.enableButtonViaCheckBox()
[docs]class STableView(QtWidgets.QTableView): """ Just like a QTableView except: 1) LineEditDelegate is the default delegate. It draws "pencil" icons in cells that are editable. 2) Cells whose delegates have MOUSE_CLICK_STARTS_EDIT set will enter the edit mode as soon as the user clicks (press & release) in the cell. 3) Cells whose delegates have MOUSE_PRESS_STARTS_EDIT set will enter the edit mode as soon as the user presses the mouse button. """
[docs] def __init__(self, *args, **kwargs): """ Create a STableView instance. """ super().__init__(*args, **kwargs) # Import is here to prevent circular imports in the future (previously # delegates.py imported swidgets.py module) from schrodinger.ui.qt.delegates import LineEditDelegate self._default_delegate = LineEditDelegate(self) self.setItemDelegate(self._default_delegate) self.pressed.connect(self._cellPressed) self.clicked.connect(self._cellClicked)
def _cellPressed(self, index): """ Called when the user presses the mouse in a cell. If the delegate has MOUSE_PRESS_STARTS_EDIT attribute set, then the cell will enter the edit mode. """ delegate = self.itemDelegate(index) if getattr(delegate, 'MOUSE_PRESS_STARTS_EDIT', False): # If the delegate has a MOUSE_PRESS_STARTS_EDIT variable set: self.edit(index) def _cellClicked(self, index): """ Called when the user presses the mouse in a cell. If the delegate has MOUSE_CLICK_STARTS_EDIT attribute set, then the cell will enter the edit mode. """ delegate = self.itemDelegate(index) if getattr(delegate, 'MOUSE_CLICK_STARTS_EDIT', False): # If the delegate has a MOUSE_CLICK_STARTS_EDIT variable set: self.edit(index)
[docs]class STableWidgetItem(QtWidgets.QTableWidgetItem): """ Table widget item with proper sorting of numbers """ SELECT_ITEM = QtCore.Qt.ItemIsSelectable ENABLE_ITEM = QtCore.Qt.ItemIsEnabled EDITABLE_ITEM = QtCore.Qt.ItemIsEditable CHECKABLE_ITEM = QtCore.Qt.ItemIsUserCheckable # Vertical alignment TOP = QtCore.Qt.AlignTop VCENTER = QtCore.Qt.AlignVCenter BOTTOM = QtCore.Qt.AlignBaseline VERTICAL_ALIGNS = {TOP, VCENTER, BOTTOM} # Horizontal alignment LEFT = QtCore.Qt.AlignLeft HCENTER = QtCore.Qt.AlignHCenter RIGHT = QtCore.Qt.AlignRight JUSTIFY = QtCore.Qt.AlignJustify HORIZONTAL_ALIGNS = {LEFT, HCENTER, RIGHT, JUSTIFY}
[docs] def __init__(self, editable=True, selectable=True, enabled=True, user_checkable=True, text='', vertical_align=VCENTER, horizontal_align=HCENTER): """ QTableWidgetItem item which holds the value (numerical if appropriate) of text hence sorting the items properly if they are numbers. Also can easily set the item's state and text allignment. :type editable: bool :param editable: If true then item will be editable, else not. :type selectable: bool :param selectable: If true then item will be selectable, else not. :type enabled: bool :param enabled: If true then item will be enabled, else not. :type user_checkable: bool :param user_checkable: If true then item will be checkable to user, else not. :type text: str :param text: The text for the table item :type vertical_align: str :param vertical_align: Define vertical align style to top, center, or bottom. Bottom uses AlignBaseline instead of AlignBottom as AlignBottom causes text to disappear. :type horizontal_align: str :param horizontal_align: Define horizontal align style to left, center, right, or justify. :raises: AssertionError if value of horizontal_align is not left, center, right, or justify. :raises: AssertionError if value of vertical_align is not top, center, or bottom. """ assert vertical_align in self.VERTICAL_ALIGNS assert horizontal_align in self.HORIZONTAL_ALIGNS super().__init__() if text: self.setText(text) # Set state of item widget self.flag_items = { self.SELECT_ITEM: selectable, self.EDITABLE_ITEM: editable, self.CHECKABLE_ITEM: user_checkable, self.ENABLE_ITEM: enabled, } # Set state of the item, add flag using 'OR' operatore and remove using # 'AND' operator for custom_flag, state in self.flag_items.items(): if state: self.setFlags(self.flags() | custom_flag) else: self.setFlags(self.flags() ^ custom_flag) # Set text alignment self.setTextAlignment(horizontal_align | vertical_align)
def __lt__(self, other): """ Compare value attribute rather than text it will check data value role """ if isinstance(other, STableWidgetItem): # Make sorting case insensitive myval = self.getValue() try: myval = myval.lower() except AttributeError: pass otherval = other.getValue() try: otherval = otherval.lower() except AttributeError: pass try: return myval < otherval except TypeError: # Treat `None` as less than everything if myval is None or otherval is None: return myval is None # If types are incompatible, do a string comparison else: return str(myval) < str(otherval) return super().__lt__(other)
[docs] def getValue(self): """ Get the value of the current text if it is numerical text. :rtype: float or text :return: float if text is numerical text, else will return text itself. """ text = self.text() try: value = float(text) except ValueError: value = text return value
[docs] def copy(self): """ Return copy of current item with same flags and text as the current one. :rtype: STableWidgetItem (or current class) :return: copy of current item """ item = self.__class__( selectable=self.flag_items[self.SELECT_ITEM], editable=self.flag_items[self.EDITABLE_ITEM], user_checkable=self.flag_items[self.CHECKABLE_ITEM], enabled=self.flag_items[self.ENABLE_ITEM]) item.setTextAlignment(self.textAlignment()) item.setText(self.text()) return item
[docs]class STableWidget(QtWidgets.QTableWidget): """ A better table widget """ SORT_ASC = 'ascending'
[docs] def __init__(self, *arg, columns=None, column_names=None, table_data=None, row_data_names=None, sort_column=None, sort_style=SORT_ASC, select_row=False, resize_header=False, layout=None, row_data_col=None, **kwargs): """ Create a STableWidget instance. :type table_data: numpy.ndarray or list :param table_data: 2-dimension numpy array containing data for the table or list of list where inner lists is list of values for each row. :type columns: dict :param columns: Ordered dictionary with key as column names and value as the STableWidgetItem. This item will be used a base item for all the items for the column. None can be used for the value for columns that contain only widgets. :type column_names: list :param column_names: list of column names and base item for each column will be set as STableWidgetItem. If both columns and column_name is provided then column_names are ignored. :type row_data_names: list :param row_data_names: list containing names of each row data. row_data is data stored in under user role in table item widget in the row_data_col column. :type sort_column: str or int or None :param sort_column: name or index of column to sort by. If none then sort is not enabled. :type sort_style: str :param sort_style: select sorting type between ascending or descending :type select_row: bool :param select_row: enable selection of rows rather than individual items :type resize_header: bool or str :param resize_header: If True, resize last column header to fill the available space. The size cannot be changed by the user or programmatically. If a string, must be the name of the column that will stretch to fill all available space. :type layout: QLayout :param layout: If supplied, the STableWidget created will be added to the layout. :type row_data_col: str :param row_data_col: The header name for the column that will store any row data """ if columns is None and column_names is None: raise TypeError('Missing column input. Either provide columns or ' ' column_names') super().__init__(*arg, **kwargs) # Set columns and base items. if columns is not None: self.columns = columns else: self.columns = {} for column_name in column_names: self.columns[column_name] = STableWidgetItem() self.setColumnCount(len(self.columns)) self.setHorizontalHeaderLabels(list(self.columns.keys())) # Add sorting conditions super().setSortingEnabled(False) self.setSortingEnabled(not sort_column is None, sort_column, sort_style) # Determine the column that will hold data for the row if row_data_col: if row_data_col not in self.columns: raise RuntimeError(f'Data column name {row_data_col} is not a ' 'column header') self.row_data_col = row_data_col else: # No column was set by the user, pick a default one if self.columns: self.row_data_col = list(self.columns.keys())[0] else: if row_data_names: raise RuntimeError('Must supply column names if row data ' 'is to be used') self.row_data_col = None # Get the names for the row data self.row_data_names = OrderedDict() if row_data_names is not None: for role_num, data_name in enumerate(row_data_names, 1): self.row_data_names[data_name] = QtCore.Qt.UserRole + role_num # Add data to table if given if table_data is not None: self.addRows(table_data) # Add table to layout if given if layout is not None: layout.addWidget(self) # Enable row selection in table if select_row: self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) # Enable table to cover all available space header = self.horizontalHeader() if resize_header is True: header.setStretchLastSection(True) header.setSectionResizeMode(header.Stretch) elif resize_header: # resize_header is a (str) column name that should stretch if resize_header not in self.columns: raise RuntimeError(f'{resize_header} is not a valid column ' 'name to be resized') for index, colname in enumerate(self.columns.keys()): if colname == resize_header: mode = header.ResizeMode.Stretch else: mode = header.ResizeMode.Fixed header.setSectionResizeMode(index, mode)
def __getitem__(self, column_name): """ Return all the values in the column name or row data. :type column_name: str :param column_name: name of the column or row data name :rtype: list :return: list of text in the given column or row data """ row_data = False try: col_num = list(self.columns.keys()).index(column_name) except ValueError: if column_name in self.row_data_names.keys(): row_data = True else: raise KeyError(column_name) data = [] num_rows = self.rowCount() for row_num in range(num_rows): if row_data: # Get value for hidden column from the attribute of 1st column # cell rdcol = self.getColumnIndex(self.row_data_col) item = self.item(row_num, rdcol) if item: current_data = item.data(self.row_data_names[column_name]) else: current_data = None else: current_data = self.cellWidget(row_num, col_num) if not current_data: item = self.item(row_num, col_num) if item: current_data = item.getValue() else: current_data = None data.append(current_data) return data
[docs] def cellWidget(self, *args): """ Get the widget for the cell Overwrites parent method to account for CenteringWidgets that hold the widget of interest as a child widget See parent method for additional details """ widget = super().cellWidget(*args) try: # By default this class adds CenteringWidgets to cells that hold # a reference to the actual widget return widget.actual_widget except AttributeError: return widget
[docs] def setColumnCount(self, columns): """ Sets the number of columns in the table to columns. If this is less than columnCount(), the data in the unwanted columns is discarded. If it is more than columnCount() then default base item is created for the column :type columns: int :param columns: Number of columns for the table """ super().setColumnCount(columns) # Add to self.column and self.column_baseitems for column_index in range(len(self.columns), self.columnCount()): self.columns[str(column_index + 1)] = STableWidgetItem() # Remove from self.column and self.column_baseitems if necessary for column in list(self.columns.keys())[self.columnCount():]: self.columns.pop(column)
[docs] def setHorizontalHeaderLabels(self, columns): """ Sets the horizontal header labels using labels. :type columns: list :param columns: list of column labels for the table. Only the name will be changed for the column and the base item will remain the same. """ super().setHorizontalHeaderLabels(columns) # Update self.columns with new column names but keep the base items self.columns = OrderedDict(zip(columns, self.columns.values()))
[docs] def setSortingEnabled(self, enable, sorting_column=0, sort_style=SORT_ASC): """ Update parent class to enable sorting by set column :type enable: bool :param enable: If enable is true it enables sorting for the table and immediately triggers a call to sortByColumn() with the set sorting column and style :type sort_column: str or int or None :param sort_column: name or index of column to sort by. If none then sort is not enabled. :type sort_type: str :param sort_type: select sorting type between ascending or descending """ super().setSortingEnabled(enable) if enable: if isinstance(sorting_column, int): self.sort_column = sorting_column else: self.sort_column = list( self.columns.keys()).index(sorting_column) else: self.sort_column = None if sort_style is self.SORT_ASC: self.sort_style = QtCore.Qt.AscendingOrder else: self.sort_style = QtCore.Qt.DescendingOrder if enable: self.sortRows()
[docs] def getRows(self, selected_only=False): """ A generator for all table rows. :type selected_only: bool :param selected_only: If true it will only return selected rows :rtype: list :return: Iterates over each row to return list of QTableWidgetItem """ if selected_only: row_nums = self.getSelectedRowIndexes() else: row_nums = range(self.rowCount()) for row_num in row_nums: items = [] for col_num in range(self.columnCount()): item = self.item(row_num, col_num) if item is None: item = self.cellWidget(row_num, col_num) items.append(item) yield items
[docs] def getSelectedRowIndexes(self): """ Get row indexes numbers of selected rows. :rtype: list :return: list containing indexes of selected rows """ return [row.row() for row in self.selectionModel().selectedRows()]
[docs] def addRow(self, data, return_sorted=True, editable=True): """ Add a row to the table. :type data: list or dict :param data: list data values for the column ordered as columns are. followed by row data values in order of as set in row_data_names. If a dict is supplied, keys are column names or row data names and values are the values for that column/row data. Any keys missing from the dict will cause nothing to be added to that column. Widgets can be added to any column simply by supplying a widget as the list item/dict value for that column. :type return_sorted: bool :param return_sorted: return sorted table :type editable: bool :param editable: If True, item added in the table is editable """ total_cols = self.columnCount() + len(self.row_data_names.keys()) # Add empty row if data is empty if not data: data = [''] * total_cols # Check if data dimension is correct islist = not isinstance(data, dict) # Handle list/tuple input if islist: if len(data) != total_cols: raise IndexError('Number of elements in row are not equal to ' 'the number of columns and row_data.') all_headers = list(self.columns) + list(self.row_data_names) data = {x: y for x, y in zip(all_headers, data)} # Disable sorting while adding rows if enabled enable_sorting = False if self.isSortingEnabled(): super().setSortingEnabled(False) enable_sorting = True # Add the empty row row_index = self.rowCount() self.insertRow(row_index) # Add column data row_data_item = None for col_index, (header, base_item) in enumerate(self.columns.items()): value = data.get(header) if value is None: continue if isinstance(value, QtWidgets.QWidget): # Note - this function places a widget *and* an item in the cell center_widget_in_table_cell(self, row_index, col_index, value) else: item = base_item.copy() item.setText(str(value)) if not editable: item.setFlags(item.flags() ^ QtCore.Qt.ItemIsEditable) self.setItem(row_index, col_index, item) if header == self.row_data_col: row_data_item = self.item(row_index, col_index) # Add row data if self.row_data_names: if row_data_item is None: raise RuntimeError( f'Must supply a value for {self.row_data_col} if row ' 'data is supplied') for header, role in self.row_data_names.items(): value = data.get(header) if value is None: continue row_data_item.setData(role, value) # Sort the table and enable sorting after adding data if enable_sorting: super().setSortingEnabled(True) # Keep Sorting enabled but do not sort data. if not return_sorted: return self.sortRows()
[docs] def getColumnIndex(self, column_name): """ Get index of the given column name. Row data columns are not indexable. :type column_name: str :param column_name: Name of the column of get index of :rtype: int :return: index of the given column """ return list(self.columns.keys()).index(column_name)
[docs] def sortRows(self): """ Sort the rows by given sortable column """ if self.sort_column is not None: self.sortByColumn(self.sort_column, self.sort_style)
[docs] def addRows(self, table_data): """ Add a rows to the table :type table_data: numpy.ndarray or list :param table_data: 2-dimension numpy array containing data for the table or list of list where inner lists is list of values for each row. """ for row_data in table_data: self.addRow(row_data, return_sorted=False) if self.isSortingEnabled(): self.sortRows()
[docs] def deleteAllRows(self): """ Delete all rows in the table. """ self.setRowCount(0)
[docs] def deleteSelectedRows(self): """ Delete the selected rows from the table. """ selected_rows = self.getSelectedRowIndexes() for row in reversed(list(set(selected_rows))): self.removeRow(row)
[docs] def reset(self): """ Reset the table by deleting all the rows. """ self.deleteAllRows()
[docs] def selectOnlyRows(self, rows): """ Select only specified rows :type rows: list :param rows: list of row numbers """ model = self.model() selection_model = self.selectionModel() selection = QtCore.QItemSelection() rows = set(rows).intersection(set(range(self.rowCount()))) for row in rows: bottom_right = top_left = model.index(row, 0, QtCore.QModelIndex()) selection.select(top_left, bottom_right) selection_model.select( selection, QtCore.QItemSelectionModel.ClearAndSelect | QtCore.QItemSelectionModel.Rows)
[docs]class ProjectTableButton(QtWidgets.QToolButton): """ A button with a project table logo. When clicked, it will open the project table panel. """
[docs] def __init__(self, parent=None): super(ProjectTableButton, self).__init__(parent) self._icon = QtGui.QIcon(":images/toolbuttons/projecttable.png") self.setIcon(self._icon) self.setStyleSheet("QToolButton { border: none; padding: 0px; }") self.setToolTip("Open the Project Table") self.clicked.connect(self.launchProjectTable)
[docs] def launchProjectTable(self): import schrodinger maestro = schrodinger.get_maestro() if maestro: maestro.command("showpanel table")
[docs]class TextSearchBar(QtWidgets.QWidget): """ A compound widget for performing a simple text search. Consists of a text field with placeholder, previous and next match buttons, and a cancel button that will hide the search bar. """ nextMatchRequested = QtCore.pyqtSignal(str) prevMatchRequested = QtCore.pyqtSignal(str)
[docs] def __init__(self, parent=None, placeholder_text='text in page'): """ :param placeholder_text: placeholder text to show in the text field :type placeholder_text: str """ super(TextSearchBar, self).__init__(parent) layout = QtWidgets.QHBoxLayout(self) self.setLayout(layout) self.text_le = SLineEdit(show_clear=True, placeholder_text=placeholder_text) self.find_lbl = QtWidgets.QLabel('Find:') self.prev_btn = QtWidgets.QToolButton() self.next_btn = QtWidgets.QToolButton() self.hide_btn = QtWidgets.QToolButton() layout.addWidget(self.find_lbl) layout.addWidget(self.text_le) layout.addWidget(self.prev_btn) layout.addWidget(self.next_btn) layout.addWidget(self.hide_btn) layout.setContentsMargins(0, 0, 0, 0) layout.addStretch() qs = QtWidgets.QStyle style = self.style() self.prev_btn.setIcon(style.standardIcon(qs.SP_ArrowUp)) self.next_btn.setIcon(style.standardIcon(qs.SP_ArrowDown)) self.hide_btn.setIcon(style.standardIcon(qs.SP_TitleBarCloseButton)) self.hide_btn.setAutoRaise(True) self.prev_btn.clicked.connect(self.onPrevBtnClicked) self.next_btn.clicked.connect(self.onNextBtnClicked) self.hide_btn.clicked.connect(self.hide) self.text_le.editingFinished.connect(self.onNextBtnClicked)
[docs] def onPrevBtnClicked(self): self.prevMatchRequested.emit(self.text_le.text())
[docs] def onNextBtnClicked(self): self.nextMatchRequested.emit(self.text_le.text())
[docs] def searchKeyPressed(self, e): """ Checks a QEvent from keyPressEvent for the search bar invocation key combo (i.e. Ctrl-F or Cmd-F). If the key combo is detected, show the search bar, put focus on the text field, and return True. Otherwise return False. Typical usage in MyPanel:: def keyPressEvent(self, e): if self.search_bar.searchKeyPressed(e): return super(MyPanel, self).keyPressEvent(e) :param e: the event to be checked :type e: QEvent """ if e.key() == QtCore.Qt.Key_F and (e.modifiers() & QtCore.Qt.ControlModifier): self.show() self.text_le.setFocus() return True return False
[docs]def get_picture_as_text(pic, width=TOOLTIP_WIDTH, height=TOOLTIP_HEIGHT): """ Saves a QPicture to an image, returns the 64 base string for the image, so that it can be displayed as a tooltip. """ image = QtGui.QImage(width, height, QtGui.QImage.Format_ARGB32_Premultiplied) rect = QtCore.QRect(0, 0, width, height) # Without filling the background, the image was very noisy. image.fill(Qt.white) painter = QtGui.QPainter() painter.begin(image) if pic: draw_picture_into_rect(painter, pic, rect) painter.end() ba = QtCore.QByteArray() buf = QtCore.QBuffer(ba) buf.open(QtCore.QIODevice.WriteOnly) image.save(buf, "PNG") buf_str = str(buf.data().toBase64(), encoding='utf-8') return ('<img src="data:image/png;base64,{0}" width="{1}" height="{2}"' '>').format(buf_str, width, height)
[docs]class SpinBoxDoubleSlider(QtWidgets.QWidget): """ This widget is made up of a double slider widget with a double spinbox on each side. It allows selection of a sub-range from a given range of values. Originally copied from mmshare/python/scripts/watermap_result_gui_dir/cutoff_slider.py and expanded with additional functionality. """ cutoffChanged = QtCore.pyqtSignal()
[docs] def __init__(self, parent=None, range_min=-1.0, range_max=1.0, min_value=-1.0, max_value=1.0, stepsize=0.2, decimals=2, command=None, nocall=True, layout=None): """ Create an SpinBoxDoubleSlider object :type parent: QObject :param parent: the parent object of this SpinBoxDoubleSlider :type range_min: float :param minimum: The minimum value this SpinBoxDoubleSlider can take on. The reset method will also reset the range of the slider using this value. :type range_max: float :param maximum: The maximum value this SpinBoxDoubleSlider can take on. The reset method will also reset the range of the slider using this value. :type min_value: float :param value: The initial minimum (left slider) value of this SpinBoxDoubleSlider. This value will also be used as the default left slider value by the reset method. :type max_value: float :param value: The initial maximum (right slider) value of this SpinBoxDoubleSlider. This value will also be used as the default right slider value by the reset method. :type stepsize: float :param stepsize: The stepsize each spinbox arrow click changes the value by :type decimals: int :param decimals: Number of decimal places to display in each spinbox :type layout: QLayout :param layout: If supplied, the SpinBoxDoubleSlider created will be added to this layout :type command: python callable :param command: The callback for the cutoffChanged signal. This command will be called during initialization unless nocall is set to True. :type nocall: bool :param nocall: True if the callback command should not be called during initialization, False if the command should be called. """ QtWidgets.QWidget.__init__(self, parent) self.layout = QtWidgets.QHBoxLayout(self) self.min_sb = QtWidgets.QDoubleSpinBox(self) self.min_sb.setMaximumSize(QtCore.QSize(72, 16777215)) self.min_sb.setDecimals(decimals) self.min_sb.setMinimum(-999999999.0) self.min_sb.setMaximum(999999999.0) self.min_sb.setSingleStep(stepsize) self.min_sb.setValue(min_value) self.layout.addWidget(self.min_sb) # See PANEL-3561 for developing a single double slider widget. When # implemented, we should use that widget here instead. self.slider = canvas2d.ChmDoubleSlider(self) self.slider.setMinimumWidth(120) self.slider.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) self.layout.addWidget(self.slider, stretch=2) self.max_sb = QtWidgets.QDoubleSpinBox(self) self.max_sb.setMaximumSize(QtCore.QSize(72, 16777215)) self.max_sb.setDecimals(decimals) self.max_sb.setMinimum(-999999999.0) self.max_sb.setMaximum(999999999.0) self.max_sb.setSingleStep(stepsize) self.max_sb.setValue(max_value) self.layout.addWidget(self.max_sb) self.min_range = range_min self.max_range = range_max self.default_min_range = range_min self.default_max_range = range_max self.slider.setRange(self.min_range, self.max_range) self.setContentsMargins(0, 0, 0, 0) self.min_sb.valueChanged.connect(self._minValueChanged) self.max_sb.valueChanged.connect(self._maxValueChanged) self.slider.valuesChanged.connect(self._slidersMoved) self.slider.mouseReleased.connect(self._slidersReleased) if layout: layout.addWidget(self) if command: self.cutoffChanged.connect(command) if not nocall: self.cutoffChanged.emit() self.default_min_val = min_value self.default_max_val = max_value self.has_been_shown = False
[docs] def showEvent(self, event): """ See parent class for documentation """ # Re-enforce the current min/max because at creation time the size of # the slider is indeterminate so the slider positions are wrong. # MATSCI-10365 if not self.has_been_shown: from schrodinger.ui.qt import utils as qtutils # We use suppress_signals to suppress the native Qt signals, # and emit=False to suppress the manually emitted signal with qtutils.suppress_signals(self, self.slider, self.min_sb, self.max_sb): self._minValueChanged(self.min_sb.value(), emit=False) self._maxValueChanged(self.max_sb.value(), emit=False) self.has_been_shown = True return super().showEvent(event)
[docs] def setStepSize(self, stepsize): """ Set the stepsize values for both spinboxes :type stepsize: float :param stepsize: The new stepsize """ self.min_sb.setSingleStep(stepsize) self.max_sb.setSingleStep(stepsize)
[docs] def setRange(self, min_range, max_range): self.min_range = min_range self.max_range = max_range # Work around for a bug where ChmDoubleSlider can't handle min and max # being the same: if min_range == max_range: min_range -= 0.00001 max_range += 0.00001 self.slider.setRange(min_range, max_range) self._slidersMoved() self.update()
[docs] def getRange(self): return self.min_range, self.max_range
[docs] def setValues(self, lpos, rpos): self.slider.leftPos(lpos) self.slider.rightPos(rpos) self._slidersMoved()
[docs] def getValues(self): min_v = self.min_sb.value() max_v = self.max_sb.value() return min_v, max_v
[docs] def getTrueValues(self): """ Get the actual min and max values rather than that represented by the spin box. The two may differ because the spinbox values are truncated at a certain number of decimal points :rtype: (float, float) :return: The values of the left and right sliders """ lpos = self.slider.leftPos() rpos = self.slider.rightPos() return lpos, rpos
def _slidersMoved(self): if not self.slider.isEnabled(): # Work-around for SHARED-6739 self.slider.blockSignals(True) self.slider.leftPos(self.min_sb.value()) self.slider.rightPos(self.max_sb.value()) self.slider.blockSignals(False) lpos = self.slider.leftPos() rpos = self.slider.rightPos() self.min_sb.blockSignals(True) self.max_sb.blockSignals(True) self.min_sb.setValue(lpos) self.max_sb.setValue(rpos) self.min_sb.blockSignals(False) self.max_sb.blockSignals(False) self.cutoffChanged.emit() def _slidersReleased(self): self.cutoffChanged.emit() def _minValueChanged(self, new_value, emit=True): """ React to a change in the minimum value spinbox :param float new_value: The new minimum value :param bool emit: Whether to emit the cutoffChanged signal """ current = new_value if new_value < self.min_range: new_value = self.min_range rpos = self.slider.rightPos() if new_value > rpos: new_value = rpos self.slider.leftPos(new_value) if not new_value == current: self.min_sb.blockSignals(True) self.min_sb.setValue(new_value) self.min_sb.blockSignals(False) if emit: self.cutoffChanged.emit() def _maxValueChanged(self, new_value, emit=True): """ React to a change in the maximum value spinbox :param float new_value: The new maximum value :param bool emit: Whether to emit the cutoffChanged signal """ current = new_value if new_value > self.max_range: new_value = self.max_range lpos = self.slider.leftPos() if new_value < lpos: new_value = lpos self.slider.rightPos(new_value) if new_value != current: self.max_sb.blockSignals(True) self.max_sb.setValue(new_value) self.max_sb.blockSignals(False) if emit: self.cutoffChanged.emit()
[docs] def reset(self): """ Reset the spinboxes and sliders to their original value. Only the values are reset, not other parameters (consistent with other swidgets such as spinboxes """ self.setRange(self.default_min_range, self.default_max_range) self.min_sb.setValue(self.default_min_val) self.max_sb.setValue(self.default_max_val)
[docs]class EditWithFocusOutEvent(SLabeledEdit): """ SLabeledEdit that calls 'focus_out_command' on returnPressed() and on 'focusOutEvent' with modified text. """
[docs] def __init__(self, *args, **kwargs): """ Initialize EditWithFocusOutEvent. :type focus_out_command: method :keyword focus_out_command: Method to be called when focus out event occurs """ self.focus_out_command = kwargs.pop('focus_out_command', None) SLabeledEdit.__init__(self, *args, **kwargs) if self.focus_out_command: # Call 'focus_out_command' on Return or Enter pressed (MATSCI-6329) self.returnPressed.connect(self.focus_out_command)
[docs] def focusOutEvent(self, event): """ Call self.focus_out_command when text modified, if present. This does NOT create race conditions with SLineEdit.eventFilter for always_valid=True. And the focus_out_command is only executed when the text is changed. :type event: QEvent :param event: Focus out event """ if self.focus_out_command and self.isModified(): self.focus_out_command() self.setModified(False) SLabeledEdit.focusOutEvent(self, event)
[docs]class SLabeledSlider(QtWidgets.QSlider): """ A slider with text label to the left of it and a value label to the right of it that gives the current slider value. An additional label to the right of the value label ("after_label") can be used for units, etc. The slider can be used to control and display integer or floating point data. The widget has a mylayout property that points to the internal layout that contains all the widgets. This layout is added to the layout provided by the "layout" keyword, or can be manually added to a layout after creation. """
[docs] def __init__(self, text, parent=None, minimum=1, maximum=100, stepsize=1, ticks=True, tick_interval=25, value=50, float_step=1, float_decimals=2, layout=None, command=None, nocall=True, stretch=True, after_label=None, value_label=True, orientation=HORIZONTAL, tip=None): """ Create an SLabeledSlider object :type text: str or None :keyword text: The text of the label - if None, no leading label is created :type parent: QObject :param parent: the parent object of this SLabeledSlider :type minimum: int :param minimum: The minimum value of this SLabeledSlider :type maximum: int :param maximum: The maximum value of this SLabeledSlider :type stepsize: int :param stepsize: The amount the value changes each time an arrow key is pressed :type tick_interval: int :param tick_interval: The interval between tick marks :type value: int :param value: The initial value of this SLabeledSlider. This value will also be used as the default value by the reset method. :type float_step: float :param float_step: QSliders only deal with integer values but often need to control float values. Use float_step to provide the factor that the slider integer value gets multiplied by to get the float value. This floating point value is displayed in the value_label and returned by the floatValue method. :type float_decimals: int :param float_decimals: The number of decimal places to show in the value_label if the value_label is displayed and float_step is provided. :type layout: QLayout :param layout: If supplied, the SLabeledSlider created will be added to this layout :type command: python callable :param command: The callback for the valueChanged signal. This command will not be called during initialization unless nocall is set to False. :type nocall: bool :param nocall: True (default) if the callback command should not be called during initialization, False if the command should be called. :type stretch: bool :keyword stretch: Whether to put a stretch after the SLabeledSlider (or after the after_label). Default is True. :type after_label: str :keyword after_label: Label text to put after the SLabeledSlider - default is None :type value_label: bool :keyword value_label: If True, place a label directly after the slider that gives the current value of the slider. Units can be added to this value via the after_label keyword. The after_label will be placed immediately after the value_label. :type orientation: str :param orientation: The orientation of the slider. Should be one of the module constants VERTICAL or HORIZONTAL. :type tip: str :param tip: The tooltip to apply to the labels and slider """ if orientation == VERTICAL: orientation = Qt.Vertical else: orientation = Qt.Horizontal QtWidgets.QSlider.__init__(self, orientation, parent) # Must create value_label attribute before calling setMaximum self.value_label = None self.setMinimum(minimum) self.setMaximum(maximum) self.setValue(value) self.setSingleStep(stepsize) if ticks: self.setTickInterval(tick_interval) self.setTickPosition(self.TicksBelow) else: self.setTickPosition(self.NoTicks) self.float_step = float_step self.float_decimals = float_decimals self.default_value = value self.mylayout = SHBoxLayout(layout=layout) if text: self.label = SLabel(text, layout=self.mylayout) else: self.label = None self.mylayout.addWidget(self) if value_label: self.value_label = SLabel("", layout=self.mylayout) self.setupValueLabel() else: self.value_label = None if after_label: self.after_label = SLabel(after_label, layout=self.mylayout) else: self.after_label = None if stretch: self.mylayout.addStretch() if command: self.valueChanged.connect(command) if not nocall: command() if tip: self.setToolTip(tip) # Allow changing values through arrow keys, PageUp/PageDown, and # Home/End when the slider is clicked self.setFocusPolicy(QtCore.Qt.ClickFocus)
[docs] def setToolTip(self, tip): """ Add the tooltip to the slider and all labels :type tip: str :param tip: The tooltip to add """ super().setToolTip(tip) if self.label: self.label.setToolTip(tip) if self.value_label: self.value_label.setToolTip(tip) if self.after_label: self.after_label.setToolTip(tip)
[docs] def setEnabled(self, state): """ Set the slider's and labels' enabled states :type state: bool :param state: Whether the slider and labels are enabled or not """ QtWidgets.QSlider.setEnabled(self, state) if self.label: self.label.setEnabled(state) if self.value_label: self.value_label.setEnabled(state) if self.after_label: self.after_label.setEnabled(state)
[docs] def setMaximum(self, maximum): """ Set the maximum value possible for the slider :type maximum: int :param maximum: The maximum value for the slider """ QtWidgets.QSlider.setMaximum(self, maximum) # Ensure the value label is the proper width if not self.value_label: return self._setValueLabelWidth()
[docs] def reset(self): """ Reset the value of the slider """ self.setValue(self.default_value)
[docs] def setupValueLabel(self): """ Set up the value label. Can be called to setup a label in a custom layout as the widget's value label """ self._setValueLabelWidth() self.updateValueLabel() self.valueChanged.connect(self.updateValueLabel)
def _setValueLabelWidth(self): """ Set the widget of the value label to a fixed width that accommodates its largest value. This helps prevent the label (and other widgets) from bouncing left/right as the value in the label changes """ if not self.value_label: return previous = self.value_label.text() value = self.maximum() * self.float_step self._setValueLabelText(value) hint = self.value_label.sizeHint() self.value_label.setFixedWidth(hint.width()) self.value_label.setText(previous) def _setValueLabelText(self, value): """ Set the text in the value label to a new value :type value: int or float :param value: The new value for the label """ if self.float_step != 1: self.value_label.setText('%.*f' % (self.float_decimals, value)) else: self.value_label.setText(str(value))
[docs] def updateValueLabel(self): """ Set the value label text based on the current slider value """ if self.value_label: if self.float_step != 1: current = self.floatValue() else: current = self.value() self._setValueLabelText(current)
[docs] def floatValue(self): """ Get the current float value. The float value differs from the slider value in that the slider value is an integer, while the float value is a float calculated by multiplying float_step by the integer value. :rtype: float :return: The current widget float value """ return self.float_step * self.value()
[docs] def setFloatValue(self, float_value): """ Set the current float value of the slider. The value gets rounded to the nearest acceptable slider value. :param float float_value: The value to set """ self.setValue(self.toInt(float_value))
[docs] def toInt(self, float_value): """ Convert a float value to the equivalent int value for the slider :param float float_value: The value to convert :rtype: int :return: The integer equivalent of float_value """ return round(float_value / self.float_step)
[docs] def setFloatMaximum(self, float_maximum): """ Set the maximum float value of the slider. The maximum gets rounded to the nearest acceptable slider value. :param float float_maximum: The new maximum """ self.setMaximum(self.toInt(float_maximum))
[docs] def setFloatMinimum(self, float_minimum): """ Set the minimum float value of the slider. The minimum gets rounded to the nearest acceptable slider float value. :param float float_minimum: The new minimum """ self.setMinimum(self.toInt(float_minimum))
[docs] def setVisible(self, state): """ Set all child widgets to the visible state :type state: bool :param state: True if widgets should be visible, False if not """ super().setVisible(state) if self.value_label: self.value_label.setVisible(state) if self.label: self.label.setVisible(state) if self.after_label: self.after_label.setVisible(state)
[docs]class STabWidget(QtWidgets.QTabWidget): """ A QTabWidget with additional convenience methods """
[docs] def __init__(self, parent=None, tabs=None, layout=None, command=None, nocall=True): """ Create an STabWidget instance :type parent: QWidget :param parent: The parent widget for this widget :type tabs: dict :param tabs: Tabs to create and add to the STabWidget. Keys are tab labels, values are widgets for that tab :type layout: QLayout :param layout: If supplied, the STabWidget created will be added to this layout :type command: python callable :param command: The callback for the currentChanged signal. This command will not be called during initialization unless nocall is set to False. :type nocall: bool :param nocall: True (default) if the callback command should not be called during initialization, False if the command should be called. """ QtWidgets.QTabWidget.__init__(self, parent) if layout is not None: layout.addWidget(self) if tabs: for label, widget in tabs.items(): self.addTab(widget, label) if command: self.currentChanged.connect(command) if not nocall: self.currentChanged.emit(self.currentIndex())
[docs] def reset(self): """ Reset the STabWidget by calling reset on each individual tab. Tabs without a reset() method will be skipped """ for tab in self.tabs(): try: tab.reset() except AttributeError: # This tab does not have a reset method pass
[docs] def tabs(self): """ A generator for all tab widgets :rtype: QWidget :return: Iterates over each tab widget in index order """ for index in range(self.count()): yield self.widget(index)
[docs] def getTabByLabel(self, label): """ Return the tab widget with the given label :type label: str :param label: The label of the desired tab :rtype: QWidget :return: The tab widget with the given label :raise ValueError: If no tab with this label is found """ for index in range(self.count()): if self.tabText(index) == label: return self.widget(index) raise ValueError('No tab with label %s is found' % label)
[docs] def setCurrentLabel(self, label): """ Set the tab with the given label to be the current tab :type label: str :param label: The label of the tab to make current """ self.setCurrentWidget(self.getTabByLabel(label))
[docs] def currentLabel(self): """ Get the label of the current tab :rtype: str :return: The label of the current tab """ return self.tabText(self.currentIndex())
[docs]class PlaceholderComboMixin: """ A QtWidgets.QComboBox mixin which allows for a placeholder text to be set without the need to make the combobox editable. The placeholder text is shown in italics when the current index is -1 """
[docs] def __init__(self, parent=None, placeholder="Please select an option...", **kwargs): super().__init__(parent, **kwargs) self._placeholder_text = placeholder
[docs] def paintEvent(self, event): """ This reimplements QComboBox.paintEvent based on the C++ implementation. It uses italic font to paint _placeholder_text when the index is -1 """ painter = QtWidgets.QStylePainter(self) painter.setPen(self.palette().color(QtGui.QPalette.Text)) opt = QtWidgets.QStyleOptionComboBox() self.initStyleOption(opt) if self.currentIndex() == -1: # Paint the placeholder text in italics font = painter.font() font.setItalic(True) painter.setFont(font) opt.currentText = self._placeholder_text # draw the combobox frame, focusrect, selected, etc. painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, opt) # draw the icon and text painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, opt)
[docs]class StyleMixin: """ In order to style custom QWidget subclasses using stylesheet, this custom paintEvent method must be implemented. """
[docs] def paintEvent(self, e): """ See `QtWidgets.QWidget` documentation for more information. """ opt = QtWidgets.QStyleOption() opt.initFrom(self) p = QtGui.QPainter(self) self.style().drawPrimitive(QtWidgets.QStyle.PE_Widget, opt, p, self)
[docs]class SRetainableMenu(QtWidgets.QMenu): """ A menu that doesn't get closed after selecting an item """
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.installEventFilter(self)
[docs] def eventFilter(self, obj, event): if event.type() == QtCore.QEvent.MouseButtonPress: if isinstance(obj, QtWidgets.QMenu): if obj.activeAction(): obj.activeAction().trigger() return True return super().eventFilter(obj, event)
[docs]class SSplitter(QtWidgets.QSplitter):
[docs] def __init__(self, orientation=Qt.Horizontal, collapsible=False, expanding=True, layout=None, widgets=None): """ A customized QSplitter widget :param enum orientation: Either Qt.Horiztonal or Qt.Vertical :param bool collapsible: Whether the panes can be collapsed or not :param bool expanding: Whether the splitter can expand in the direction opposite of the splitter orientation :param `QtWidgets.QBoxLayout` layout: The layout to place this into :param list widgets: A list of widgets to add to the splitter """ super().__init__(orientation) self.setChildrenCollapsible(collapsible) if expanding: policy = self.sizePolicy() if self.orientation() == Qt.Horizontal: policy.setVerticalPolicy(policy.Expanding) else: policy.setHorizontalPolicy(policy.Expanding) self.setSizePolicy(policy) if widgets: for widget in widgets: self.addWidget(widget) # Default to the same size for each widget. Note that contrary to # the Qt docs, if the size value is smaller than the actual widget # size, they will not be set to the same size. Only values larger # than the actual size result in equal-size panes. self.setSizes([10000] * len(widgets)) if layout is not None: layout.addWidget(self)
[docs]class CenteringWidget(QtWidgets.QWidget): """ A widget used to center other widgets when placed in a table cell"""
[docs] def __init__(self, widget, layout_margins=None): """ Create a CenteringWidget object :param `QtWidgets.QWidget` widget: The widget to center """ super().__init__() self.actual_widget = widget layout = SHBoxLayout(self) layout.setAlignment(Qt.AlignCenter) if layout_margins: layout.setContentsMargins(*layout_margins) layout.addWidget(widget)
[docs]class SelectorWithPopUp(SFrame): """ A frame that contains a label, read-only line edit, and a tool button that shows a pop up list. Requires a TOOL_BUTTON_CLASS to be defined in children. """
[docs] def __init__(self, label, default_selection, layout=None, stretch=True): """ Create a SelectorWithPopUp instance :param str label: The label for the edit :param str default_selection: The default selection for the edit and list :param QLayout layout: The layout to place the frame in """ super().__init__(layout=layout, layout_type=HORIZONTAL) self.selection_le = SLabeledEdit(label, layout=self.mylayout, read_only=True, stretch=False) self.tool_btn = self.TOOL_BUTTON_CLASS(self) self.tool_btn.setPopupValign(self.tool_btn.ALIGN_BOTTOM) self.tool_btn.popUpClosing.connect(self.selectionChanged) self.mylayout.addWidget(self.tool_btn) if stretch: self.mylayout.addStretch() self.default_selection = default_selection self.reset()
[docs] def getSelection(self): """ Get the current selection :rtype: str :return: The current selection """ return self.selection_le.text()
[docs] def clearFilters(self): """ Clear the filters and make all options visible """ self.tool_btn.clearFilters()
[docs] def selectionChanged(self): """ Callback for when a new item is selected in the pop up list """ raise NotImplementedError
[docs]def center_widget_in_table_cell(table, row, column, widget, layout_margins=None, item_type=STableWidgetItem): """ Centers the widget in the cell and adds a non-selectable widget item so the cell background can't be selected :type table: QTableWidget :param table: The table that contains the cell & widget :type row: int :param row: The row of the cell :type column: int :param column: The column of the cell :type widget: QWidget :param widget: The widget that will be placed into the cell and centered :param tuple layout_margins: Tuple containing left, top, right, and bottom margins to use around the layout :param class item_type: The class for the create table widget item """ centering_widget = CenteringWidget(widget, layout_margins=layout_margins) table.setCellWidget(row, column, centering_widget) # Add a non-selectable item so the user can't accidentally select this cell item = item_type() item.setFlags(Qt.NoItemFlags) table.setItem(row, column, item)
[docs]class DeleteButton(QtWidgets.QToolButton): """ A QToolButton that accepts the delete command callback for the clicked() signal as an argument """
[docs] def __init__(self, *args, command=None, horizontal_policy=QtWidgets.QSizePolicy.Fixed, layout=None, tip=None): """ Accepts all arguments given for a QToolButton :param function command: Delete function to call when the button emits a 'clicked' signal :param QtWidgets.QSizePolicy.Policy horizontal_policy: If QSizePolicy.Fixed (default), the button horizontal size policy will be 'fixed'. If None, it will be the QtWidgets.QToolButton default. Otherwise, the horizontal policy will be set to the value given by this parameter. :param QLayout layout: Layout to place the button in :param str tip: The tooltip for this delete button """ super().__init__(*args) if command: self.clicked.connect(command) if layout is not None: layout.addWidget(self) if horizontal_policy: size_policy = self.sizePolicy() size_policy.setHorizontalPolicy(horizontal_policy) self.setSizePolicy(size_policy) self.setIcon(QtGui.QIcon(std_icons.TRASH_DELETE_LB)) if tip: self.setToolTip(tip)