"""
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 os
import numpy
import sys
import pyhelp
from collections import OrderedDict
from collections import namedtuple
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.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_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_TEXT = lambda x: '<sub>%s</sub>' % x
RIGHT_ARROW = chr(10132)
TRIPLE_BOND = u'\u2261'
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])
class SLabel(QtWidgets.QLabel):
"""
A QLabel that can pack itself into a layout upon creation
"""
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()
def reset(self):
"""
Reset the label to its original text
"""
self.setText(self.default_text)
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.
"""
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()
def setText(self, text):
self._text = text
self.setToolTip(text)
self._updateElidedText()
def resizeEvent(self, event):
self._updateElidedText()
def _updateElidedText(self):
"""
Adjust the text of the QLable according to the current widget size.
"""
font_metrics = QtGui.QFontMetrics(self.font())
width = self.width() - 3
elided_text = font_metrics.elidedText(self._text, Qt.ElideRight, width)
super().setText(elided_text)
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.
"""
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)
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
def setText(self, text, *args, **kwargs):
# Clear image before setting text
self._image = self._width = self._height = None
super().setText(text, *args, **kwargs)
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()
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)
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)
class Spinner(QtWidgets.QLabel):
"""
A QLabel that shows a rotating spinner icon.
"""
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)
def startAnimation(self):
"""
Show an animated spinner icon.
"""
if not self.timer.isActive():
self.timer.start(100)
def stopAnimation(self):
"""
Stop animation of the spinner icon.
"""
if self.timer.isActive():
self.timer.stop()
self.setPixmap(self.standard_icon)
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])
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'
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
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()
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)
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'
class LargeSpinner(QtWidgets.QLabel):
"""
A QLabel that shows a large rotating spinner icon.
"""
def __init__(self, *args, **kwargs):
QtWidgets.QLabel.__init__(self, *args, **kwargs)
self.setAlignment(Qt.AlignCenter)
self.loading_icon = _LargeSpinnerIcon(self)
self.setMinimumSize(0, 66)
def startAnimation(self):
"""
Start the spinner
"""
self.loading_icon.startSpinner(self)
def stopAnimation(self):
"""
Stop the spinner
"""
self.loading_icon.stopSpinner()
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()))
def icon(self):
"""
For compatibility with the SpinnerIcon class.
"""
return self.loading_icon
class SValidIndicator(SLabel, object):
"""
An SLabel that easily changes between a valid icon and an invalid icon
"""
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
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
}
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)
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
def reset(self):
"""
Set the indicator to its initial state
"""
self.setState(self._default_state)
class VerticalLabel(SLabel):
"""
This creates a vertical label with property size, position,
and enabled/disabled states.
"""
def __init__(self, *args, **kwargs):
"""
Create a label and set default state.
"""
super(VerticalLabel, self).__init__(*args, **kwargs)
self.state = True
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()
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()
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())
def sizeHint(self):
"""
Overwrite parent method.
"""
size = SLabel.sizeHint(self)
return QtCore.QSize(size.height(), size.width())
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.')
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
: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)
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())
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)
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)
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()
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)
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)
def reset(self):
"""
Reset the LineEdit to its original text
"""
self.setText(self.default_text)
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())
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())
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())
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)
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.
"""
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
: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)
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
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
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
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')
class SPushButton(QtWidgets.QPushButton):
"""
A QPushButton that accepts the command callback for the clicked() signal as
an argument
"""
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)))
class SCheckBox(QtWidgets.QCheckBox):
"""
A QCheckBox that accepts the command callback for the clicked() signal as
an argument
"""
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)))
def reset(self):
"""
Reset the checkbox to its initial state
"""
self.setChecked(self.default_state)
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
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)
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)
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)
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.
"""
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())
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.
"""
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)
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())
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)
def reset(self):
"""
Reset all the widgets
"""
if hasattr(self.subwidget, 'reset'):
self.subwidget.reset()
super().reset()
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.
"""
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)
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()
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)
def checkBoxText(self):
"""
Get the current text of the checkbox label
:rtype: str
:return: The text of the checkbox label
"""
return SCheckBox.text(self)
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)
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)
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)
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)
@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
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()
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)
class SRadioButton(QtWidgets.QRadioButton):
"""
A QRadioButton that accepts the command callback for the clicked() signal as
an argument
"""
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)
def reset(self):
"""
Reset the radiobutton to its initial state
"""
self.setChecked(self.default_state)
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.
"""
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)
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)
def setVisible(self, state):
"""
Set visible state.
:type state: bool
:param state: the visible state
"""
super().setVisible(state)
self.subwidget.setVisible(state)
def reset(self):
"""
Reset.
"""
super().reset()
if hasattr(self.subwidget, 'reset'):
self.subwidget.reset()
class SCheckboxButtonGroup(QtWidgets.QButtonGroup):
"""
A QButtonGroup that changes the functionality of set exclusive and allows
for deselection
"""
def __init__(self, parent=None):
"""
Create a SCheckboxButtonGroup instance to make exclusive work for
checkboxes
:type parent: QObject
:param parent: the parent object of this ButtonGroup
:type layout: QLayout
:param layout: If supplied, the buttons created will be added to this
layout in the order they are created.
"""
QtWidgets.QButtonGroup.__init__(self, parent)
self.buttonClicked.connect(self.checkState)
self.setExclusive(False)
def checkState(self, button):
"""
Uncheck all the buttons except the button clicked
:type button: QCheckBox
:param button: The button that was clicked
"""
[x.setChecked(False) for x in self.buttons() if x != button]
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.
"""
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)
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)
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()]
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 self.radio:
if subwidget:
button = SRadioButtonWithSubWidget(
subwidget.widget,
text,
layout=self.layout,
layout_type=subwidget.layout_type)
else:
button = SRadioButton(text, layout=self.layout)
else:
button = SCheckBox(text, layout=self.layout)
bid = self.addExistingButton(button, connect=connect)
if tip:
button.setToolTip(tip)
return bid
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
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
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)
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 ""
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()
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
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()
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)
class SLabeledRadioButtonGroup(SRadioButtonGroup):
"""
Convenience class for adding all SRadioButtonGroup buttons to a layout with
an optional group label
"""
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:
# mylayout can be accessed using self.layout
mylayout = SHBoxLayout()
else:
mylayout = SVBoxLayout()
if layout is not None:
layout.addLayout(mylayout)
if group_label:
self.group_label = SLabel(group_label, layout=mylayout)
else:
self.group_label = None
super().__init__(layout=mylayout, **kwargs)
if stretch:
mylayout.addStretch()
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)
def reset(self):
"""
Reset radio buttons and group label
"""
super().reset()
if self.group_label:
self.group_label.reset()
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)
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
"""
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)
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
"""
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)
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
"""
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)
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.
"""
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)))
def reset(self):
"""
Reset the GroupBox to the default check state if checkable
"""
if self.isCheckable():
self.setChecked(self.default_checkstate)
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):
"""
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
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.
"""
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)
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
class FileBaseNameValidator(EnforcingValidatorMixin, QtGui.QValidator):
"""
Manage a file base name validator.
"""
def __init__(self, *args, **kwargs):
QtGui.QValidator.__init__(self, *args, **kwargs)
self.last_valid_value = None
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
class SAsciiOnlyValidator(QtGui.QValidator):
"""
Ensures that the line edit contains only ASCII characters
"""
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
class SASLValidator(QtGui.QValidator):
"""
Ensures that the line edit contains valid ASL
"""
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
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.
"""
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
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
class SNoCommaDoubleValidator(SNoCommaMinMaxMixin, QtGui.QDoubleValidator):
"""
A double validator that does not accept comma, which is used
and accepted as a group separator in QDoubleValidator.
"""
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)
class SNonNegativeRealValidator(EnforcingValidatorMixin,
SNoCommaDoubleValidator):
"""
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.
"""
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)
if bottom is not None:
self.setBottom(bottom)
if top:
self.setTop(top)
if decimals:
self.setDecimals(decimals)
self.last_valid_value = None
class SNoCommaIntValidator(SNoCommaMinMaxMixin, QtGui.QIntValidator):
"""
An int validator that does not accept comma, which is used
and accepted as a group separator in QIntValidator.
"""
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)
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.
"""
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
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.
"""
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('-')
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)
if not value:
# Empty edit is OK in the middle of editing
return self.Intermediate, value, position
if self.delimiter:
tokens = value.split(self.delimiter)
else:
tokens = value.split()
is_ok = True
for index, token in enumerate(tokens, 1):
if token in self.allowed_symbols:
if index == len(tokens):
# If the last value is one of the allowed symbols, the user
# is just in the process of appending another number after
# the delimiter
return self.Intermediate, value, position
else:
# It's not OK for an allowed symbol value at the beginning
# or in the middle of the tokens, this means the user has
# done something like "," or "1,,2"
return self.Invalid, value, position
try:
val = self.number_type(token.strip())
except ValueError:
# Values must be of the correct type
is_ok = False
break
if val < self.bottom:
# Values must not be below bottom
is_ok = False
break
if is_ok:
self.last_valid_value = value
return self.Acceptable, value, position
else:
return self.Invalid, value, position
class SPosIntListValidator(SNumberListValidator):
"""
A Validator that only accepts a list of positive ints with a delimiter.
"""
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)
class SRegExpValidator(EnforcingValidatorMixin, QtGui.QRegExpValidator):
"""
A validator that only accepts strings that match the regexp.
"""
def __init__(self, regexp, parent=None):
"""
Create an SNonEmptyStringValidator object.
:type regexp: `QtCore.QRegExp`
:param regexp: Regular expression to validate
:type parent: QObject
:param parent: the object that owns the validator
"""
QtGui.QRegExpValidator.__init__(self, regexp, parent=parent)
self.last_valid_value = None
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])
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):
"""
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"
"""
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)
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)
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
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)
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)
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())
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)
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)
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())
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())
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)
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
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)
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())]
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.
"""
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.
"""
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)
SComboBox.__init__(
self,
items=items,
layout=self.mylayout,
default_item=default_item,
default_index=default_index,
command=command,
nocall=nocall,
itemdict=itemdict)
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)))
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
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
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
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)
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)
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)
def addAction(self, action):
self.menu.addAction(action)
def addSeparator(self):
return self.menu.addSeparator()
def count(self):
return len(self.menu.actions())
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)
def reset(self):
self.menu.clear()
def showPopup(self):
global_pos = self.parent().mapToGlobal(self.geometry().topLeft())
self.menu.exec_(global_pos)
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())
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)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.menu = QtWidgets.QMenu()
self.setMenu(self.menu)
self.setStyleSheet(self.STYLE_SHEET)
def addItem(self, text, function):
return self.menu.addAction(text, function)
def addAction(self, action):
self.menu.addAction(action)
def addSeparator(self):
return self.menu.addSeparator()
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.
"""
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)
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)
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)
class SSpinBox(QtWidgets.QSpinBox):
"""
A QSpinBox that can set it's min, max and value in the constructor plus
pack itself in a layout
"""
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)
def reset(self):
"""
Reset the spin box to its default value
"""
if self.default_value is not None:
self.setValue(self.default_value)
class SDoubleSpinBox(QtWidgets.QDoubleSpinBox):
"""
A QDoubleSpinBox that can set it's min, max and value in the constructor
plus pack itself in a layout
"""
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)
def reset(self):
"""
Reset the spin box to its default value
"""
if self.default_value is not None:
self.setValue(self.default_value)
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.
"""
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)
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
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
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
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.
"""
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)
tip = kwargs.pop('tip', 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)
if tip:
self.label.setToolTip(tip)
self.setToolTip(tip)
try:
self.after_label.setToolTip(tip)
except AttributeError:
pass
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
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
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
class SListWidget(QtWidgets.QListWidget):
"""
A QListWidget that can set its items and set up a callback at creation.
"""
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)
def allItems(self):
"""
Return all the items in the list widget in order.
:rtype: list
:return: list of all QListWidgetItems in the list widget in order
"""
return [self.item(x) for x in range(self.count())]
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()))
def allText(self):
"""
Return the list of all text values.
:rtype: list
:return: List of strings
"""
return [str(x.text()) for x in self.allItems()]
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)
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
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
def setTextSelected(self, text, selected=True):
"""
Selects the item with the given text
: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(True)
return True
except IndexError:
return False
def rowByText(self, text, match=QtCore.Qt.MatchExactly):
"""
Returns the row that a given text is in.
: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
def selectedText(self):
"""
Return the list of selected text values.
:rtype: list
:return: List of strings, each member is the text of a selected item
"""
return [str(x.text()) for x in self.selectedItems()]
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.
"""
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._value_list = []
self.case = case
self._case_list = []
layout = kwargs.pop('layout', None)
SListWidget.__init__(self, **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)
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
"""
if not self.case:
filtervalue = filtervalue.lower()
SListWidget.clear(self)
for value, casevalue in zip(self._value_list, self._case_list):
if self.filter_func(casevalue, filtervalue):
SListWidget.addItem(self, value)
def newItem(self, text, index=None):
"""
Adds a new string to _value_list and _case_list. Adds it lowercase if
we do case insensitive filtering to save time during the actual filter
process.
Does not add the item to the ListWidget (though item will be added the
next time filtering is done)
:type text: str
:param text: The text give to the item
:type index: int
:param index: The index to insert this item at
"""
text = str(text)
if index is None:
index = len(self._value_list)
self._value_list.insert(index, text)
if not self.case:
self._case_list.insert(index, text.lower())
else:
self._case_list.insert(index, text)
def addItem(self, text):
"""
Add an item to the ListWidget.
:type text: str or QListWidgetItem
:param text: The text of the item to be created and added, or a
QListWidget item to be added
"""
if isinstance(text, str):
self.newItem(text)
else:
self.newItem(text.text())
SListWidget.addItem(self, text)
def insertItem(self, index, text):
"""
Add an item to the ListWidget.
:type index: int
:param index: The index to insert this item at
:type text: str or QListWidgetItem
:param text: The text of the item to be created and added, or a
QListWidget item to be added
"""
if isinstance(text, str):
self.newItem(text, index=index)
else:
self.newItem(text.text(), index=index)
SListWidget.insertItem(self, index, text)
def addItems(self, alist):
"""
Add a list of items to the ListWidget.
:type alist: list
:param alist: list of strings to be added to the ListWidget
"""
for value in alist:
self.newItem(value)
SListWidget.addItems(self, alist)
def insertItems(self, index, alist):
"""
Add a list of items to the ListWidget.
:type index: int
:param index: The index to insert this item at
:type alist: list
:param alist: list of strings to be added to the ListWidget
"""
count = index
for value in alist:
self.newItem(value, index=count)
count = count + 1
SListWidget.insertItems(self, index, alist)
def clear(self):
"""
Removes all items from the ListWidget
"""
self._value_list = []
self._case_list = []
self.edit.clear()
SListWidget.clear(self)
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:
index = self._value_list.index(text)
del self._value_list[index]
del self._case_list[index]
return SListWidget.removeItemByText(self, text)
except IndexError:
return None
def takeItem(self, row):
"""
Removes an item from the ListWidget
:type row: int
:param row: the row number to remove
:rtype: QListWidgetItem or 0
:return: The removed item, or 0 if an item with the given text is
not found.
"""
del self._value_list[row]
del self._case_list[row]
return SListWidget.takeItem(self, row)
def trueIndexByText(self, text):
"""
Returns the index of text in the the overall list of items, not just
those currently displayed in the ListWidget.
:type text: str
:param text: the text to search for
:rtype: int or None
:return: The index of the text in the overall list of items, or None if
text is not found
"""
try:
return self._value_list.index(text)
except IndexError:
return None
def editItem(self, item):
"""
Not implemented - editing the item text will cause self._value_list and
self._case_list to become obsolete.
"""
raise NotImplementedError(
'editItem is not implemented for SFilteredListWidgets')
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
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)
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)
class SFrame(QtWidgets.QFrame):
"""
A Frame that can be placed in a layout and is given a layout when created
"""
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)
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)
def addStretch(self):
"""
Add stretch to the frame layout.
"""
self.mylayout.addStretch()
class SHLine(QtWidgets.QFrame):
"""
A simple horizontal line
"""
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)
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.
"""
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)
class SEmittingScrollBar(QtWidgets.QScrollBar):
def showEvent(self, event):
self.shown.emit(self.orientation())
def hideEvent(self, event):
self.hidden.emit(self.orientation())
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)
class SToolButton(QtWidgets.QToolButton):
"""
Schrodinger-style QToolButton
"""
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)
class EditButton(SToolButton):
"""Edit button with custom Schrodinger icon."""
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)
class HelpButton(SToolButton):
HELP_BUTTON_HEIGHT = 22
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))
class InfoButton(SToolButton):
"""Info button, with custom Schrodinger icon."""
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)
class ResetButton(QtWidgets.QToolButton):
"""
Reset button, with custom Schrodinger icon.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
RESET_BUTTON_STYLESHEET = """
QToolButton {
width: 18px;
height: 18px;
border: none;
padding: 1px;
margin-left: 4px;
}
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");
}
""" % {
'UI_QT_DIR': UI_QT_DIR
}
self.setStyleSheet(RESET_BUTTON_STYLESHEET)
self.setToolTip("Reset All")
class SDialog(QtWidgets.QDialog):
"""
A Base Dialog class for easier Dialog creation
"""
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)
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')
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.')
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)
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)
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)
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')
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()
def __init__(self,
master,
tag=None,
empty_ok=True,
label='SMARTS:',
indicator=True,
indicator_state=INDICATOR_VALID,
indicator_tips=True,
canvas_api=True,
**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)
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
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()
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
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
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
def startedEditingSMARTS(self):
"""
React to the SMARTS pattern changing but not yet finished
"""
self.setIndicator(INDICATOR_INTERMEDIATE)
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()
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
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:
if self.canvas_api:
validation = analyze.validate_smarts_canvas(pattern)
else:
validation = analyze.validate_smarts(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 ""
def setText(self, text):
"""
Overrides the parent method to also evaluate the new SMARTS string
"""
SLabeledEdit.setText(self, text)
self.doneEditingSMARTS(force=True)
def clear(self):
"""
Overrides the parent method to also evaluate the new SMARTS string
"""
SLabeledEdit.clear(self)
self.doneEditingSMARTS(force=True)
class ColorWidget(SFrame):
"""
A QtWidgets.QFrame to manage a color widget.
"""
color_changed = QtCore.pyqtSignal(tuple)
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)
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
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)
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)
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)
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()
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.
"""
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)
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}
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):
return self.getValue() < other.getValue()
return super().__lt__(other)
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
def copy(self):
"""
Return copy of curent item with same flags and text as the current one.
:rtype: STableWidgetItem
:return: copy of current item
"""
item = STableWidgetItem(
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
class STableWidget(QtWidgets.QTableWidget):
"""
A better table widget
"""
ROW_DATA_COL = 0
SORT_ASC = 'ascending'
def __init__(self,
*arg,
columns=None,
column_names=None,
table_data=None,
row_data_names=None,
sort_column=None,
sort_style=SORT_ASC,
widget_columns=None,
select_row=False,
resize_header=False,
layout=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: `collections.OrderedDict`
: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.
: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 widget_columns: list
:param widget_columns: list containing names of columns containing
widget instead of items.
:type select_row: bool
:param select_row: enable selection of rows rather than individual items
:type resize_header: bool
:param resize_header: Resize header to fill the available space.
The size cannot be changed by the user or programmatically.
:type layout: QLayout
:param layout: If supplied, the STableWidget created will be added to
the layout.
"""
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 = OrderedDict()
for column_name in column_names:
self.columns[column_name] = STableWidgetItem()
self.setColumnCount(len(self.columns))
self.setHorizontalHeaderLabels(list(self.columns.keys()))
# Set widget column
if widget_columns:
self.widget_columns = [
index for index, x in enumerate(self.columns.keys())
if x in list(widget_columns)
]
else:
self.widget_columns = []
if (self.ROW_DATA_COL in self.widget_columns) and row_data_names:
raise TypeError('Widget cannot be placed in column containing '
'row data.')
# Add sorting conditions
super().setSortingEnabled(False)
self.setSortingEnabled(not sort_column is None, sort_column, sort_style)
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
if resize_header:
self.horizontalHeader().setStretchLastSection(True)
self.horizontalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.Stretch)
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
if not row_data:
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
item = self.item(row_num, self.ROW_DATA_COL)
current_data = item.data(self.row_data_names[column_name])
elif col_num in self.widget_columns:
current_data = self.cellWidget(row_num, col_num)
else:
current_data = self.item(row_num, col_num).getValue()
data.append(current_data)
return data
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)
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()))
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()
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
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()]
def addRow(self, data, return_sorted=True):
"""
Add a row to the table.
:type data: list
: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.
:type return_sorted: bool
:param return_sorted: return sorted table
"""
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
if len(data) != total_cols:
raise IndexError('Number of elements in row are not equal to '
' the number of columns and row_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 row data
col_data = data[:self.columnCount()]
row_data = data[self.columnCount():]
data_items = zip(col_data, self.columns.values())
for col_index, (data, base_items) in enumerate(data_items):
if col_index in self.widget_columns:
self.setCellWidget(row_index, col_index, data)
else:
item = base_items.copy()
item.setText(str(data))
# Add row data as attribute of ROW_DATA_COL column
if col_index == self.ROW_DATA_COL:
for role, value in zip(self.row_data_names.values(),
row_data):
item.setData(role, value)
self.setItem(row_index, col_index, item)
# 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()
def getIndex(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)
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)
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()
def deleteAllRows(self):
"""
Delete all rows in the table.
"""
self.setRowCount(0)
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)
def reset(self):
"""
Reset the table by deleting all the rows.
"""
self.deleteAllRows()
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)
class ProjectTableButton(QtWidgets.QToolButton):
"""
A button with a project table logo. When clicked, it will open the project
table panel.
"""
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)
def launchProjectTable(self):
import schrodinger
maestro = schrodinger.get_maestro()
if maestro:
maestro.command("showpanel table")
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)
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)
def onPrevBtnClicked(self):
self.prevMatchRequested.emit(self.text_le.text())
def onNextBtnClicked(self):
self.nextMatchRequested.emit(self.text_le.text())
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
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)
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()
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
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)
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)
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()
def getRange(self):
return self.min_range, self.max_range
def setValues(self, lpos, rpos):
self.slider.leftPos(lpos)
self.slider.rightPos(rpos)
self._slidersMoved()
def getValues(self):
min_v = self.min_sb.value()
max_v = self.max_sb.value()
return min_v, max_v
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()
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)
class EditWithFocusOutEvent(SLabeledEdit):
"""
SLabeledEdit that calls 'focus_out_command' on returnPressed() and on
'focusOutEvent' with modified text.
"""
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)
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)
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.
"""
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)
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)
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)
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()
def reset(self):
"""
Reset the value of the slider
"""
self.setValue(self.default_value)
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))
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)
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()
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))
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)
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))
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))
class STabWidget(QtWidgets.QTabWidget):
"""
A QTabWidget with additional convenience methods
"""
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())
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
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)
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)
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))
def currentLabel(self):
"""
Get the label of the current tab
:rtype: str
:return: The label of the current tab
"""
return self.tabText(self.currentIndex())
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
"""
def __init__(self,
parent=None,
placeholder="Please select an option...",
**kwargs):
super().__init__(parent, **kwargs)
self._placeholder_text = placeholder
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)
class StyleMixin:
"""
In order to style custom QWidget subclasses using stylesheet, this custom
paintEvent method must be implemented.
"""
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)
class SRetainableMenu(QtWidgets.QMenu):
"""
A menu that doesn't get closed after selecting an item
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.installEventFilter(self)
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)
class SSplitter(QtWidgets.QSplitter):
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)