"""
Contains modifications of Qt widgets to allow for easier creation and use, and
also general functions for dealing with Qt widgets.
"""
# Copyright Schrodinger, LLC. All rights reserved.
import enum
import os
import sys
from collections import OrderedDict
from collections import namedtuple
from html.parser import HTMLParser
import numpy
import pyhelp
from schrodinger import adapter
from schrodinger.infra import canvas2d
from schrodinger.infra import mm
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.structutils import analyze
from schrodinger.structutils import color
from schrodinger.test import ioredirect
from schrodinger.ui.qt import icons
from schrodinger.ui.qt.standard.icons import icons as std_icons
from schrodinger.utils import fileutils
ANGSTROM = u'\u00C5'
# The little circle symbol for degrees
DEGREES = u'\u00B0'
# Greek letters
GL_epsilon = u'\u03B5'
GL_Gamma = u'\u0393'
GL_gamma = u'\u03B3'
GL_eta = u'\u03B7'
GL_mu = u'\u03BC'
GL_sigma = u'\u03C3'
GL_theta = u'\u0398'
GL_alpha = u'\u03B1'
GL_beta = u'\u03B2'
GL_lambda = u'\u03BB'
GL_rho = u'\u03C1'
GL_tau = u'\u03C4'
GL_UPPER_PSI = u'\u03A8'
GL_UPPER_PHI = u'\u03A6'
GL_UPPER_DELTA = u'\u0394'
GL_PLUS_MINUS = u'\u00B1'
# superscripts
SUPER_SQUARED = u'\u00B2'
SUPER_CUBED = u'\u00B3'
SUPER_EIGHT = u'\u2078'
SUPER_MINUS1 = u'\u207B\u00b9'
SUPER_MINUS2 = u'\u207B\u00b2'
SUP_TEXT = lambda x: '<sup>%s</sup>' % x
# subscripts
SUB_ZERO = u'\u2080'
SUB_ONE = u'\u2081'
SUB_TWO = u'\u2082'
SUB_THREE = u'\u2083'
SUB_FOUR = u'\u2084'
SUB_FIVE = u'\u2085'
SUB_SIX = u'\u2086'
SUB_SEVEN = u'\u2087'
SUB_EIGHT = u'\u2088'
SUB_NINE = u'\u2089'
SUB_DIGITS = {
0: SUB_ZERO,
1: SUB_ONE,
2: SUB_TWO,
3: SUB_THREE,
4: SUB_FOUR,
5: SUB_FIVE,
6: SUB_SIX,
7: SUB_SEVEN,
8: SUB_EIGHT,
9: SUB_NINE
}
SUB_TEXT = lambda x: '<sub>%s</sub>' % x
RIGHT_ARROW = chr(10132)
TRIPLE_BOND = u'\u2261'
# Hyphen that prevents line-break between both words (MATSCI-11246)
NON_BREAKING_HYPHEN = '‑'
STANDARD_INDENT = 18
# Paths with backslashes don't work in QSS and need to be converted
UI_QT_DIR = os.path.dirname(os.path.abspath(__file__)).replace("\\", "/")
CLEAR_BUTTON_FILE = os.path.join(UI_QT_DIR, "clearbutton.png")
SEARCH_ICON_FILE = os.path.join(UI_QT_DIR, "search_icon.png")
SPINNER_ICON_BASE = ":/schrodinger/ui/qt/icons_dir/spinner/"
VERTICAL = 'vertical'
HORIZONTAL = 'horizontal'
GRID = 'grid'
INDICATOR_VALID = 0
INDICATOR_INTERMEDIATE = 1
INDICATOR_INVALID = 2
# this is blue
DEFAULT_COLOR = (0, 0, 255)
PIXMAP_SIZE = 16
TOOLTIP_HEIGHT = 150
TOOLTIP_WIDTH = 200
CALLBACK_ROLE = Qt.UserRole + 99
# widget type is QWidget
SubWidget = namedtuple('SubWidget', ['label', 'widget', 'layout_type'],
defaults=[HORIZONTAL])
QTOOLBUTTON_SIZE = """QToolButton {
width: 18px;
height: 18px;
border: none;
padding: 1px;
margin-left: 4px;
}"""
[docs]class SLabel(QtWidgets.QLabel):
"""
A QLabel that can pack itself into a layout upon creation
"""
[docs] def __init__(self, *args, layout=None, tip=None, **kwargs):
"""
Create a SLabel instance
:param `QLayout` layout: layout to place the label in
:param str tip: The tooltip for this label
"""
super().__init__(*args, **kwargs)
if layout is not None:
layout.addWidget(self)
if tip:
self.setToolTip(tip)
try:
self.default_text = str(self.text())
except UnicodeEncodeError:
self.default_text = self.text()
[docs] def reset(self):
"""
Reset the label to its original text
"""
self.setText(self.default_text)
[docs]class ElidedLabel(QtWidgets.QLabel):
"""
Just like a QLabel, except that the text is truncated at the end if it
doesn't fit. Full text is shown in the tooltip.
"""
[docs] def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setSizePolicy(QtWidgets.QSizePolicy.Minimum,
QtWidgets.QSizePolicy.Preferred)
self.setMinimumSize(5, self.minimumSize().height())
self._text = self.text()
self._updateElidedText()
[docs] def setText(self, text):
self._text = text
self.setToolTip(text)
self._updateElidedText()
[docs] def resizeEvent(self, event):
self._updateElidedText()
def _updateElidedText(self):
"""
Adjust the text of the QLabel according to the current widget size.
Calculates the width without the html identifiers, and adds the elided text
back into the html formatted text.
"""
font = self.font()
font.setBold(True)
font_metrics = QtGui.QFontMetrics(font)
super().setText(elide_rich_text(self._text, font_metrics, self.width()))
[docs]def elide_rich_text(rich_text: str,
font_metrics: QtGui.QFontMetrics,
width: int,
elide_type=Qt.ElideRight):
"""
Using the given width and font metrics, generate the elided text and fit it
into the parsed html.
:param rich_text: the source rich text that needs to be elided
:param font_metrics: the metrics to calculate the horizontal advance
:param width: maximum width before cutting off the html with ellipsis
:param elide_type: where to place the ellipsis
:return: new formatted html string to display
"""
parser = ElidedTextHTMLParser()
parser.feed(rich_text)
return parser.elideRichText(font_metrics, width, elide_type)
[docs]class ElidedTextHTMLParser(HTMLParser):
"""
HTML Parser used for parsing rich text and then eliding it
given a width and font metrics.
This parser should only be fed single line html.
"""
[docs] class HTMLType(enum.Enum):
START_TAG = 1
END_TAG = 2
TEXT = 3
[docs] def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._elided_list = []
[docs] def handle_starttag(self, tag, attrs):
attrs_str = ' '.join([f'{name}="{value}"' for name, value in attrs])
tag_str = f'{tag}'
if attrs_str:
tag_str += f' {attrs_str}'
self._elided_list.append((self.HTMLType.START_TAG, tag_str))
[docs] def handle_endtag(self, tag):
self._elided_list.append((self.HTMLType.END_TAG, tag))
[docs] def handle_data(self, data):
self._elided_list.append((self.HTMLType.TEXT, data))
[docs] def elideRichText(self,
font_metrics: QtGui.QFontMetrics,
width: int,
elide_type=Qt.ElideRight):
"""
Elides the rich text inside the html.
See documentation for `elide_rich_text`.
"""
text_list = [
data for (html_type, data) in self._elided_list
if html_type is self.HTMLType.TEXT
]
raw_text = ''.join(text_list)
elided_text = font_metrics.elidedText(raw_text, elide_type, width)
elided_str_list = []
text_pointer = 0
for html_type, data in self._elided_list:
if html_type is self.HTMLType.START_TAG:
elided_str_list.append(f'<{data}>')
elif html_type is self.HTMLType.END_TAG:
elided_str_list.append(f'</{data}>')
elif html_type is self.HTMLType.TEXT:
# if the current text data contains the ellipsis
if text_pointer + len(data) >= len(
elided_text) - 1 and text_pointer < len(elided_text):
# add the text data up to the ellipsis, include it
elided_str_list.append(elided_text[text_pointer:])
text_pointer = len(elided_text)
elif text_pointer < len(elided_text):
# add the text data to the list
elided_str_list.append(data)
text_pointer += len(data)
# Qt does not offset the padding for ending italics
# manually adding a space, see [QTBUG-53502]
for html_type, data in reversed(self._elided_list):
if html_type is self.HTMLType.END_TAG:
if data == 'i':
elided_str_list.append(' ')
break
else:
break
return ''.join(elided_str_list)
[docs]class ImageLabel(QtWidgets.QLabel):
"""
A label that draws an image. High resolution images will be scaled down
to the specified size.
Works as expected on high resolution (e.g. Retina) displays.
"""
[docs] def __init__(self, *args, **kwargs):
"""
Create a label. Optionally specify the image by passing in image,
width, and height arguments.
:param image: Image to draw in the label.
:type image: QImage
:param width: Width to scale down the image to.
:type width: int or float
:param height: Height to scale down the image to.
:type height: int or float
"""
self._image = kwargs.pop('image', None)
self._width = kwargs.pop('width', None)
self._height = kwargs.pop('height', None)
image_args = (self._image, self._width, self._height)
if any(image_args) and not all(image_args):
raise ValueError('Must specify image, width, and height options.')
super().__init__(*args, **kwargs)
[docs] def setImage(self, image, width, height):
"""
Set the image to draw, and the dimensions to scale it down to.
:param image: Image to draw in the label.
:type image: QImage
:param width: Width to scale down the image to.
:type width: int or float
:param height: Height to scale down the image to.
:type height: int or float
"""
if image is not None and not isinstance(image, QtGui.QImage):
raise TypeError(f"image must be a QImage, not {type(image)}")
self._image = image
self._width = width
self._height = height
[docs] def setText(self, text, *args, **kwargs):
# Clear image before setting text
self._image = self._width = self._height = None
super().setText(text, *args, **kwargs)
[docs] def paintEvent(self, event):
"""
If an image is set, paint it in the widget. Otherwise, use default
paintEvent.
"""
if not self._image:
super().paintEvent(event)
return
target = QtCore.QRectF(0, 0, self._width, self._height)
painter = QtGui.QPainter(self)
painter.drawImage(target, self._image)
painter.end()
[docs] def sizeHint(self):
"""
If an image is set, force the label to be the size of the image.
"""
if not self._image:
return super().sizeHint()
return QtCore.QSize(self._height, self._width)
[docs] def sizePolicy(self):
"""
If an image is set, force the label to be fixed size.
"""
if not self._image:
return super().sizePolicy()
QSP = QtWidgets.QSizePolicy
return QSP(QSP.Fixed, QSP.Fixed)
[docs]class ResizablePictureLabel(QtWidgets.QLabel):
"""
Label that automatically resizes its QPicture as the widget is resized.
To keep the aspect ratio, padding is added to top&bottom or left&right
sides as needed.
To set a background color, use the "background-color" style sheet.
"""
[docs] def paintEvent(self, event):
pic = self.picture()
if pic is None:
super().paintEvent(event)
return
cr = self.contentsRect()
painter = QtGui.QPainter(self)
draw_picture_into_rect(painter, pic, cr)
painter.end()
[docs]class Spinner(QtWidgets.QLabel):
"""
A QLabel that shows a rotating spinner icon.
"""
[docs] def __init__(self, *args, **kwargs):
"""
Create a Spinner instance.
"""
QtWidgets.QLabel.__init__(self, *args, **kwargs)
self.setAlignment(Qt.AlignCenter)
self.current_icon = 0
# TODO this icon doesn't render in high resolution on Retina displays
self.icons = [
QtGui.QPixmap(SPINNER_ICON_BASE + '%i.png' % num)
for num in range(1, 9)
]
self.standard_icon = QtGui.QPixmap(SPINNER_ICON_BASE + '0.png')
self.setPixmap(self.standard_icon)
self.timer = QtCore.QTimer()
self.timer.timeout.connect(self.rotateIcon)
[docs] def startAnimation(self):
"""
Show an animated spinner icon.
"""
if not self.timer.isActive():
self.timer.start(100)
[docs] def stopAnimation(self):
"""
Stop animation of the spinner icon.
"""
if self.timer.isActive():
self.timer.stop()
self.setPixmap(self.standard_icon)
[docs] def rotateIcon(self):
self.current_icon += 1
if self.current_icon == len(self.icons):
self.current_icon = 0
self.setPixmap(self.icons[self.current_icon])
[docs]class SpinnerIcon(QtGui.QMovie):
"""
Animates a "loading" icon that can be used by any Qt object that
has the `setIcon` method.
Example:
def startTask(self):
self.button = QtWidgets.QPushButton('Button')
self.loading_icon = bwidgets.SpinnerIcon(self)
self.loading_icon.startSpinner(self.button)
self.launchTask()
def taskCallback(self):
self.loading_icon.stopSpinner()
"""
GIF_FNAME = 'blue-loading.gif'
[docs] def __init__(self, parent=None):
super(SpinnerIcon, self).__init__(parent)
# Get the path to the blue spinner
gif_path = os.path.join(os.path.dirname(__file__), 'icons_dir',
self.GIF_FNAME)
self.setFileName(os.path.abspath(gif_path))
self.setCacheMode(QtGui.QMovie.CacheAll)
self.setSpeed(100)
self.frameChanged.connect(self.cycle)
self.qt_obj = None
[docs] def startSpinner(self, qt_obj):
"""
Set the `qt_obj`'s icon to a loading icon.
:param qt_obj: Object to set icon for. Must have `setIcon` method.
"""
if not hasattr(qt_obj, 'setIcon'):
raise AttributeError('No setIcon method for %s' % qt_obj)
# Grab the starting icon to change back to it after
self.starting_icon = qt_obj.icon()
self.qt_obj = qt_obj
self.start()
[docs] def cycle(self):
"""
The callback for the `frameChanged` signal. This will grab the
pixmap of the current frame and create an icon from it. It then
sets the `self.qt_obj`'s icon to this icon.
"""
pix = self.currentPixmap()
icon = QtGui.QIcon(pix)
self.qt_obj.setIcon(icon)
[docs] def stopSpinner(self):
"""
Removes the loading icon and sets the object's icon back to
the original icon
"""
if self.qt_obj:
self.qt_obj.setIcon(self.starting_icon)
self.qt_obj = None
self.stop()
class _LargeSpinnerIcon(SpinnerIcon):
"""
A version of SpinnerIcon with a large spinner.
"""
GIF_FNAME = 'loading-transparent.gif'
[docs]class LargeSpinner(QtWidgets.QLabel):
"""
A QLabel that shows a large rotating spinner icon.
"""
[docs] def __init__(self, *args, **kwargs):
QtWidgets.QLabel.__init__(self, *args, **kwargs)
self.setAlignment(Qt.AlignCenter)
self.loading_icon = _LargeSpinnerIcon(self)
self.setMinimumSize(0, 66)
[docs] def startAnimation(self):
"""
Start the spinner
"""
self.loading_icon.startSpinner(self)
[docs] def stopAnimation(self):
"""
Stop the spinner
"""
self.loading_icon.stopSpinner()
[docs] def setIcon(self, icon):
"""
For compatibility with the SpinnerIcon class.
:param icon: Icon to be set
:type icon: QtGui.QIcon or QtGui.QMovie
"""
if isinstance(icon, QtGui.QMovie):
self.setMovie(icon)
elif isinstance(icon, QtGui.QIcon):
self.setPixmap(icon.pixmap(self.width(), self.height()))
[docs] def icon(self):
"""
For compatibility with the SpinnerIcon class.
"""
return self.loading_icon
[docs]class SValidIndicator(SLabel, object):
"""
An SLabel that easily changes between a valid icon and an invalid icon
"""
[docs] def __init__(self, state=INDICATOR_VALID, layout=None, tips=None):
"""
Create an SValidIndicator instance
:type state: int
:param state: The initial state of the indicator. Should be one of the
module constants INDICATOR_VALID, INDICATOR_INTERMEDIATE,
INDICATOR_INVALID
:type layout: `QtWidgets.QBoxLayout`
:param layout: The layout to place the SValidIndicator in
:type tips: dict
:param tips: The tooltips for the indicator in its various states. Keys
should be the possible states (INDICATOR_VALID, etc.) and values should
be the corresponding tooltip. Tooltips are optional and may also be
provided directly by setState. If no tooltips are provided in either
place, the icon will have no tooltip.
"""
SLabel.__init__(self, layout=layout)
self.createPixmaps()
self._default_state = state
self.tips = tips
self.setState(state)
self.setFocusPolicy(QtCore.Qt.NoFocus)
def __bool__(self):
"""
Allow the True/False check of the indicator to indicate a valid (True)
or intermediate/invalid (False) state
:rtype: bool
:return: Whether the indicator is in a valid state or not
"""
return not self._state
[docs] def createPixmaps(self):
"""
Create the pixmaps for the indicator states.
"""
ok_pixmap, warn_pixmap, crit_pixmap = icons.get_validation_pixmaps()
self.icons = {
INDICATOR_VALID: ok_pixmap,
INDICATOR_INTERMEDIATE: warn_pixmap,
INDICATOR_INVALID: crit_pixmap
}
[docs] def setState(self, state, tip=None):
"""
Set the state of the indicator
:type state: int
:param state: The new state of the indicator. Should be one of the
module constants INDICATOR_VALID, INDICATOR_INTERMEDIATE,
INDICATOR_INVALID
:type tip: str
:param tip: The new tooltip for the indicator. If not provided, the
tooltips, if any, provided to the constructor will be used.
:raise RuntimeError: If no icon corresponds to state
"""
self._state = state
try:
icon = self.icons[state]
except KeyError:
raise RuntimeError('%s is an invalid state for the indicator. No '
'corresponding icon exists.')
self.setPixmap(icon)
if not tip:
try:
tip = self.tips[state]
except (TypeError, KeyError):
# No tooltip exists for this state
pass
if tip:
self.setToolTip(tip)
[docs] def getState(self):
"""
Get the state of the indicator
:rtype: int
:return: The state of the indicator. Will be one of the module constants
INDICATOR_VALID, INDICATOR_INTERMEDIATE, INDICATOR_INVALID
"""
return self._state
[docs] def reset(self):
"""
Set the indicator to its initial state
"""
self.setState(self._default_state)
[docs]class VerticalLabel(SLabel):
"""
This creates a vertical label with property size, position,
and enabled/disabled states.
"""
[docs] def __init__(self, *args, **kwargs):
"""
Create a label and set default state.
"""
super(VerticalLabel, self).__init__(*args, **kwargs)
self.state = True
[docs] def paintEvent(self, event):
"""
Overwrite parent method. Create a painter with drawn text
and tunable colors.
See parent class for additional documentation.
"""
painter = QtGui.QPainter(self)
painter.setPen(QtCore.Qt.black if (
self.state and self.parent().isEnabled()) else QtCore.Qt.gray)
# FIXME remove these hard-coded values:
painter.translate(10, 115)
painter.rotate(-90)
painter.drawText(0, 0, self.default_text)
painter.end()
[docs] def setEnabled(self, state):
"""
Enable/disable the label by changing the color.
:type state: bool
:param state: True, if the label widget is enabled.
"""
self.state = state
self.repaint()
[docs] def minimumSizeHint(self):
"""
Overwrite parent method.
"""
# FIXME use the height & width of the drawn text
size = SLabel.minimumSizeHint(self)
return QtCore.QSize(size.height(), size.width())
[docs] def sizeHint(self):
"""
Overwrite parent method.
"""
size = SLabel.sizeHint(self)
return QtCore.QSize(size.height(), size.width())
[docs]class SLineEdit(QtWidgets.QLineEdit):
"""
A QLineEdit that accepts a validator as one of its arguments
"""
AV_ERROR_MSG = ('always_valid must be used in conjuction with a '
'QValidator that has a restoreLastValidValue method.')
[docs] def __init__(self, *args, **kwargs):
"""
Create a SLineEdit instance. The initial text of this edit will be used
by the reset method to reset the SLineEdit.
:type validator: QValidator
:keyword validator: The validator used for this LineEdit
:type layout: QLayout
:keyword layout: layout to place the LineEdit in
:type width: int
:keyword width: limit on the maximum width of the QLineEdit
:param int min_width: limit on the minimum width of the QLineEdit
:type read_only: bool
:keyword read_only: Whether the edit is read only
:type always_valid: bool
:keyword always_valid: If True, the SLineEdit will always restore the
last valid value when it loses focus. This prevents blank SLineEdits or
SLineEdits with only partially valid values at run time. If False
(default), no modification of the SLineEdit value will occur when it
loses focus.
:param show_clear: Whether to show a clear button in the line edit
:type show_clear: bool
:param show_search_icon: Whether to show a magnifying glass icon.
:type show_search_icon: bool
:type placeholder_text: str
:param placeholder_text: The placeholder text for the edit. This is the
text that shows up when the line edit is empty and gives a hint to the
user of what to enter.
"""
QtWidgets.QLineEdit.__init__(self, *args)
validator = kwargs.pop('validator', None)
if validator:
self.setValidator(validator)
width = kwargs.pop('width', None)
min_width = kwargs.pop('min_width', None)
if min_width:
self.setMinimumWidth(min_width)
if width:
self.setMaximumWidth(width)
layout = kwargs.pop('layout', None)
if layout is not None:
layout.addWidget(self)
self.always_valid = kwargs.pop('always_valid', False)
if self.always_valid:
self.setToAlwaysValid()
self.clear_btn = QtWidgets.QToolButton(self)
show_clear = kwargs.pop('show_clear', False)
self.setClearButton(show_clear)
show_search = kwargs.pop('show_search_icon', False)
if show_search:
self.setStyleSheet('''
background-image: url('%s');
background-repeat: no-repeat;
background-position: left;
padding-left: 17px;
border: 1px solid #D3D3D3;
height: 20px;
''' % SEARCH_ICON_FILE)
ptext = kwargs.pop('placeholder_text', None)
if ptext:
self.setPlaceholderText(ptext)
read_only = kwargs.pop('read_only', False)
self.setReadOnly(read_only)
tip = kwargs.pop('tip', None)
if tip:
self.setToolTip(tip)
if kwargs:
raise TypeError('Unrecognized keyword arguments: ' +
str(list(kwargs)))
self.default_text = str(self.text())
[docs] def setText(self, text, only_if_valid=False):
"""
Set the text of the edit.
:param str text: The text to set in the edit
:param bool only_if_valid: Only set the text if it validates as
acceptable via the edit's validator.
"""
text = str(text)
if self.always_valid or only_if_valid:
dator = self.validator()
if dator:
# Calling validate will store this value as the last_valid_value
# for always_valid cases (if it is valid). The '0' in the call
# below is a fake cursor position necessary for the validate
# API.
result = dator.validate(text, 0)
if only_if_valid and result[0] != dator.Acceptable:
return
super().setText(text)
[docs] def setToAlwaysValid(self):
"""
Set this instance to always replace invalid text with
valid text per validation performed by self.validator
NOTE: The original value is never validated, so we need to set the
validator last_valid_value property by hand. Note that forcing a
validation here doesn't work - appears to be a PyQt bug (or
feature) where validation doesn't work inside the __init__method.
store it as the last valid value.
"""
self.always_valid = True
validator = self.validator()
if (not validator or not hasattr(validator, 'restoreLastValidValue')):
raise RuntimeError(self.AV_ERROR_MSG)
if self.text():
validator.last_valid_value = self.text()
self.installEventFilter(self)
[docs] def resizeEvent(self, resize_event):
""" This is used to place the clear text button """
if self.show_clear:
sz = self.clear_btn.sizeHint()
framewidth = self.style().pixelMetric(
QtWidgets.QStyle.PM_DefaultFrameWidth)
self.clear_btn.move(self.rect().right() - framewidth - sz.width(),
(self.rect().bottom() + 1 - sz.height()) / 2)
[docs] def reset(self):
"""
Reset the LineEdit to its original text
"""
self.setText(self.default_text)
[docs] def float(self):
"""
Returns the current value of the LineEdit as a float
:rtype: float
:return: Current float value of the LineEdit
"""
return float(self.text())
[docs] def int(self):
"""
Returns the current value of the LineEdit as a float
:rtype: int
:return: Current int value of the LineEdit
"""
return int(self.text())
[docs] def getString(self):
"""
Returns the current value of the LineEdit as a string
:rtype: str
:return: The current string value of the LineEdit
"""
return str(self.text())
[docs] def eventFilter(self, edit, event):
"""
Filter FocusOut events so we can restore the last valid value
Note: This could have been done in the event() method rather than
eventFilter() method, but doing it this way allows us to NOT process
events for line edits that don't always_valid validity, which saves on
overhead.
:type edit: QWidget
:param edit: The widget that generated this event - will be this
instance (self)
:type event: QEvent
:param event: The QEvent object generated by the event
:rtype: bool
:return: Whether event processing should stop for this event
"""
if not self.always_valid:
return False
if event.type() == event.FocusOut:
validator = self.validator()
if validator:
state = validator.validate(self.text(), 0)[0]
if state != validator.Acceptable:
try:
validator.restoreLastValidValue(self)
except AttributeError:
raise AttributeError(self.AV_ERROR_MSG)
return QtWidgets.QLineEdit.eventFilter(self, edit, event)
[docs]class SLabeledEdit(SLineEdit):
"""
An SLineEdit that has a label attached to it.
A SLabledEdit instance has a .label property that is an SLabel widget.
The SLabeledEdit instance also has a .mylayout property that the label and
edit are placed into. This .mylayout should be added to another layout in
order to place the widgets into the GUI if the layout= keyword is not used
during construction.
"""
[docs] def __init__(self, text, **kwargs):
"""
Create a SLabeledEdit instance. The initial text of this edit and labels
will be used by the reset method to reset the SLineEdit and labels.
:type text: str
:keyword text: The text of the label
:type edit_text: str
:keyword edit_text: The initial text of the LineEdit
:type side: str
:keyword side: 'left' if the label should appear to the left of the Line
Edit (defaul), or 'top' if the label should appear above it
:type validator: QValidator
:keyword validator: The validator used for this LineEdit
:type stretch: bool
:keyword stretch: Whether to put a stretch after the LineEdit (or after
the after_label). Default is True, even if side='top'.
:type after_label: str
:keyword after_label: Label text to put after the LineEdit - default is
None
:type width: int
:keyword width: limit on the maximum width of the QLineEdit
:param int min_width: limit on the minimum width of the QLineEdit
:type layout: QLayout
:keyword layout: layout to place the LabeledEdit in
:type always_valid: bool
:keyword always_valid: If True, the SLineEdit will always restore the
last valid value when it loses focus. This prevents blank SLineEdits or
SLineEdits with only partially valid values at run time. If False
(default), no modification of the SLineEdit value will occur when it
loses focus.
:param show_clear: Whether to show a clear button in the line edit
:type show_clear: bool
:type tip: str
:param tip: The tooltip to apply to the labels and edit
:type indicator: bool
:param indicator: True if a valid/invalid indicator should be appended
:type indicator_state: int
:param indicator_state: The default state of the indicator
(INDICATOR_VALID, INDICATOR_INVALID or INDICATOR_INTERMEDIATE)
:type indicator_tips: dict
:param indicator_tips: The tooltips for the different states of the
indicator - see the SValidIndicator class for more information
:type placeholder_text: str
:param placeholder_text: The placeholder text for the edit. This is the
text that shows up when the line edit is empty and gives a hint to the
user of what to enter.
"""
# Set up the layout for this widget
side = kwargs.pop('side', 'left')
if side == 'left':
self.mylayout = SHBoxLayout()
else:
self.mylayout = SVBoxLayout()
parent_layout = kwargs.pop('layout', None)
if parent_layout is not None:
parent_layout.addLayout(self.mylayout)
self.label = SLabel(text, layout=self.mylayout)
# Pull out any other non-SLineEdit keywords
after = kwargs.pop('after_label', "")
stretch = kwargs.pop('stretch', True)
tip = kwargs.pop('tip', None)
indicator = kwargs.pop('indicator', False)
indicator_state = kwargs.pop('indicator_state', True)
indicator_tips = kwargs.pop('indicator_tips', None)
edit_text = kwargs.pop('edit_text', "")
SLineEdit.__init__(self, edit_text, layout=self.mylayout, **kwargs)
# Fill in the rest of the layout
if after:
self.after_label = SLabel(after, layout=self.mylayout)
if indicator:
self.indicator = SValidIndicator(state=indicator_state,
layout=self.mylayout,
tips=indicator_tips)
if stretch:
self.mylayout.addStretch()
if tip:
self.label.setToolTip(tip)
self.setToolTip(tip)
if after:
self.after_label.setToolTip(tip)
[docs] def reset(self):
"""
Reset the label and line edit to their default values
"""
self.label.reset()
SLineEdit.reset(self)
try:
self.after_label.reset()
except AttributeError:
pass
try:
self.indicator.reset()
except AttributeError:
pass
[docs] def setEnabled(self, state):
"""
Set all child widgets to enabled state of state
:type state: bool
:param state: True if widgets should be enabled, False if not
"""
SLineEdit.setEnabled(self, state)
self.label.setEnabled(state)
try:
self.after_label.setEnabled(state)
except AttributeError:
pass
try:
self.indicator.setEnabled(state)
except AttributeError:
pass
[docs] def setVisible(self, state):
"""
Set all child widgets to visible state of state
:type state: bool
:param state: True if widgets should be visible, False if not
"""
SLineEdit.setVisible(self, state)
self.label.setVisible(state)
try:
self.after_label.setVisible(state)
except AttributeError:
pass
try:
self.indicator.setVisible(state)
except AttributeError:
pass
[docs] def setIndicator(self, state, tip=None):
"""
Set the state of the indicator icon
:type state: int
:param state: The new state of the indicator. Should be one of the
module constants INDICATOR_VALID, INDICATOR_INTERMEDIATE,
INDICATOR_INVALID
:type tip: str
:param tip: The new tooltip for the indicator icon - if not provided,
the tooltips, if any, given to the constructor will be used.
"""
try:
self.indicator.setState(state, tip=tip)
except AttributeError:
raise RuntimeError('Cannot set the state of an indicator that '
'does not exist')
[docs]class SCheckBox(QtWidgets.QCheckBox):
"""
A QCheckBox that accepts the command callback for the clicked() signal as
an argument
"""
[docs] def __init__(self, *args, **kwargs):
"""
Accepts all arguments normally given for a QCheckBox
:type command: function
:keyword command: function to call when the checkbox emits a 'clicked()'
signal
:type layout: QLayout
:keyword layout: layout to place the CheckBox in
:type checked: bool
:keyword checked: True if checked to start with, False (default) if not.
This is also the state the reset method restores.
:type disabled_checkstate: bool
:keyword disabled_checkstate: The check state the box should assume
when it is disabled. Upon being re-enabled, the box will return to
the same state it had prior to being disabled. Calling setChecked()
on a disabled box will not actually change the state, but will
change the state the box returns to upon being enabled. Use
forceSetChecked() to change the current and re-enabled state of a
disabled checkbox. The default of None avoids special behavior for
disabled checkboxes.
:type tip: str
:param tip: The tooltip for the checkbox
"""
self.disabled_checkstate = kwargs.pop('disabled_checkstate', None)
QtWidgets.QCheckBox.__init__(self, *args)
command = kwargs.pop('command', None)
if command:
self.clicked.connect(command)
layout = kwargs.pop('layout', None)
if layout is not None:
layout.addWidget(self)
self.setChecked(kwargs.pop('checked', False))
self.default_state = self.isChecked()
self.reable_checkstate = self.isChecked()
tip = kwargs.pop('tip', None)
if tip:
self.setToolTip(tip)
if kwargs:
raise TypeError('Unrecognized keyword arguments: ' +
str(list(kwargs)))
[docs] def reset(self):
"""
Reset the checkbox to its initial state
"""
self.setChecked(self.default_state)
[docs] def trackAbleState(self):
"""
Check whether we need to track the checkstate of this widget when it is
enabled/disabled
:rtype: bool
:return: Whether we should track the checkstate
"""
return self.disabled_checkstate is not None
[docs] def changeEvent(self, event):
"""
Overrides the parent method to save & change the checkstate when the
widget is enabled/disabled (if requested)
"""
if self.trackAbleState() and event.type() == event.EnabledChange:
if self.isEnabled():
self.setChecked(self.reable_checkstate)
else:
current_state = self.isChecked()
self.forceSetChecked(self.disabled_checkstate)
self.reable_checkstate = current_state
return QtWidgets.QCheckBox.changeEvent(self, event)
[docs] def setChecked(self, state):
"""
Overrides the parent method to not actually change the check state if
the widget is disabled AND we're controlling the checkstate of disabled
widgets. In that case, we only store the desired state for when the
widget is re-enabled.
"""
if self.trackAbleState() and not self.isEnabled():
self.reable_checkstate = state
else:
QtWidgets.QCheckBox.setChecked(self, state)
[docs] def forceSetChecked(self, state):
"""
Change the checkstate of the widget even if it is disabled and we are
controlling the checkstate of disabled widgets. Also sets this state to
be the state when the widget re-enabled
:type state: bool
:param state: Whether the checkbox should be checked
"""
self.reable_checkstate = state
QtWidgets.QCheckBox.setChecked(self, state)
[docs]class SCheckBoxToggle(SCheckBox):
"""
A QCheckBox that accepts the command callback for the toggled() signal as
an argument, in comparison to SCheckBox that has command callback bound to
clicked() signal.
"""
[docs] def __init__(self, *args, nocall=True, **kwargs):
"""
Accepts all arguments normally given for a QCheckBox
:param bool nocall: True if command should not be called during
initialization, False otherwise
:type command: function
:keyword command: function to call when the checkbox emits a 'clicked()'
signal
:type layout: QLayout
:keyword layout: layout to place the CheckBox in
:type checked: bool
:keyword checked: True if checked to start with, False (default) if not.
This is also the state the reset method restores.
:type disabled_checkstate: bool
:keyword disabled_checkstate: The check state the box should assume
when it is disabled. Upon being re-enabled, the box will return to the
same state it had prior to being disabled. Calling setChecked() on a
disabled box will not actually change the state, but will change the
state the box returns to upon being enabled. Use forceSetChecked() to
change the current and re-enabled state of a disabled checkbox. The
default of None avoids special behavior for disabled checkboxes.
:type tip: str
:param tip: The tooltip for the checkbox
"""
command = kwargs.pop('command', None)
super().__init__(*args, **kwargs)
if command:
self.toggled.connect(command)
if not nocall:
self.toggled.emit(self.isChecked())
[docs]class SCheckBoxEdit(SCheckBox):
"""
A combined checkbox and line edit widget where the checkbox controls the
enabled/disabled state of the edit. For use in cases where you want::
[x] some text ______ more text
where [x] is the checkbox and ______ is the line edit and the line edit
should be disabled when the checkbox is unchecked.
The parent widget of this widget is the SCheckBox. The widget also contains
an SLabeledEdit in the self.edit property and the layout containing both in
the self.mylayout property. You can place the widget directly in a layout at
creation using the layout keyword argument, or later but adding the
.mylayout property to an existing layout.
Attribute requests will be fulfilled by the SCheckBox class first, and then
the SLabeledEdit next. Thus, accessing self.isEnabled() will give the
enabled state of the checkbox. Accessing self.validator() will give the
validator set on the edit. To access an attribute on the edit where the
checkbox has an attribute of the same name, you can always use
self.edit.attribute.
The exceptions to the attribute hierarchy are the text and setText methods.
These give the text and setText methods of the edit, because that is
overwhelmingly going to be the widget of interest for those methods. To
access the checkbox methods, use checkBoxText and setCheckBoxText methods.
The class constructor can be given keywords for either the SCheckBox or
SLabeledEdit class. They will be passed to the proper class.
Note: The first label of the SLabeledEdit is hidden by default, so the
'some text' to the edit in the example above should be set as the checkbox
label.
"""
[docs] def __init__(self, text, *args, **kwargs):
"""
Create a SCheckBoxEdit instance.
Keyword arguments can be taken from the SCheckBox, SLineEdit or
SLabeledEdit classes.
"""
checked = kwargs.pop('checked', False)
command = kwargs.pop('command', None)
dstate = kwargs.pop('disabled_checkstate', None)
layout = kwargs.pop('layout', None)
tip = kwargs.get('tip', None)
self.mylayout = SHBoxLayout(layout=layout)
SCheckBox.__init__(self,
text,
checked=checked,
command=command,
disabled_checkstate=dstate,
layout=self.mylayout,
tip=tip)
kwargs['layout'] = self.mylayout
self.edit = SLabeledEdit("", **kwargs)
self.edit.label.hide()
self.toggled.connect(self._toggleStateChanged)
self._toggleStateChanged(self.isChecked())
def __getattr__(self, attribute):
"""
Pass on any requests for unknown attributes to the line edit. If the
attribute is still unknown, raise an AttributeError for this class
"""
try:
return getattr(self.edit, attribute)
except AttributeError:
raise AttributeError('SCheckBoxEdit has no attribute %s' %
attribute)
[docs] def text(self):
"""
Override the checkbox method to get the text from the edit instead. Use
checkBoxText to get the checkbox text.
:rtype: str
:return: The text contained in the edit
"""
return self.edit.text()
[docs] def setText(self, text):
"""
Override the checkbox method to set the text in the edit instead. Use
setCeckBoxText to get the checkbox text.
:type text: str
:param text: The new text for the edit
"""
self.edit.setText(text)
[docs] def checkBoxText(self):
"""
Get the current text of the checkbox label
:rtype: str
:return: The text of the checkbox label
"""
return SCheckBox.text(self)
[docs] def setCheckBoxText(self, text):
"""
Set the text of the checkbox label
:type text: str
:param text: The new text for the label
"""
SCheckBox.setText(self, text)
[docs] def reset(self):
"""
Reset all the widgets
"""
self.edit.reset()
SCheckBox.reset(self)
def _toggleStateChanged(self, state):
"""
React to the checkbox changing checked state
:type state: bool
:param state: The new checked state of the checkbox
"""
# We don't call setEnabled on the entire LabeledEdit because we don't
# want to enable/disable the text labels
SLineEdit.setEnabled(self.edit, state)
[docs] def setEnabled(self, state):
"""
Set both the checkbox and line edit enabled/disabled
:type state: bool
:param state: The desired enabled state of both the checkbox and edit
"""
self.edit.setEnabled(state)
SCheckBox.setEnabled(self, state)
[docs] def setVisible(self, state):
"""
Set both the checkbox and line edit visible/invisible
:type state: bool
:param state: The desired visible state of both the checkbox and edit
"""
self.edit.setVisible(state)
super().setVisible(state)
[docs] @staticmethod
def getValuesFromStr(str_in):
"""
Helper function for converting from string to bool and text.
:type str_in: str
:param str_in: String describing state of the widget
:rtype: (bool, str)
:return: Bool is True if checkbox is checked, otherwise - False. Str is
edit text value.
"""
is_checked, text = str_in.split('_', 1)
return bool(int(is_checked)), text
[docs] def af2SettingsGetValue(self):
"""
This function adds support for the settings mixin. It allows to
save checked item states and text in case this combo box is included in
a settings panel.
:return: String of the form: '0_text', where first number can be 0 or 1
(for the checkbox), 'text' is where text from edit goes.
:rtype: str
"""
return str(int(self.isChecked())) + '_' + self.text()
[docs] def af2SettingsSetValue(self, str_in):
"""
This function adds support for the settings mixin. It allows to
set combo box check states and edit text when this widget is included
in a settings panel.
:type str_in: str
:param str_in: String of the form: '0_text', where first number can be
0 or 1 (for the checkbox), 'text' is where text from edit goes.
"""
is_checked, text = SCheckBoxEdit.getValuesFromStr(str_in)
self.setChecked(is_checked)
self.setText(text)
[docs]def set_layout_margins(layout, indent=False):
"""
Sets all layout margins to be 0, with the possible exception of indenting
the left margin.
:type layout: QBoxLayout
:param layout: The layout to set the margins on
:type indent: bool or int
:param indent: False for a 0 left margin, True for a STANDARD_INDENT left
margin, or an int for the value of the left margin
"""
if indent is True:
left_indent = STANDARD_INDENT
else:
left_indent = int(indent)
layout.setContentsMargins(left_indent, 0, 0, 0)
[docs]class SHBoxLayout(QtWidgets.QHBoxLayout):
"""
A QHBoxLayout with the proper margins for Schrodinger layouts.
:type indent: bool or int
:keyword indent: False for a 0 left margin, True for a STANDARD_INDENT left
margin, or an int for the value of the left margin - False by default.
:type layout: QBoxLayout
:keyword layout: The layout this layout should be placed in
"""
[docs] def __init__(self, *args, **kwargs):
"""
Accepts all arguments normally given for a QHBoxLayout
"""
QtWidgets.QHBoxLayout.__init__(self, *args)
indent = kwargs.get('indent', False)
set_layout_margins(self, indent=indent)
self.setSpacing(3)
layout = kwargs.get('layout', None)
if layout is not None:
layout.addLayout(self)
[docs]class SVBoxLayout(QtWidgets.QVBoxLayout):
"""
A QVBoxLayout with the proper margins for Schrodinger layouts.
:type indent: bool or int
:keyword indent: False for a 0 left margin, True for a STANDARD_INDENT left
margin, or an int for the value of the left margin - False by default.
:type layout: QBoxLayout
:keyword layout: The layout this layout should be placed in
"""
[docs] def __init__(self, *args, **kwargs):
"""
Accepts all arguments normally given for a QVBoxLayout
"""
QtWidgets.QVBoxLayout.__init__(self, *args)
indent = kwargs.get('indent', False)
set_layout_margins(self, indent=indent)
self.setSpacing(1)
layout = kwargs.get('layout', None)
if layout is not None:
layout.addLayout(self)
[docs]class SGridLayout(QtWidgets.QGridLayout):
"""
A QGridLayout with the proper margins for Schrodinger layouts
:type indent: bool or int
:keyword indent: False for a 0 left margin, True for a STANDARD_INDENT left
margin, or an int for the value of the left margin - False by default.
:type layout: QBoxLayout
:keyword layout: The layout this layout should be placed in
"""
[docs] def __init__(self, *args, **kwargs):
"""
Accepts all arguments normally given for a QGridLayout
"""
QtWidgets.QGridLayout.__init__(self, *args)
indent = kwargs.get('indent', False)
set_layout_margins(self, indent=indent)
self.setHorizontalSpacing(3)
self.setVerticalSpacing(1)
layout = kwargs.get('layout', None)
if layout is not None:
layout.addLayout(self)
[docs]class SGroupBox(QtWidgets.QGroupBox):
"""
A QGroupBox that includes a layout with the proper margins and spacing for
Schrodinger layouts. By default, this is a vertical layout.
"""
[docs] def __init__(self, *args, **kwargs):
"""
Accepts all arguments normally given for a QGroupBox
:type layout: string
:keyword layout: Module constants VERTICAL, HORIZONTAL or GRID, for the
type of internal layout
:type parent_layout: QBoxLayout
:keyword parent_layout: The layout to place this SGroupBox into
:type checkable: bool
:keyword checkable: Whether the groupbox is checkable, False by default
:type checked: bool
:keyword checked: Whether the groupbox should be checked if checkable.
Default is True for checkable groupboxes.
:type tip: str
:param tip: The tooltip to apply to the group box
:type flat: bool
:param flat: whether to apply FlatStyle
:type command: function
:keyword command: function to call when the checkbox emits a 'clicked()'
signal
:type toggled_command: function
:keyword toggled_command: function to call when the checkbox emits a 'toggled()'
signal
"""
QtWidgets.QGroupBox.__init__(self, *args)
layout = kwargs.pop('layout', None)
if not layout or layout == VERTICAL:
self.layout = SVBoxLayout(self)
elif layout == HORIZONTAL:
self.layout = SHBoxLayout(self)
elif layout == GRID:
self.layout = SGridLayout(self)
self.layout.setContentsMargins(3, -1, 3, 3)
parent_layout = kwargs.pop('parent_layout', None)
if parent_layout is not None:
parent_layout.addWidget(self)
checkable = kwargs.pop('checkable', False)
if checkable:
self.setCheckable(checkable)
checked = kwargs.pop('checked', True)
self.setChecked(checked)
self.default_checkstate = checked
command = kwargs.pop('command', None)
if command:
self.clicked.connect(command)
toggled_command = kwargs.pop('toggled_command', None)
if toggled_command:
self.toggled.connect(toggled_command)
tip = kwargs.pop('tip', None)
if tip:
self.setToolTip(tip)
flat = kwargs.pop('flat', False)
self.setFlat(flat)
if kwargs:
raise TypeError('Unrecognized keyword arguments: ' +
str(list(kwargs)))
[docs] def reset(self):
"""
Reset the GroupBox to the default check state if checkable
"""
if self.isCheckable():
self.setChecked(self.default_checkstate)
[docs]class SNoCommaMinMaxMixin:
"""
This is a mixin class to bound value to top and bottom. It validates
the absence of comma in the input. Further, it will validate
whether the value is in between min and max.
Custom Validation classes can inherit from this class first, then the
base Validator class. i.e.
class MyValidator(SNoCommaMinMaxMixin, QtGui.QIntValidator):
"""
[docs] def minMaxValidate(self,
value,
pos,
datatype=float,
bottom=-numpy.inf,
top=numpy.inf):
"""
Method is a validator
:type value: int or float
:param value: any int or float data to be validated
:type pos: int
:param pos: position of the cursor
:type datatype: type
:param datatype: datatype of value, Default is float
:type bottom: int or float
:param bottom: minimum value
:type top: int or float
:param top: maximum value
:rtype: tuple
:return: tuple of QtValidator definition of valid, value and pos.
See PyQt QValidator documentation for definition of valid.
"""
if ',' in value:
return self.Invalid, value, pos
# If bottom > 0, '-' is not acceptable
if value == '-':
if bottom >= 0:
return self.Invalid, value, pos
else:
return self.Intermediate, value, pos
# Handle error in case value entered is not a number
try:
dvalue = datatype(value)
except ValueError:
# MATSCI-9867: Catch values with scientific notation. Any value
# ending with E, E+ and E- returns a intermediate state. However,
# user allowed value in scientific notation is significantly
# limited. We will not allow user to input value larger than top
# and lower than bottom even though user's intention is to add
# exponent later to make it lower than top or bigger than bottom.
try:
datatype(value + "1")
except ValueError:
return self.Invalid, value, pos
else:
return self.Intermediate, value, pos
# If bottom is greater than zero, all values are positive
if bottom >= 0 and dvalue < 0:
return self.Invalid, value, pos
# If bottom is less than zero then value shouldn't be less than bottom
if bottom < 0 and dvalue < bottom:
return self.Invalid, value, pos
# if top is less than zero then all values are negative
if top < 0 and dvalue > 0:
return self.Invalid, value, pos
# if top is greater than zero then value shouldn't be greater than top
if top > 0 and dvalue > top:
return self.Invalid, value, pos
return self.Acceptable, value, pos
[docs]class EnforcingValidatorMixin(object):
"""
This is a mixin class to be added to a QValidator subclass. It tracks the
last valid value for a QLineEdit and can restore it when asked. Adding this
mixin to a QValidator subclass will automatically enable it to work with the
SLineEdit class "always_valid" parameter.
Custom Validation classes should inherit from this class first, then the
base Validator class. i.e.
class MyValidator(EnforcingValidatorMixin, QtGui.QIntValidator):
If a custom class overwrites the validate method, that method must store
valid strings in self.last_valid_value before returning self.Acceptable.
"""
[docs] def restoreLastValidValue(self, edit):
"""
Restores edit to the last valid value validated by this Validator
:type edit: QLineEdit
:param edit: The QLineEdit to restore the value to
"""
if hasattr(self, 'last_valid_value'):
if self.last_valid_value is not None:
edit.setText(self.last_valid_value)
[docs] def validate(self, *args):
"""
Validate, and store the current value if it is valid
See PyQt documentation for argument and return value documentation.
If a custom class overwrites the validate method, that method must store
valid strings in self.last_valid_value before returning self.Acceptable.
"""
results = super().validate(*args)
if results[0] == self.Acceptable:
self.last_valid_value = str(args[0])
return results
[docs]class FileBaseNameValidator(EnforcingValidatorMixin, QtGui.QValidator):
"""
Manage a file base name validator.
"""
[docs] def __init__(self, *args, **kwargs):
QtGui.QValidator.__init__(self, *args, **kwargs)
self.last_valid_value = None
[docs] def validate(self, value, position):
"""
See PyQt documentation for arguments and return values.
"""
if not value:
return self.Intermediate, value, position
if fileutils.is_valid_jobname(value):
self.last_valid_value = value
return self.Acceptable, value, position
else:
return self.Invalid, value, position
[docs]class SAsciiOnlyValidator(QtGui.QValidator):
"""
Ensures that the line edit contains only ASCII characters
"""
[docs] def validate(self, value, position):
"""
Do not accept strings that can't be converted to type 'str'
See PyQt documentation for argument and return value documentation.
"""
try:
value.encode('ascii')
except UnicodeEncodeError:
return self.Invalid, value, position
return self.Acceptable, value, position
[docs]class SASLValidator(QtGui.QValidator):
"""
Ensures that the line edit contains valid ASL
"""
[docs] def validate(self, value, position):
"""
Mark not valid ASL strings as intermediate.
"""
with ioredirect.IOSilence():
if not len(value) or analyze.validate_asl(value):
res = self.Acceptable
else:
res = self.Intermediate
return res, value, position
[docs]class SNonEmptyStringValidator(EnforcingValidatorMixin, QtGui.QValidator):
"""
A QValidator that ensures that only accepts non-empty strings and can
enforce validity if the validated widget loses focus and is empty. Unicode
characters are not accepted.
"""
[docs] def __init__(self, parent=None):
"""
Create an SNonEmptyStringValidator object
:type parent: QObject
:param parent: the object that owns the validator
:type bottom: float
:param bottom: the least positive/most negative number accepted
:type top: float
:param top: the most positive/least negative number accepted. If
top=None, then there is no limit at the top end of the scale.
"""
QtGui.QValidator.__init__(self, parent)
# last_valid_value is used by the LineEdit if it needs to restore the
# most recent valid value
self.last_valid_value = None
[docs] def validate(self, value, position):
"""
We only want to accept non-empty strings. Empty strings are considered
Intermediate as this allows the edit to be cleared.
See PyQt documentation for argument and return value documentation.
"""
if value:
try:
test_value = value.encode('ascii')
except UnicodeEncodeError:
return self.Invalid, value, position
test_value = test_value.decode('ascii')
self.last_valid_value = test_value
return self.Acceptable, value, position
else:
return self.Intermediate, value, position
[docs]class SNoCommaDoubleValidator(SNoCommaMinMaxMixin, QtGui.QDoubleValidator):
"""
A double validator that does not accept comma, which is used
and accepted as a group separator in QDoubleValidator.
"""
[docs] def validate(self, value, pos):
if not value:
return self.Intermediate, value, pos
# If top and bottom is not defined, set top and bottom
top = self.top() if self.top() is not None else numpy.inf
bottom = self.bottom() if self.bottom() is not None else -numpy.inf
# if top < -1 and bottom is > 0, '-.' is not acceptable
if value == '-.':
if top <= -1 or bottom >= 0:
return self.Invalid, value, pos
else:
return self.Intermediate, value, pos
# If top is less than 0 , '.' is not not acceptable
if value == '.':
if top < 0 or bottom >= 1:
return self.Invalid, value, pos
else:
return self.Intermediate, value, pos
result = self.minMaxValidate(value,
pos,
datatype=float,
bottom=bottom,
top=top)
if result[0] != self.Acceptable:
return result
return super(SNoCommaDoubleValidator, self).validate(value, pos)
[docs]class SRealValidator(EnforcingValidatorMixin, SNoCommaDoubleValidator):
"""
A QValidator that only accepts real numbers and can enforce validity if the
validated widget loses focus and does not have valid text.
"""
[docs] def __init__(self, parent=None, bottom=None, top=None, decimals=None):
"""
:type parent: QObject
:param parent: the object that owns the validator
:type bottom: float
:param bottom: the least positive/most negative number accepted
:type top: float
:param top: the most positive/least negative number accepted. If
top=None, then there is no limit at the top end of the scale.
:type decimals: int
:param decimals: Maximum number of digits after the deciaml point.
If decimals is None, default decimals from QDoubleValidator (1000)
is used.
"""
super().__init__(parent)
if bottom is not None:
self.setBottom(bottom)
if top is not None:
self.setTop(top)
if decimals:
self.setDecimals(decimals)
self.last_valid_value = None
[docs]class SNonNegativeRealValidator(SRealValidator):
"""
A QValidator that only accepts non-negative (0.0 to +infinity) real numbers
and can enforce validity if the validated widget loses focus and does not
have valid text.
"""
[docs] def __init__(self, parent=None, bottom=0.0, top=None, decimals=None):
"""
:type parent: QObject
:param parent: the object that owns the validator
:type bottom: float
:param bottom: the least positive/most negative number accepted
:type top: float
:param top: the most positive/least negative number accepted. If
top=None, then there is no limit at the top end of the scale.
:type decimals: int
:param decimals: Maximum number of digits after the deciaml point.
If decimals is None, default decimals from QDoubleValidator (1000)
is used.
"""
super().__init__(parent, bottom=bottom, top=top, decimals=decimals)
[docs]class SNoCommaIntValidator(SNoCommaMinMaxMixin, QtGui.QIntValidator):
"""
An int validator that does not accept comma, which is used
and accepted as a group separator in QIntValidator.
"""
[docs] def validate(self, value, pos):
if not value:
return self.Intermediate, value, pos
# if top and bottom are not defined set top and bottom
top = self.top() if self.top() is not None else numpy.inf
bottom = self.bottom() if self.bottom() is not None else -numpy.inf
result = self.minMaxValidate(value,
pos,
datatype=int,
bottom=bottom,
top=top)
if result[0] != self.Acceptable:
return result
return super(SNoCommaIntValidator, self).validate(value, pos)
[docs]class SNonNegativeIntValidator(EnforcingValidatorMixin, SNoCommaIntValidator):
"""
A QValidator that only accepts non-negative (0 to +infinity) int numbers and
can enforce validity if the validated widget loses focus and does not have
valid text.
"""
[docs] def __init__(self, parent=None, bottom=0, top=None):
"""
:type parent: QObject
:param parent: the object that owns the validator
:type bottom: int
:param bottom: the least positive/most negative number accepted
:type top: int
:param top: the most positive/least negative number accepted. If
top=None, then there is no limit at the top end of the scale.
"""
super().__init__(parent)
self.setBottom(bottom)
if top:
self.setTop(top)
self.last_valid_value = None
[docs]class SNumberListValidator(EnforcingValidatorMixin, QtGui.QValidator):
"""
A Validator that only accepts a list of numbers (int or floats) with a
delimiter. Minimum accepted number can be defined in 'bottom' parameter.
"""
[docs] def __init__(self, parent=None, delimiter=None, number_type=int, bottom=0):
"""
Create a SNumberListValidator object.
:type delimiter: str
:param delimiter: The delimiter for the list of numbers. If not given,
any whitespace is considered a delimiter
:type number_type: type (int or float allowed)
:param number_type: Type to convert to, int or float
:type bottom: int or float
:param bottom: the least positive/most negative number accepted
See parent class for additional documentation
"""
QtGui.QValidator.__init__(self, parent)
self.delimiter = delimiter
self.number_type = number_type
self.bottom = self.number_type(bottom)
self.last_valid_value = None
self.allowed_symbols = ['']
if number_type.__name__ == 'float':
self.allowed_symbols.append('.')
if self.bottom < 0.0:
self.allowed_symbols.append('-')
self.allowed_symbols = set(self.allowed_symbols)
[docs] def validate(self, value, position):
"""
Only accept a list of comma-delimited positive integers
See PyQt documentation for argument and return value documentation.
"""
value = str(value)
# Empty edit is OK in the middle of editing
if not value:
return self.Intermediate, value, position
tokens = value.split(self.delimiter)
for token in tokens:
# Values must be of the correct type
try:
val = self.number_type(token.strip())
# The user is probably still entering input if the token is empty
# or comprises only digits and allowed symbols.
except ValueError:
if all(char in self.allowed_symbols or char.isdigit()
for char in token):
return self.Intermediate, value, position
# Otherwise, the token is actually invalid
return self.Invalid, value, position
# Values must not be below bottom
if val < self.bottom:
return self.Invalid, value, position
self.last_valid_value = value
return self.Acceptable, value, position
[docs]class SPosIntListValidator(SNumberListValidator):
"""
A Validator that only accepts a list of positive ints with a delimiter.
"""
[docs] def __init__(self, parent=None, delimiter=None):
"""
Create a PosIntListValidator object
:type delimiter: str
:param delimiter: The delimitor for the list of ints. If not given, any
whitespace is considered a delimiter
See parent class for additional documentation
"""
SNumberListValidator.__init__(self,
parent=parent,
delimiter=delimiter,
number_type=int,
bottom=1)
[docs]class SRegularExpressionValidator(EnforcingValidatorMixin,
QtGui.QRegularExpressionValidator):
"""
A validator that only accepts strings that match the regexp.
"""
[docs] def __init__(self,
regexp: QtCore.QRegularExpression,
parent: QtCore.QObject = None):
"""
Create an SRegularExpressionValidator object.
:param regexp: Regular expression to validate
:param parent: the object that owns the validator
"""
super().__init__(regexp, parent=parent)
self.last_valid_value = None
[docs]class SComboBox(QtWidgets.QComboBox):
"""
A QComboBox widget with several Pythonic enhancements. It can also set its
items and select a default value at creation time; and can be easily reset
to its default value via reset.
A slot can be specified that will be hooked up with the
currentIndexChanged[str] signal. This command will be called when the
default value is set unless nocall=True
Action items can be added via the addAction() method. These items are not
selectable. When one is chosen, its callback is executed and previously
selected item is re-selected.
"""
currentIndexChanged = QtCore.pyqtSignal([int], [str])
[docs] def __init__(self,
parent=None,
items=None,
default_item="",
default_index=0,
command=None,
nocall=False,
layout=None,
tip="",
itemdict=None,
adjust_to_contents=True,
min_width=None):
"""
Create an SComboBox object.
:type parent: QWidget
:param parent: The parent widget of this SComboBox
:type items: list
:param items: list of items (str) to be added to the ComboBox - see also
itemdict.
:type default_item: str
:param default_item: text of the item to select initially and upon reset
:type default_index: int
:param default_index: the index of the item to select initially and upon
reset. default_item overrides this parameter.
:type command: callable
:param command: The slot to connect to the currentIndexChanged[str]
signal. This command will be called when the default item is selected
during initialization unless nocall=True.
:type nocall: bool
:param nocall: True if command should not be called during initialization,
False otherwise
:type layout: QLayout
:param layout: The layout to place this widget in
:type tip: str
:param tip: The tooltip for the combo
:type itemdict: `collections.OrderedDict`
:param itemdict: An OrderedDictionary with ComboBox items as keys and
data as values. The data for the current ComboBox item is returned by
the currentData method. If both items and itemdict are given, items are
added first.
:param bool adjust_to_contents: True if the size adjust policy should be
set to "Adjust to contents"
:type min_width: int
:param min_width: this property holds the minimum number of characters that should fit into the combobox
"""
super().__init__(parent)
if items:
self.addItems(items)
if itemdict:
self.addItemsFromDict(itemdict)
self.default_item = default_item
self.default_index = default_index
self.reset()
if command:
self.currentIndexChanged[str].connect(command)
if not nocall:
# Normally, we could just connect this SIGNAL before setting the
# default value and let that process emit the signal naturally.
# However, if the default index is 0, then the combobox does not
# emit the signal since the value never actually changed. So
# instead, we set the value, then connect the slot, then emit the
# signal by hand.
self.currentIndexChanged[str].emit(self.currentText())
if layout is not None:
layout.addWidget(self)
if tip:
self.setToolTip(tip)
self.activated.connect(self._actionTriggered)
if adjust_to_contents:
self.setSizeAdjustPolicy(self.AdjustToContents)
if min_width:
self.setMinimumContentsLength(min_width)
[docs] def addItemsFromDict(self, items):
"""
Add items to the combo box from a dictionary of {text: user_data}. Note
that setting the order of elements requires a `collections.OrderedDict`
:param items: A dictionary of {text: user_data} to add to the combo box
:type items: dict
"""
for key, val in items.items():
self.addItem(key, val)
[docs] def currentData(self, role=Qt.UserRole):
"""
Get the user data for the currently selected item
:param role: The role to retrieve data for
:type role: int
:return: The user data
"""
index = self.currentIndex()
data = self.itemData(index, role)
return data
[docs] def setCurrentData(self, data, role=Qt.UserRole):
"""
Select the index with the specified user data
:param text: The user data to select
:type data: object
:param role: The role to search
:type role: int
:raise ValueError: If the specified data is not found in the combo box
"""
index = self.findDataPy(data, role)
self.setCurrentIndex(index)
[docs] def findDataPy(self, data, role=Qt.UserRole):
"""
Get the index of the item containing the specified data. Comparisons
are done in Python rather than in C++ (as they are in `findData`), so
Python types without a direct C++ mapping can be compared correctly.
:param data: The data to search for
:type data: object
:param role: The role to search
:type role: int
:raise ValueError: If the specified data cannot be found
"""
for index in range(self.count()):
if data == self.itemData(index, role):
return index
raise ValueError("Data not in combo box: %s" % data)
[docs] def currentString(self):
"""
Obsolete. Use QComboBox.currentText() instead.
:rtype: str
:return: The currentText() value of the ComboBox converted to a string
"""
return str(self.currentText())
[docs] def reset(self):
"""
Reset the current ComboBox item to the default item.
"""
if self.default_item:
# findText returns -1 if the text isn't found
index = max(0, self.findText(self.default_item))
else:
index = self.default_index
self.setCurrentIndex(index)
[docs] def setCurrentText(self, text):
"""
Set the combobox to the item with the supplied text
:type text: str
:param text: The text of the item to make the current item
:raise: ValueError if no item with text exists
"""
index = self.findText(text)
if index < 0:
raise ValueError('No item with text=%s was found' % text)
self.setCurrentIndex(index)
[docs] def addItem(self, *args, **kwargs):
"""
Add given items and emit signals as needed.
"""
was_empty = (self.count() == 0)
super().addItem(*args, **kwargs)
if was_empty:
self.currentIndexChanged[int].emit(self.currentIndex())
self.currentIndexChanged[str].emit(self.currentText())
[docs] def addItems(self, *args, **kwargs):
"""
Add given items and emit signals as needed.
"""
was_empty = (self.count() == 0)
super(SComboBox, self).addItems(*args, **kwargs)
if was_empty:
self.currentIndexChanged[int].emit(self.currentIndex())
self.currentIndexChanged[str].emit(self.currentText())
[docs] def addAction(self, text, callback):
"""
Add an "action" menu item. When selected by the user, it will invoke
the callback and re-select the previously selected item.
"""
self.addItem(text)
index = self.count() - 1
self.setItemData(index, callback, CALLBACK_ROLE)
[docs] def setCurrentIndex(self, index):
# For documentation, see QComboBox.setCurrentIndex().
if self.itemData(index, CALLBACK_ROLE):
raise ValueError("Can not select item %i because it's an action" %
index)
prev_selection = self.currentIndex()
if index == prev_selection:
return
self._previous_index = index
super(SComboBox, self).setCurrentIndex(index)
self.currentIndexChanged[int].emit(index)
self.currentIndexChanged[str].emit(self.currentText())
def _actionTriggered(self, index):
"""
Called when a menu item is selected. If it's an action item, the
callback is invoked. If it's a regular selectable item, it is selected.
"""
callback = self.itemData(index, CALLBACK_ROLE)
if callback:
# An action item was selected
# Revert the selection to the previously-selected item:
super(SComboBox, self).setCurrentIndex(self._previous_index)
# Trigger the callback, but don't emit currentIndexChanged:
callback()
else:
# This is a regular menu item
self._previous_index = index
self.currentIndexChanged[int].emit(index)
self.currentIndexChanged[str].emit(self.currentText())
def _actionsPresent(self):
"""
Returns True if there is at least one action item in the menu.
"""
for index in range(self.count()):
if self.itemData(index, CALLBACK_ROLE):
return True
return False
[docs] def keyPressEvent(self, event):
"""
Ignore Up/Down key press events if any action items are present.
In the future, consider re-implementing these handlers; it's a non-
trivial task. See
http://code.metager.de/source/xref/lib/qt/src/gui/widgets/qcombobox.cpp#2931
"""
key = event.key()
if key == Qt.Key_Up or key == Qt.Key_Down:
if self._actionsPresent():
return
super(SComboBox, self).keyPressEvent(event)
[docs] def items(self):
"""
Return the current list of items in the combo box.
:rtype: list
:return: the current list of items
"""
return [self.itemText(i) for i in range(self.count())]
[docs]class SLabeledComboBox(SComboBox):
"""
An SComboBox that has a label attached to it.
A SLabeledComboBox instance has a .label property that is an SLabel widget.
The SLabeledComboBox instance also has a .mylayout property that the label
and ComboBox are placed into. This .mylayout should be added to another
layout in order to place the widgets into the GUI if the layout= keyword is
not used during construction.
"""
[docs] def __init__(self, text, **kwargs):
"""
Create a SLabledComboBox instance
:type text: str
:keyword text: The text of the label
:type side: str
:keyword side: 'left' if the label should appear to the left of the
ComboBox (default), or 'top' if the label should appear above it
:type stretch: bool
:keyword stretch: Whether to put a stretch after the ComboBox (or after
the after_label). Default is True, even if side='top'.
:type after_label: str
:keyword after_label: Label text to put after the ComboBox - default is
None
:type items: list
:param items: list of items (str) to be added to the ComboBox
:type default_item: str
:param default_item: text of the item to select initially and upon reset
:type default_index: int
:param default_index: the index of the item to select initially and upon
reset. default_item overrides this parameter.
:type command: callable
:param command: The slot to connect to the currentIndexChanged[str]
signal. This command will be called when the default item is
selected during initialization unless nocall=True.
:type nocall: bool
:param nocall: True if command should not be called during initialization,
False otherwise
:type layout: QLayout
:keyword layout: layout to place the LabeledEdit in
:type tip: str
:param tip: The tooltip for the labels and combobox
:type itemdict: `collections.OrderedDict`
:param itemdict: An OrderedDictionary with ComboBox items as keys and
data as values. The data for the current ComboBox item is returned
by the currentData method. If both items and itemdict are given,
items are added first.
:type min_width: int
:param min_width: this property holds the minimum number of characters that should fit into the combobox
"""
side = kwargs.pop('side', 'left')
if side == 'left':
#: Place this layout into the parent GUI, not the ComboBox
self.mylayout = SHBoxLayout()
else:
self.mylayout = SVBoxLayout()
self.label = SLabel(text, layout=self.mylayout)
items = kwargs.pop('items', None)
default_item = kwargs.pop('default_item', "")
default_index = kwargs.pop('default_index', 0)
command = kwargs.pop('command', None)
nocall = kwargs.pop('nocall', False)
itemdict = kwargs.pop('itemdict', None)
min_width = kwargs.pop('min_width', None)
SComboBox.__init__(self,
items=items,
layout=self.mylayout,
default_item=default_item,
default_index=default_index,
command=command,
nocall=nocall,
itemdict=itemdict,
min_width=min_width)
after = kwargs.pop('after_label', "")
tip = kwargs.pop('tip', "")
if after:
self.after_label = SLabel(after, layout=self.mylayout)
if tip:
self.after_label.setToolTip(tip)
if tip:
self.setToolTip(tip)
self.label.setToolTip(tip)
if kwargs.pop('stretch', True):
self.mylayout.addStretch()
parent_layout = kwargs.pop('layout', None)
if parent_layout is not None:
parent_layout.addLayout(self.mylayout)
if kwargs:
raise TypeError('Unrecognized keyword arguments: ' +
str(list(kwargs)))
[docs] def reset(self):
"""
Reset the labels and ComboBox to their default values
"""
self.label.reset()
SComboBox.reset(self)
try:
self.after_label.reset()
except AttributeError:
pass
[docs] def setEnabled(self, state):
"""
Set all child widgets to enabled state of state
:type state: bool
:param state: True if widgets should be enabled, False if not
"""
SComboBox.setEnabled(self, state)
self.label.setEnabled(state)
try:
self.after_label.setEnabled(state)
except AttributeError:
pass
[docs] def setVisible(self, state):
"""
Set all child widgets to visible state of state
:type state: bool
:param state: True if widgets should be visible, False if not
"""
SComboBox.setVisible(self, state)
self.label.setVisible(state)
try:
self.after_label.setVisible(state)
except AttributeError:
pass
[docs]class ActionComboBox(QtWidgets.QComboBox):
"""
A combo box with a few notable changes:
1) "action" signal is emitted when a new item is selected, and callback
is called when one is provided for the selected menu.
2) The label of the combo box never changes - there is no concept of a
currently selected item.
3) The label of the combo box is not shown in the pop-up menu.
For custom behavior (e.g. disabling items), use the ActionComboBox.menu
attribute to get the QMenu object.
:param itemSelected: signal emitted when a menu item is selected. The
only argument is the text of the selected item.
:type itemSelected: `PyQt5.QtCore.pyqtSignal`
"""
itemSelected = QtCore.pyqtSignal(str)
[docs] def __init__(self, parent, text=''):
"""
:param text: Text/label to show in the combo menu. This is what the user
sees until they click on the widget.
:param text: str
"""
super(ActionComboBox, self).__init__(parent)
super(ActionComboBox, self).addItem(text)
self.menu = QtWidgets.QMenu()
self.menu.triggered.connect(self.actionTriggered)
[docs] def addItem(self, text, callback=None):
"""
Add a menu item with the given action callback. When this item is
selected, the specified callback will be called in addition to emitting
the itemSelected signal.
"""
if callback:
self.menu.addAction(text, callback)
else:
self.menu.addAction(text)
[docs] def addAction(self, action):
self.menu.addAction(action)
[docs] def addSeparator(self):
return self.menu.addSeparator()
[docs] def count(self):
return len(self.menu.actions())
[docs] def setText(self, combo_txt):
"""
Sets texts that will be shown in combo box label widget.
:parama combo_txt: combo box default text
:type combo_txt: str
"""
self.setItemText(0, combo_txt)
[docs] def reset(self):
self.menu.clear()
[docs] def actionTriggered(self, action):
"""
This function is called when item is selected in the popup.
:param item_index: Index (position) of the selected menu item.
:type item_index: int
"""
self.itemSelected.emit(action.text())
[docs]class MinMaxSpinBoxTether(QtCore.QObject):
"""
A QObject that ties two spinboxes together and ensures that the max spinbox
never has a value less than the min spinbox. This restriction is enforced
when one of the spinbox changes by changing the value of the other spinbox
if necessary.
"""
[docs] def __init__(self, minbox, maxbox, parent=None):
"""
Create a MinMaxSpinboxTether object
:type minbox: QSpinBox
:param minbox: The spinbox controlling the minimum value
:type maxbox: QSpinBox
:param maxbox: The spinbox controlling the maximum value
:type parent: QWidget
:param parent: The parent widget
"""
super().__init__(parent=parent)
# Ensure that bounds are correct
assert (maxbox.maximum() >= minbox.minimum() and
minbox.minimum() <= maxbox.minimum())
self.minbox = minbox
self.maxbox = maxbox
self.minbox.valueChanged.connect(self.minimumChanged)
self.maxbox.valueChanged.connect(self.maximumChanged)
[docs] def minimumChanged(self, minval):
"""
React to a change in the minimum value
:type minval: int or float
:param minval: The new minimum value
"""
maxval = self.maxbox.value()
if minval > maxval:
# Delayed import to avoid circular imports
from schrodinger.ui.qt import utils as qtutils
with qtutils.suppress_signals(self.maxbox):
self.maxbox.setValue(minval)
[docs] def maximumChanged(self, maxval):
"""
React to a change in the maximum value
:type maxval: int or float
:param maxval: The new maximum value
"""
minval = self.minbox.value()
if minval > maxval:
# Delayed import to avoid circular imports
from schrodinger.ui.qt import utils as qtutils
with qtutils.suppress_signals(self.minbox):
self.minbox.setValue(maxval)
[docs]class SSpinBox(QtWidgets.QSpinBox):
"""
A QSpinBox that can set it's min, max and value in the constructor plus
pack itself in a layout
"""
[docs] def __init__(self,
parent=None,
minimum=None,
maximum=None,
value=None,
layout=None,
command=None,
nocall=False,
stepsize=None,
width=None,
tip=None):
"""
Create an SSpinBox object
:type parent: QObject
:param parent: the parent object of this SSpinBox
:type minimum: int
:param minimum: The minimum value of this SSpinBox
:type maximum: int
:param maximum: The maximum value of this SSpinBox
:type value: int
:param value: The initial value of this SSpinBox. This value will also
be used as the default value by the reset method.
:type layout: QLayout
:param layout: If supplied, the SSpinBox created will be added to this
layout
:type command: python callable
:param command: The callback for the valueChanged signal. This command
will not be called during initialization unless nocall is set to False.
:type nocall: bool
:param nocall: True if the callback command should not be called
during initialization, False (default) if the command should be called.
:type stepsize: int
:param stepsize: The stepsize each arrow click changes the value by
:type width: int
:param width: The maximum width of the spinbox
:param `str` tip: The tooltip
"""
QtWidgets.QSpinBox.__init__(self, parent)
if minimum is not None:
self.setMinimum(minimum)
if maximum is not None:
self.setMaximum(maximum)
if not nocall and command is not None:
self.valueChanged.connect(command)
if value is not None:
self.setValue(value)
if stepsize is not None:
self.setSingleStep(stepsize)
if width:
self.setMaximumWidth(width)
self.default_value = value
if nocall and command is not None:
self.valueChanged.connect(command)
if layout is not None:
layout.addWidget(self)
if tip:
self.setToolTip(tip)
[docs] def reset(self):
"""
Reset the spin box to its default value
"""
if self.default_value is not None:
self.setValue(self.default_value)
[docs]class SDoubleSpinBox(QtWidgets.QDoubleSpinBox):
"""
A QDoubleSpinBox that can set it's min, max and value in the constructor
plus pack itself in a layout
"""
[docs] def __init__(self,
parent=None,
minimum=None,
maximum=None,
value=None,
layout=None,
command=None,
nocall=False,
stepsize=None,
decimals=None,
width=None,
tip=None):
"""
Create an SDoubleSpinBox object
:type parent: QObject
:param parent: the parent object of this SDoubleSpinBox
:type minimum: float
:param minimum: The minimum value of this SDoubleSpinBox
:type maximum: float
:param maximum: The maximum value of this SDoubleSpinBox
:type value: float
:param value: The initial value of this SDoubleSpinBox. This value will
also be used as the default value by the reset method.
:type stepsize: float
:param stepsize: The stepsize each arrow click changes the value by
:type decimals: int
:param decimals: Number of decimal places to display
:type layout: QLayout
:param layout: If supplied, the SDoubleSpinBox created will be added to
this layout
:type command: python callable
:param command: The callback for the valueChanged signal. This command
will be called during initialization unless nocall is set to True.
:type nocall: bool
:param nocall: True if the callback command should not be called
during initialization, False (default) if the command should be called.
:type width: int
:param width: The maximum width of the spinbox
:param str tip: The tooltip
"""
QtWidgets.QDoubleSpinBox.__init__(self, parent)
if minimum is not None:
self.setMinimum(minimum)
if maximum is not None:
self.setMaximum(maximum)
if not nocall and command is not None:
self.valueChanged.connect(command)
if stepsize is not None:
self.setSingleStep(stepsize)
if decimals is not None:
self.setDecimals(decimals)
# Preventing default decimals from rounding the initial value
if value is not None:
self.setValue(value)
self.default_value = value
if nocall and command is not None:
self.valueChanged.connect(command)
if layout is not None:
layout.addWidget(self)
if width:
self.setMaximumWidth(width)
if tip:
self.setToolTip(tip)
[docs] def reset(self):
"""
Reset the spin box to its default value
"""
if self.default_value is not None:
self.setValue(self.default_value)
[docs]class SLabeledDoubleSpinBox(SDoubleSpinBox):
"""
An SDoubleSpinBox that has a label attached to it.
A SLabledDoubleSpinBox instance has a .label property that is an SLabel
widget. The SLabeledComboBox instance also has a .mylayout property that
the label and SDoubleSpinBox are placed into. This .mylayout should be
added to another layout in order to place the widgets into the GUI if the
layout= keyword is not used during construction.
"""
[docs] def __init__(self, text, **kwargs):
"""
Create a SLabledDoubleSpinBox instance
:type text: str
:keyword text: The text of the label
:type side: str
:keyword side: 'left' if the label should appear to the left of the
SDoubleSpinBox (default), or 'top' if the label should appear above it
:type stretch: bool
:keyword stretch: Whether to put a stretch after the SDoubleSpinBox
(or after the after_label). Default is True, even if side='top'.
:type after_label: str
:keyword after_label: Label text to put after the SDoubleSpinBox -
default is None
:type parent: QObject
:param parent: the parent object of this SDoubleSpinBox
:type minimum: float
:param minimum: The minimum value of this SDoubleSpinBox
:type maximum: float
:param maximum: The maximum value of this SDoubleSpinBox
:type value: float
:param value: The initial value of this SDoubleSpinBox
:type stepsize: float
:param stepsize: The stepsize each arrow click changes the value by
:type decimals: int
:param decimals: Number of decimal places to display
:type layout: QLayout
:param layout: If supplied, the SDoubleSpinBox created will be added to
this layout
:type command: python callable
:param command: The callback for the valueChanged signal. This command
will not be called during initialization unless nocall is set to False.
:type nocall: bool
:param nocall: True if the callback command should not be called
during initialization, False (default) if the command should be called.
:type tip: str
:param tip: The tooltip to apply to the labels and spinbox
"""
side = kwargs.pop('side', 'left')
if side == 'left':
#: Place this layout into the parent GUI, not the ComboBox
self.mylayout = SHBoxLayout()
else:
self.mylayout = SVBoxLayout()
self.label = SLabel(text, layout=self.mylayout)
after = kwargs.pop('after_label', "")
stretch = kwargs.pop('stretch', True)
parent_layout = kwargs.pop('layout', None)
tip = kwargs.pop('tip', None)
SDoubleSpinBox.__init__(self, layout=self.mylayout, **kwargs)
if after:
self.after_label = SLabel(after, layout=self.mylayout)
if stretch:
self.mylayout.addStretch()
if parent_layout is not None:
parent_layout.addLayout(self.mylayout)
if tip:
self.label.setToolTip(tip)
self.setToolTip(tip)
if after:
self.after_label.setToolTip(tip)
[docs] def reset(self):
"""
Reset the labels and ComboBox to their default values
"""
self.label.reset()
SDoubleSpinBox.reset(self)
try:
self.after_label.reset()
except AttributeError:
pass
[docs] def setEnabled(self, state):
"""
Set all child widgets to enabled state of state
:type state: bool
:param state: True if widgets should be enabled, False if not
"""
SDoubleSpinBox.setEnabled(self, state)
self.label.setEnabled(state)
try:
self.after_label.setEnabled(state)
except AttributeError:
pass
[docs] def setVisible(self, state):
"""
Set all child widgets to visible state of state
:type state: bool
:param state: True if widgets should be visible, False if not
"""
SDoubleSpinBox.setVisible(self, state)
self.label.setVisible(state)
try:
self.after_label.setVisible(state)
except AttributeError:
pass
[docs]class SLabeledSpinBox(SSpinBox):
"""
An SSpinBox that has a label attached to it.
A SLabledSpinBox instance has a .label property that is an SLabel widget.
The SLabeledSpinBox instance also has a .mylayout property that the label
and SSpinBox are placed into. This .mylayout should be added to another
layout in order to place the widgets into the GUI if the layout= keyword is
not used during construction.
"""
[docs] def __init__(self, text, **kwargs):
"""
Create a SLabledSpinBox instance
:type text: str
:keyword text: The text of the label
:type side: str
:keyword side: 'left' if the label should appear to the left of the
SSpinBox (default), or 'top' if the label should appear above it
:type stretch: bool
:keyword stretch: Whether to put a stretch after the SSpinBox (or after
the after_label). Default is True, even if side='top'.
:type after_label: str
:keyword after_label: Label text to put after the SSpinBox - default is
None
:type parent: QObject
:param parent: the parent object of this SSpinBox
:type minimum: int
:param minimum: The minimum value of this SSpinBox
:type maximum: int
:param maximum: The maximum value of this SSpinBox
:type value: int
:param value: The initial value of this SSpinBox
:type layout: QLayout
:param layout: If supplied, the SSpinBox created will be added to this
layout
:type command: python callable
:param command: The callback for the valueChanged signal. This command
will not be called during initialization unless nocall is set to
False.
:type nocall: bool
:param nocall: True if the callback command should not be called
during initialization, False (default) if the command should be
called.
:type tip: str
:param tip: The tooltip to apply to the labels and edit
"""
side = kwargs.pop('side', 'left')
if side == 'left':
#: Place this layout into the parent GUI, not the ComboBox
self.mylayout = SHBoxLayout()
else:
self.mylayout = SVBoxLayout()
self.label = SLabel(text, layout=self.mylayout)
after = kwargs.pop('after_label', '')
stretch = kwargs.pop('stretch', True)
parent_layout = kwargs.pop('layout', None)
SSpinBox.__init__(self, layout=self.mylayout, **kwargs)
if after:
self.after_label = SLabel(after, layout=self.mylayout)
if stretch:
self.mylayout.addStretch()
if parent_layout is not None:
parent_layout.addLayout(self.mylayout)
self.setToolTip(kwargs.pop('tip', ''))
[docs] def reset(self):
"""
Reset the labels and ComboBox to their default values
"""
self.label.reset()
SSpinBox.reset(self)
try:
self.after_label.reset()
except AttributeError:
pass
[docs] def setEnabled(self, state):
"""
Set all child widgets to enabled state of state
:type state: bool
:param state: True if widgets should be enabled, False if not
"""
SSpinBox.setEnabled(self, state)
self.label.setEnabled(state)
try:
self.after_label.setEnabled(state)
except AttributeError:
pass
[docs] def setVisible(self, state):
"""
Set all child widgets to visible state of state
:type state: bool
:param state: True if widgets should be visible, False if not
"""
super().setVisible(state)
self.label.setVisible(state)
try:
self.after_label.setVisible(state)
except AttributeError:
pass
[docs]class SFrame(QtWidgets.QFrame):
"""
A Frame that can be placed in a layout and is given a layout when created
"""
[docs] def __init__(self,
parent=None,
layout=None,
layout_type=VERTICAL,
indent=False):
"""
Create an SFrame instance. The frame has a mylayout property that is
the internal layout.
:type parent: QObject
:param parent: the parent object of this SFrame
:type layout_type: string
:keyword layout_type: Module constants VERTICAL, HORIZONTAL, or GRID,
for the type of internal layout
:type layout: QBoxLayout
:keyword layout: The layout to place this SFrame into
:type indent: bool or int
:keyword indent: False for a 0 left margin, True for a STANDARD_INDENT
left margin, or an int for the value of the left margin - False by
default.
"""
QtWidgets.QFrame.__init__(self, parent)
if layout is not None:
layout.addWidget(self)
if layout_type == HORIZONTAL:
self.mylayout = SHBoxLayout(self, indent=indent)
elif layout_type == GRID:
self.mylayout = SGridLayout(self, indent=indent)
else:
self.mylayout = SVBoxLayout(self, indent=indent)
[docs] def addStretch(self):
"""
Add stretch to the frame layout.
"""
self.mylayout.addStretch()
[docs]class SHLine(QtWidgets.QFrame):
"""
A simple horizontal line
"""
[docs] def __init__(self, layout=None):
"""
Create a SHLine object
:type layout: QLayout
:param layout: If supplied, the line created will be added to this
layout
"""
QtWidgets.QFrame.__init__(self)
self.setFrameShape(self.HLine)
self.setFrameShadow(self.Raised)
if layout is not None:
layout.addWidget(self)
[docs]def draw_picture_into_rect(painter,
pic,
rect,
max_scale=None,
padding_factor=None):
"""
Draw a QPicture into a given rectangle.
:type painter: QtGui.QPainter object
:param painter: QPainter object that will do the drawing into the cell.
:type pic: QPicture
:param pic: the picture to be drawn into the rectangle
:type rect: QRect
:param rect: the rectangle that defines the drawing region
:type max_scale: float or None
:param max_scale: The maximum amount to scale the picture while attempting
to make it fit into rect. This value is multipled by the scale factor
required to fit a 150(wide) x 100 (high) picture to fit that picture into
rect. The resulting product (max_scale * reference picture scale) is the
true maximum scale factor allowed.
:type padding_factor: float or None
:padding_factor: If set, add padding on all 4 sides of QPicture, on
the dimensions of the rectangle, to avoid
it from "touching" the outer edges of the paint area. For 2D structure
images, value of 0.04 is recommended.
:return: Rectangle that the QPicture was actually painted into. It's
a rectangle within `rect` - after removing padding on each side.
:rtype: QRectF
"""
dest_x = rect.left()
dest_y = rect.top()
dest_w = rect.width()
dest_h = rect.height()
picrect = pic.boundingRect()
pic_w = picrect.width()
pic_h = picrect.height()
if padding_factor is not None:
padding = min(dest_w, dest_h) * padding_factor
dest_x += padding
dest_y += padding
dest_w -= (2.0 * padding)
dest_h -= (2.0 * padding)
# Nothing to do if the picture is empty. Avoid ZeroDivisionError and
# simply exit without drawing anything:
if pic_w == 0 or pic_h == 0:
return None
# Scaling of specified picture to destination rect:
# (using whichever ratio is smaller of width vs height)
scale = min(dest_w / pic_w, dest_h / pic_h)
# scale is small for large molecules and large for small molecules
# (benzenes)
# It's the ratio by which to UP scale the QPicture.
if max_scale is not None and max_scale != 0:
if max_scale <= 0:
raise ValueError("max_scale must be more than zero")
# Scaling of 150x100 pic to the dest rect:
# (using whichever ratio is smaller of width vs height)
ref_rect_w = 150
ref_rect_h = 100
ref_scale = min(dest_w / ref_rect_w, dest_h / ref_rect_h)
# Reduce the scaling (do not shrink as much) if the molecule
# is too small:
if scale > ref_scale * max_scale:
scale = ref_scale * max_scale
xoffset = (dest_w - scale * pic_w) / 2.0
yoffset = (dest_h - scale * pic_h) / 2.0
x = dest_x + xoffset
y = dest_y + yoffset
painter.translate(x, y)
if scale != 0:
painter.scale(scale, scale)
painter.setBrush(QtGui.QBrush(QtCore.Qt.black, QtCore.Qt.NoBrush))
painter.drawPicture(-picrect.left(), -picrect.top(), pic)
if scale != 0:
painter.scale(1.0 / scale, 1.0 / scale)
painter.translate(-x, -y)
return QtCore.QRectF(x, y, pic_w * scale, pic_h * scale)
[docs]class SDialog(QtWidgets.QDialog):
"""
A Base Dialog class for easier Dialog creation
"""
[docs] def __init__(self,
master,
user_accept_function=None,
standard_buttons=None,
nonstandard_buttons=None,
help_topic="",
title=None):
"""
Creat an SDialog object
:type master: QWidget
:param master: The parent of this dialog
:type user_accept_function: callable
:param user_accept_function: A function to call from a custom accept
method implemented in a subclass
:type standard_buttons: list
:param standard_buttons: List of standard buttons to add - each item
must be a QDialogButtonBox.StandardButton constant (i.e.
QDialogButtonBox.Ok). The default if no standard buttons or non-standard
buttons are provided is Ok and Cancel.
:type nonstandard_buttons: list of (QPushButton,
QDialogButtonBox.ButtonRole) tuples
:param nonstandard_buttons: Each item of the list is a tuple containing
an existing button and a ButtonRole such as AcceptRole. The default if
no standard buttons or non-standard buttons are provided is Ok and
Cancel buttons.
:type help_topic: str
:param help_topic: The help topic for this panel. If the help topic is
provided a help button will automatically be added to the panel.
:type title: str
:param title: The dialog window title
"""
self.help_topic = help_topic
self.master = master
self.user_accept_function = user_accept_function
QtWidgets.QDialog.__init__(self, master)
if title:
self.setWindowTitle(title)
self.mylayout = SVBoxLayout(self)
self.mylayout.setContentsMargins(6, 6, 6, 6)
self.layOut()
# Create the requested buttons
self.dbb = QtWidgets.QDialogButtonBox()
if not standard_buttons and not nonstandard_buttons:
standard_buttons = [self.dbb.Ok, self.dbb.Cancel]
elif not standard_buttons:
standard_buttons = []
if self.help_topic and self.dbb.Help not in standard_buttons:
standard_buttons.append(self.dbb.Help)
if not nonstandard_buttons:
nonstandard_buttons = []
for sbutton in standard_buttons:
self.dbb.addButton(sbutton)
for nsbutton, role in nonstandard_buttons:
self.dbb.addButton(nsbutton, role)
# Hook up common standard buttons
# Note - self.accept and self.reject are standard Dialog methods and do
# not need to be reimplemented if nothing custom is done
self.dbb.accepted.connect(self.accept)
self.dbb.rejected.connect(self.reject)
self.dbb.helpRequested.connect(self.giveHelp)
reset_button = self.dbb.button(self.dbb.Reset)
if reset_button:
reset_button.clicked.connect(self.reset)
self.mylayout.addWidget(self.dbb)
# see MATSCI-5358 - where it was found in the 18-2 release
# that dialogs on Windows by default come with a question mark
# help button on the title bar next to the close button, the
# following line of code prevents this
self.setWindowFlags(self.windowFlags() &
~QtCore.Qt.WindowContextHelpButtonHint)
[docs] def layOut(self):
"""
Lay out the custom widgets in this dialog.
"""
layout = self.mylayout
raise NotImplementedError('The layOut method must be implemented to '
'add widgets to dialog.mylayout')
[docs] def giveHelp(self):
"""
Display the help topic for this dialog
"""
if self.help_topic:
status = pyhelp.mmpyhelp_show_help_topic(self.help_topic)
if status != pyhelp.MMHELP_OK:
self.warning('The help topic for this panel could not be '
'found.')
else:
self.warning('Help was requested but no help topic or no help '
'product was defined for this panel.')
[docs] def warning(self, msg):
"""
Display a warning dialog with a message
:type msg: str
:param msg: The message to display in the warning dialog
"""
from schrodinger.ui.qt import utils as qtutils
with qtutils.remove_wait_cursor:
QtWidgets.QMessageBox.warning(self, 'Warning', msg)
[docs] def info(self, msg):
"""
Display an information dialog with a message
:type msg: str
:param msg: The message to display in the information dialog
"""
from schrodinger.ui.qt import utils as qtutils
with qtutils.remove_wait_cursor:
QtWidgets.QMessageBox.information(self, 'Information', msg)
[docs] def error(self, msg):
"""
Display an error dialog with a message
:type msg: str
:param msg: The message to display in the error dialog
"""
from schrodinger.ui.qt import utils as qtutils
with qtutils.remove_wait_cursor:
QtWidgets.QMessageBox.critical(self, 'Error', msg)
[docs] def reset(self):
"""
Reset the panel when the Reset button is pressed. Must be re-implemented
in a subclass
"""
raise NotImplementedError('The reset method must be implemented')
[docs] def question(self, msg, button1='OK', button2='Cancel'):
"""
Display a question dialog with a message
Returns True if first button (default OK) is pressed, False otherwise.
:param str msg: The message to display in the question dialog
:param str button1: The text to display on first button
:param str button2: The text to display on second button
"""
from schrodinger.ui.qt import messagebox
from schrodinger.ui.qt import utils as qtutils
with qtutils.remove_wait_cursor:
return messagebox.show_question(parent=self,
text=msg,
yes_text=button1,
no_text=button2,
add_cancel_btn=False)
[docs]class SMARTSEdit(SLabeledEdit):
"""
A line edit for entering SMARTS strings with an icon that indicates the
SMARTS' validity
"""
smarts_data_changed = QtCore.pyqtSignal()
invalid_smarts_changed = QtCore.pyqtSignal()
[docs] def __init__(self,
master,
tag=None,
empty_ok=True,
label='SMARTS:',
indicator=True,
indicator_state=INDICATOR_VALID,
indicator_tips=True,
canvas_api=True,
rdkit_api=False,
**kwargs):
"""
Create a SMARTSEntry object
:type master: QWidget
:param master: Something with a warning method for displaying warning
dialogs
:type tag: str
:param tag: A string that defines the edit for use in warning messages.
ex. "No valid SMARTS pattern for 'tag'". If not supplied, a generic
message will be used.
:type empty_ok: bool
:param empty_ok: Whether an empty edit is considered VALID or
INTERMEDIATE
:type label: str
:param label: The label preceeding the QLineEdit
:type indicator: bool
:param indicator: True if a valid/invalid indicator should be appended
:type indicator_state: bool
:param indicator_state: The default state of the indicator
(INDICATOR_VALID, INDICATOR_INVALID or INDICATOR_INTERMEDIATE)
:type indicator_tips: dict
:param indicator_tips: The tooltips for the different states of the
indicator - see the SValidIndicator class for more information. If
not provided, they can be given directly in the setIndicator call.
If not given in either place, the indicator will have default
tooltips. Pass in None to have no tooltips at all.
:type canvas_api: bool
:param canvas_api: toggle whether to verify with analyze.validate_smarts
or analyze.validate_smarts_canvas (defaults to
validate_smarts_canvas)
:type rdkit_api: bool
:param rdkit_api: Whether to verify with adapter.validate_smarts. It
cannot be used with canvas_api.
See the parent `SLabeledEdit` class for additional keyword arguments
"""
if indicator_tips is True:
indicator_tips = {
INDICATOR_VALID: 'SMARTS pattern is valid',
INDICATOR_INVALID: 'SMARTS pattern is not valid',
INDICATOR_INTERMEDIATE: 'Enter a valid SMARTS pattern'
}
self.empty_ok = empty_ok
dator = SAsciiOnlyValidator()
self.canvas_api = canvas_api
self.rdkit_api = rdkit_api
if self.canvas_api and self.rdkit_api:
raise AttributeError(
'Cannot use canvas and rdkit api at the same time.')
SLabeledEdit.__init__(self,
label,
indicator=indicator,
indicator_state=indicator_state,
indicator_tips=indicator_tips,
validator=dator,
**kwargs)
self.textChanged.connect(self.startedEditingSMARTS)
self.editingFinished.connect(self.doneEditingSMARTS)
self.last_text = None
self.master = master
self.tag = tag
self.findSMARTSError()
[docs] def setEmptyOK(self, is_ok):
"""
Set whether an empty edit is considered VALID or INTERMEDIATE
:type is_ok: bool
:param is_ok: Whether an empty edit is VALID
"""
self.empty_ok = is_ok
[docs] def isInvalid(self):
"""
Is the line edit currently colored as invalid?
:rtype: bool
:return: Whether the line edit has the invalid color
"""
return self.indicator.getState() == INDICATOR_INVALID
[docs] def isValid(self):
"""
Is the line edit currently colored as valid?
:rtype: bool
:return: Whether the line edit has the valid color
"""
return self.indicator.getState() == INDICATOR_VALID
[docs] def startedEditingSMARTS(self):
"""
React to the SMARTS pattern changing but not yet finished
"""
self.setIndicator(INDICATOR_INTERMEDIATE)
[docs] def doneEditingSMARTS(self, force=False):
"""
The SMARTS pattern is complete, so validate it if necessary
:type force: bool
:param force: Force the SMARTS pattern to be validated even if the
existing state is invalid.
"""
# If we are already invalid, the text has already been found
# to be invalid, so don't go any further. This avoids loops where we
# show a dialog, which causes the edit to lose focus, which triggers
# this method, which shows a dialog, etc. It also avoids constantly
# yelling at the user if they don't change the SMARTS pattern
# immediately. Both of the problems can occur if the user hits "return"
# while in the edit, which initially leaves the focus in the edit but
# still triggers this signal.
self.last_text = self.text()
if not self.isInvalid() or force:
if not self.findSMARTSError():
self.smarts_data_changed.emit()
else:
self.invalid_smarts_changed.emit()
[docs] def getSMARTSPatterns(self, raise_if_empty=False):
"""
Get a list of the current SMARTS patterns specified by the user
:type raise_if_empty: bool
:param raise_if_empty: Whether to raise an error if the checkbox is
checked but the line edit is empty
:rtype: list
:return: An empty list if the checkbox is not checked, or the contents
of the edit converted via whitespace to a list of strings if the
checkbox is checked.
:raise ValueError: if raise_if_empty is True, the checkbox is checked,
and the edit is empty
"""
patterns = self.text().split()
if not patterns and raise_if_empty:
msg = 'No SMARTS pattern has been defined'
if self.tag:
msg = msg + 'for ' + self.tag
raise ValueError(msg)
return patterns
[docs] def validateSMARTS(self, pattern):
"""
Validate smarts pattern
:param pattern: The smarts pattern to validate
:type pattern: str
:rtype: (bool, str)
:return: The bool indicates whether the SMARTS pattern is valid, the
string gives the error message if bool is False Note: the return
value of this function always evaluates to True since it is a
non-empty tuple.
"""
if self.canvas_api:
return analyze.validate_smarts_canvas(pattern)
elif self.rdkit_api:
try:
adapter.validate_smarts(pattern)
return True, ""
except ValueError as err:
return False, str(err)
return analyze.validate_smarts(pattern)
[docs] def findSMARTSError(self, verbose=True):
"""
Find any error in the SMARTS patterns
:type verbose: bool
:param verbose: Whether to post a warning dialog with the error found
:rtype: str
:return: An empty string if no error is found, or an error message
describing the error that was found
"""
patterns = self.getSMARTSPatterns()
for pattern in patterns:
validation = self.validateSMARTS(pattern)
if not validation[0]:
# Replace the '^' position marker in the message,
# because the non-fixed point font of message boxes
# messes up its position and "^" isn't critical.
verror = validation[1].replace("^", "")
msg = ('%s is not a valid SMARTS pattern, error was:\n%s' %
(pattern, verror))
self.setIndicator(INDICATOR_INVALID)
if verbose:
self.master.warning(msg)
return msg
if not patterns and not self.empty_ok:
self.setIndicator(INDICATOR_INTERMEDIATE)
else:
self.setIndicator(INDICATOR_VALID)
return ""
[docs] def setText(self, text):
"""
Overrides the parent method to also evaluate the new SMARTS string
"""
SLabeledEdit.setText(self, text)
self.doneEditingSMARTS(force=True)
[docs] def clear(self):
"""
Overrides the parent method to also evaluate the new SMARTS string
"""
SLabeledEdit.clear(self)
self.doneEditingSMARTS(force=True)
[docs]class STableView(QtWidgets.QTableView):
"""
Just like a QTableView except:
1) LineEditDelegate is the default delegate. It draws "pencil" icons in
cells that are editable.
2) Cells whose delegates have MOUSE_CLICK_STARTS_EDIT set will enter the
edit mode as soon as the user clicks (press & release) in the cell.
3) Cells whose delegates have MOUSE_PRESS_STARTS_EDIT set will enter the
edit mode as soon as the user presses the mouse button.
"""
[docs] def __init__(self, *args, **kwargs):
"""
Create a STableView instance.
"""
super().__init__(*args, **kwargs)
# Import is here to prevent circular imports in the future (previously
# delegates.py imported swidgets.py module)
from schrodinger.ui.qt.delegates import LineEditDelegate
self._default_delegate = LineEditDelegate(self)
self.setItemDelegate(self._default_delegate)
self.pressed.connect(self._cellPressed)
self.clicked.connect(self._cellClicked)
def _cellPressed(self, index):
"""
Called when the user presses the mouse in a cell. If the delegate
has MOUSE_PRESS_STARTS_EDIT attribute set, then the cell will enter
the edit mode.
"""
delegate = self.itemDelegate(index)
if getattr(delegate, 'MOUSE_PRESS_STARTS_EDIT', False):
# If the delegate has a MOUSE_PRESS_STARTS_EDIT variable set:
self.edit(index)
def _cellClicked(self, index):
"""
Called when the user presses the mouse in a cell. If the delegate
has MOUSE_CLICK_STARTS_EDIT attribute set, then the cell will enter
the edit mode.
"""
delegate = self.itemDelegate(index)
if getattr(delegate, 'MOUSE_CLICK_STARTS_EDIT', False):
# If the delegate has a MOUSE_CLICK_STARTS_EDIT variable set:
self.edit(index)
[docs]class TextSearchBar(QtWidgets.QWidget):
"""
A compound widget for performing a simple text search. Consists of a text
field with placeholder, previous and next match buttons, and a cancel button
that will hide the search bar.
"""
nextMatchRequested = QtCore.pyqtSignal(str)
prevMatchRequested = QtCore.pyqtSignal(str)
[docs] def __init__(self, parent=None, placeholder_text='text in page'):
"""
:param placeholder_text: placeholder text to show in the text field
:type placeholder_text: str
"""
super(TextSearchBar, self).__init__(parent)
layout = QtWidgets.QHBoxLayout(self)
self.setLayout(layout)
self.text_le = SLineEdit(show_clear=True,
placeholder_text=placeholder_text)
self.find_lbl = QtWidgets.QLabel('Find:')
self.prev_btn = QtWidgets.QToolButton()
self.next_btn = QtWidgets.QToolButton()
self.hide_btn = QtWidgets.QToolButton()
layout.addWidget(self.find_lbl)
layout.addWidget(self.text_le)
layout.addWidget(self.prev_btn)
layout.addWidget(self.next_btn)
layout.addWidget(self.hide_btn)
layout.setContentsMargins(0, 0, 0, 0)
layout.addStretch()
qs = QtWidgets.QStyle
style = self.style()
self.prev_btn.setIcon(style.standardIcon(qs.SP_ArrowUp))
self.next_btn.setIcon(style.standardIcon(qs.SP_ArrowDown))
self.hide_btn.setIcon(style.standardIcon(qs.SP_TitleBarCloseButton))
self.hide_btn.setAutoRaise(True)
self.prev_btn.clicked.connect(self.onPrevBtnClicked)
self.next_btn.clicked.connect(self.onNextBtnClicked)
self.hide_btn.clicked.connect(self.hide)
self.text_le.editingFinished.connect(self.onNextBtnClicked)
[docs] def onPrevBtnClicked(self):
self.prevMatchRequested.emit(self.text_le.text())
[docs] def onNextBtnClicked(self):
self.nextMatchRequested.emit(self.text_le.text())
[docs] def searchKeyPressed(self, e):
"""
Checks a QEvent from keyPressEvent for the search bar invocation key
combo (i.e. Ctrl-F or Cmd-F). If the key combo is detected, show the
search bar, put focus on the text field, and return True. Otherwise
return False.
Typical usage in MyPanel::
def keyPressEvent(self, e):
if self.search_bar.searchKeyPressed(e):
return
super(MyPanel, self).keyPressEvent(e)
:param e: the event to be checked
:type e: QEvent
"""
if e.key() == QtCore.Qt.Key_F and (e.modifiers() &
QtCore.Qt.ControlModifier):
self.show()
self.text_le.setFocus()
return True
return False
[docs]def get_picture_as_text(pic, width=TOOLTIP_WIDTH, height=TOOLTIP_HEIGHT):
""" Saves a QPicture to an image, returns the 64 base string
for the image, so that it can be displayed as a tooltip.
"""
image = QtGui.QImage(width, height,
QtGui.QImage.Format_ARGB32_Premultiplied)
rect = QtCore.QRect(0, 0, width, height)
# Without filling the background, the image was very noisy.
image.fill(Qt.white)
painter = QtGui.QPainter()
painter.begin(image)
if pic:
draw_picture_into_rect(painter, pic, rect)
painter.end()
ba = QtCore.QByteArray()
buf = QtCore.QBuffer(ba)
buf.open(QtCore.QIODevice.WriteOnly)
image.save(buf, "PNG")
buf_str = str(buf.data().toBase64(), encoding='utf-8')
return ('<img src="data:image/png;base64,{0}" width="{1}" height="{2}"'
'>').format(buf_str, width, height)
[docs]class SpinBoxDoubleSlider(QtWidgets.QWidget):
"""
This widget is made up of a double slider widget with a double spinbox on
each side. It allows selection of a sub-range from a given range of values.
Originally copied from mmshare/python/scripts/watermap_result_gui_dir/cutoff_slider.py
and expanded with additional functionality.
"""
cutoffChanged = QtCore.pyqtSignal()
[docs] def __init__(self,
parent=None,
range_min=-1.0,
range_max=1.0,
min_value=-1.0,
max_value=1.0,
stepsize=0.2,
decimals=2,
command=None,
nocall=True,
layout=None):
"""
Create an SpinBoxDoubleSlider object
:type parent: QObject
:param parent: the parent object of this SpinBoxDoubleSlider
:type range_min: float
:param minimum: The minimum value this SpinBoxDoubleSlider can take on.
The reset method will also reset the range of the slider using this
value.
:type range_max: float
:param maximum: The maximum value this SpinBoxDoubleSlider can take on.
The reset method will also reset the range of the slider using this
value.
:type min_value: float
:param value: The initial minimum (left slider) value of this
SpinBoxDoubleSlider. This value will also be used as the default
left slider value by the reset method.
:type max_value: float
:param value: The initial maximum (right slider) value of this
SpinBoxDoubleSlider. This value will also be used as the default
right slider value by the reset method.
:type stepsize: float
:param stepsize: The stepsize each spinbox arrow click changes the value
by
:type decimals: int
:param decimals: Number of decimal places to display in each spinbox
:type layout: QLayout
:param layout: If supplied, the SpinBoxDoubleSlider created will be
added to this layout
:type command: python callable
:param command: The callback for the cutoffChanged signal. This command
will be called during initialization unless nocall is set to True.
:type nocall: bool
:param nocall: True if the callback command should not be called
during initialization, False if the command should be called.
"""
QtWidgets.QWidget.__init__(self, parent)
self.layout = QtWidgets.QHBoxLayout(self)
self.min_sb = QtWidgets.QDoubleSpinBox(self)
self.min_sb.setMaximumSize(QtCore.QSize(72, 16777215))
self.min_sb.setDecimals(decimals)
self.min_sb.setMinimum(-999999999.0)
self.min_sb.setMaximum(999999999.0)
self.min_sb.setSingleStep(stepsize)
self.min_sb.setValue(min_value)
self.layout.addWidget(self.min_sb)
# See PANEL-3561 for developing a single double slider widget. When
# implemented, we should use that widget here instead.
self.slider = canvas2d.ChmDoubleSlider(self)
self.slider.setMinimumWidth(120)
self.slider.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Fixed)
self.layout.addWidget(self.slider, stretch=2)
self.max_sb = QtWidgets.QDoubleSpinBox(self)
self.max_sb.setMaximumSize(QtCore.QSize(72, 16777215))
self.max_sb.setDecimals(decimals)
self.max_sb.setMinimum(-999999999.0)
self.max_sb.setMaximum(999999999.0)
self.max_sb.setSingleStep(stepsize)
self.max_sb.setValue(max_value)
self.layout.addWidget(self.max_sb)
self.min_range = range_min
self.max_range = range_max
self.default_min_range = range_min
self.default_max_range = range_max
self.slider.setRange(self.min_range, self.max_range)
self.setContentsMargins(0, 0, 0, 0)
self.min_sb.valueChanged.connect(self._minValueChanged)
self.max_sb.valueChanged.connect(self._maxValueChanged)
self.slider.valuesChanged.connect(self._slidersMoved)
self.slider.mouseReleased.connect(self._slidersReleased)
if layout:
layout.addWidget(self)
if command:
self.cutoffChanged.connect(command)
if not nocall:
self.cutoffChanged.emit()
self.default_min_val = min_value
self.default_max_val = max_value
self.has_been_shown = False
[docs] def showEvent(self, event):
""" See parent class for documentation """
# Re-enforce the current min/max because at creation time the size of
# the slider is indeterminate so the slider positions are wrong.
# MATSCI-10365
if not self.has_been_shown:
from schrodinger.ui.qt import utils as qtutils
# We use suppress_signals to suppress the native Qt signals,
# and emit=False to suppress the manually emitted signal
with qtutils.suppress_signals(self, self.slider, self.min_sb,
self.max_sb):
self._minValueChanged(self.min_sb.value(), emit=False)
self._maxValueChanged(self.max_sb.value(), emit=False)
self.has_been_shown = True
return super().showEvent(event)
[docs] def setStepSize(self, stepsize):
"""
Set the stepsize values for both spinboxes
:type stepsize: float
:param stepsize: The new stepsize
"""
self.min_sb.setSingleStep(stepsize)
self.max_sb.setSingleStep(stepsize)
[docs] def setRange(self, min_range, max_range):
self.min_range = min_range
self.max_range = max_range
# Work around for a bug where ChmDoubleSlider can't handle min and max
# being the same:
if min_range == max_range:
min_range -= 0.00001
max_range += 0.00001
self.slider.setRange(min_range, max_range)
self._slidersMoved()
self.update()
[docs] def getRange(self):
return self.min_range, self.max_range
[docs] def setValues(self, lpos, rpos):
self.slider.leftPos(lpos)
self.slider.rightPos(rpos)
self._slidersMoved()
[docs] def getValues(self):
min_v = self.min_sb.value()
max_v = self.max_sb.value()
return min_v, max_v
[docs] def getTrueValues(self):
"""
Get the actual min and max values rather than that represented by the
spin box. The two may differ because the spinbox values are truncated at
a certain number of decimal points
:rtype: (float, float)
:return: The values of the left and right sliders
"""
lpos = self.slider.leftPos()
rpos = self.slider.rightPos()
return lpos, rpos
def _slidersMoved(self):
if not self.slider.isEnabled():
# Work-around for SHARED-6739
self.slider.blockSignals(True)
self.slider.leftPos(self.min_sb.value())
self.slider.rightPos(self.max_sb.value())
self.slider.blockSignals(False)
lpos = self.slider.leftPos()
rpos = self.slider.rightPos()
self.min_sb.blockSignals(True)
self.max_sb.blockSignals(True)
self.min_sb.setValue(lpos)
self.max_sb.setValue(rpos)
self.min_sb.blockSignals(False)
self.max_sb.blockSignals(False)
self.cutoffChanged.emit()
def _slidersReleased(self):
self.cutoffChanged.emit()
def _minValueChanged(self, new_value, emit=True):
"""
React to a change in the minimum value spinbox
:param float new_value: The new minimum value
:param bool emit: Whether to emit the cutoffChanged signal
"""
current = new_value
if new_value < self.min_range:
new_value = self.min_range
rpos = self.slider.rightPos()
if new_value > rpos:
new_value = rpos
self.slider.leftPos(new_value)
if not new_value == current:
self.min_sb.blockSignals(True)
self.min_sb.setValue(new_value)
self.min_sb.blockSignals(False)
if emit:
self.cutoffChanged.emit()
def _maxValueChanged(self, new_value, emit=True):
"""
React to a change in the maximum value spinbox
:param float new_value: The new maximum value
:param bool emit: Whether to emit the cutoffChanged signal
"""
current = new_value
if new_value > self.max_range:
new_value = self.max_range
lpos = self.slider.leftPos()
if new_value < lpos:
new_value = lpos
self.slider.rightPos(new_value)
if new_value != current:
self.max_sb.blockSignals(True)
self.max_sb.setValue(new_value)
self.max_sb.blockSignals(False)
if emit:
self.cutoffChanged.emit()
[docs] def reset(self):
"""
Reset the spinboxes and sliders to their original value. Only the
values are reset, not other parameters (consistent with other swidgets
such as spinboxes
"""
self.setRange(self.default_min_range, self.default_max_range)
self.min_sb.setValue(self.default_min_val)
self.max_sb.setValue(self.default_max_val)
[docs]class EditWithFocusOutEvent(SLabeledEdit):
"""
SLabeledEdit that calls 'focus_out_command' on returnPressed() and on
'focusOutEvent' with modified text.
"""
[docs] def __init__(self, *args, **kwargs):
"""
Initialize EditWithFocusOutEvent.
:type focus_out_command: method
:keyword focus_out_command: Method to be called when focus out event
occurs
"""
self.focus_out_command = kwargs.pop('focus_out_command', None)
SLabeledEdit.__init__(self, *args, **kwargs)
if self.focus_out_command:
# Call 'focus_out_command' on Return or Enter pressed (MATSCI-6329)
self.returnPressed.connect(self.focus_out_command)
[docs] def focusOutEvent(self, event):
"""
Call self.focus_out_command when text modified, if present.
This does NOT create race conditions with SLineEdit.eventFilter
for always_valid=True. And the focus_out_command is only executed
when the text is changed.
:type event: QEvent
:param event: Focus out event
"""
if self.focus_out_command and self.isModified():
self.focus_out_command()
self.setModified(False)
SLabeledEdit.focusOutEvent(self, event)
[docs]class SLabeledSlider(QtWidgets.QSlider):
"""
A slider with text label to the left of it and a value label to the right of
it that gives the current slider value. An additional label to the right of
the value label ("after_label") can be used for units, etc. The slider can
be used to control and display integer or floating point data.
The widget has a mylayout property that points to the internal layout that
contains all the widgets. This layout is added to the layout provided by the
"layout" keyword, or can be manually added to a layout after creation.
"""
[docs] def __init__(self,
text,
parent=None,
minimum=1,
maximum=100,
stepsize=1,
ticks=True,
tick_interval=25,
value=50,
float_step=1,
float_decimals=2,
layout=None,
command=None,
nocall=True,
stretch=True,
after_label=None,
value_label=True,
orientation=HORIZONTAL,
tip=None):
"""
Create an SLabeledSlider object
:type text: str or None
:keyword text: The text of the label - if None, no leading label is
created
:type parent: QObject
:param parent: the parent object of this SLabeledSlider
:type minimum: int
:param minimum: The minimum value of this SLabeledSlider
:type maximum: int
:param maximum: The maximum value of this SLabeledSlider
:type stepsize: int
:param stepsize: The amount the value changes each time an arrow key is
pressed
:type tick_interval: int
:param tick_interval: The interval between tick marks
:type value: int
:param value: The initial value of this SLabeledSlider. This value will
also be used as the default value by the reset method.
:type float_step: float
:param float_step: QSliders only deal with integer values but often need
to control float values. Use float_step to provide the factor that the
slider integer value gets multiplied by to get the float value. This
floating point value is displayed in the value_label and returned
by the floatValue method.
:type float_decimals: int
:param float_decimals: The number of decimal places to show in the
value_label if the value_label is displayed and float_step is provided.
:type layout: QLayout
:param layout: If supplied, the SLabeledSlider created will be added to
this layout
:type command: python callable
:param command: The callback for the valueChanged signal. This command
will not be called during initialization unless nocall is set to False.
:type nocall: bool
:param nocall: True (default) if the callback command should not be
called during initialization, False if the command should be called.
:type stretch: bool
:keyword stretch: Whether to put a stretch after the SLabeledSlider
(or after the after_label). Default is True.
:type after_label: str
:keyword after_label: Label text to put after the SLabeledSlider -
default is None
:type value_label: bool
:keyword value_label: If True, place a label directly after the slider
that gives the current value of the slider. Units can be added to this
value via the after_label keyword. The after_label will be placed
immediately after the value_label.
:type orientation: str
:param orientation: The orientation of the slider. Should be one of the
module constants VERTICAL or HORIZONTAL.
:type tip: str
:param tip: The tooltip to apply to the labels and slider
"""
if orientation == VERTICAL:
orientation = Qt.Vertical
else:
orientation = Qt.Horizontal
QtWidgets.QSlider.__init__(self, orientation, parent)
# Must create value_label attribute before calling setMaximum
self.value_label = None
self.setMinimum(minimum)
self.setMaximum(maximum)
self.setValue(value)
self.setSingleStep(stepsize)
if ticks:
self.setTickInterval(tick_interval)
self.setTickPosition(self.TicksBelow)
else:
self.setTickPosition(self.NoTicks)
self.float_step = float_step
self.float_decimals = float_decimals
self.default_value = value
self.mylayout = SHBoxLayout(layout=layout)
if text:
self.label = SLabel(text, layout=self.mylayout)
else:
self.label = None
self.mylayout.addWidget(self)
if value_label:
self.value_label = SLabel("", layout=self.mylayout)
self.setupValueLabel()
else:
self.value_label = None
if after_label:
self.after_label = SLabel(after_label, layout=self.mylayout)
else:
self.after_label = None
if stretch:
self.mylayout.addStretch()
if command:
self.valueChanged.connect(command)
if not nocall:
command()
if tip:
self.setToolTip(tip)
# Allow changing values through arrow keys, PageUp/PageDown, and
# Home/End when the slider is clicked
self.setFocusPolicy(QtCore.Qt.ClickFocus)
[docs] def setEnabled(self, state):
"""
Set the slider's and labels' enabled states
:type state: bool
:param state: Whether the slider and labels are enabled or not
"""
QtWidgets.QSlider.setEnabled(self, state)
if self.label:
self.label.setEnabled(state)
if self.value_label:
self.value_label.setEnabled(state)
if self.after_label:
self.after_label.setEnabled(state)
[docs] def setMaximum(self, maximum):
"""
Set the maximum value possible for the slider
:type maximum: int
:param maximum: The maximum value for the slider
"""
QtWidgets.QSlider.setMaximum(self, maximum)
# Ensure the value label is the proper width
if not self.value_label:
return
self._setValueLabelWidth()
[docs] def reset(self):
"""
Reset the value of the slider
"""
self.setValue(self.default_value)
[docs] def setupValueLabel(self):
"""
Set up the value label. Can be called to setup a label in a custom
layout as the widget's value label
"""
self._setValueLabelWidth()
self.updateValueLabel()
self.valueChanged.connect(self.updateValueLabel)
def _setValueLabelWidth(self):
"""
Set the widget of the value label to a fixed width that accommodates its
largest value. This helps prevent the label (and other widgets) from
bouncing left/right as the value in the label changes
"""
if not self.value_label:
return
previous = self.value_label.text()
value = self.maximum() * self.float_step
self._setValueLabelText(value)
hint = self.value_label.sizeHint()
self.value_label.setFixedWidth(hint.width())
self.value_label.setText(previous)
def _setValueLabelText(self, value):
"""
Set the text in the value label to a new value
:type value: int or float
:param value: The new value for the label
"""
if self.float_step != 1:
self.value_label.setText('%.*f' % (self.float_decimals, value))
else:
self.value_label.setText(str(value))
[docs] def updateValueLabel(self):
"""
Set the value label text based on the current slider value
"""
if self.value_label:
if self.float_step != 1:
current = self.floatValue()
else:
current = self.value()
self._setValueLabelText(current)
[docs] def floatValue(self):
"""
Get the current float value. The float value differs from the slider value
in that the slider value is an integer, while the float value is a float
calculated by multiplying float_step by the integer value.
:rtype: float
:return: The current widget float value
"""
return self.float_step * self.value()
[docs] def setFloatValue(self, float_value):
"""
Set the current float value of the slider. The value gets rounded to the
nearest acceptable slider value.
:param float float_value: The value to set
"""
self.setValue(self.toInt(float_value))
[docs] def toInt(self, float_value):
"""
Convert a float value to the equivalent int value for the slider
:param float float_value: The value to convert
:rtype: int
:return: The integer equivalent of float_value
"""
return round(float_value / self.float_step)
[docs] def setFloatMaximum(self, float_maximum):
"""
Set the maximum float value of the slider. The maximum gets rounded to
the nearest acceptable slider value.
:param float float_maximum: The new maximum
"""
self.setMaximum(self.toInt(float_maximum))
[docs] def setFloatMinimum(self, float_minimum):
"""
Set the minimum float value of the slider. The minimum gets rounded to
the nearest acceptable slider float value.
:param float float_minimum: The new minimum
"""
self.setMinimum(self.toInt(float_minimum))
[docs] def setVisible(self, state):
"""
Set all child widgets to the visible state
:type state: bool
:param state: True if widgets should be visible, False if not
"""
super().setVisible(state)
if self.value_label:
self.value_label.setVisible(state)
if self.label:
self.label.setVisible(state)
if self.after_label:
self.after_label.setVisible(state)
[docs]class PlaceholderComboMixin:
"""
A QtWidgets.QComboBox mixin which allows for a placeholder text to be set
without the need to make the combobox editable.
The placeholder text is shown in italics when the current index is -1
"""
[docs] def __init__(self,
parent=None,
placeholder="Please select an option...",
**kwargs):
super().__init__(parent, **kwargs)
self._placeholder_text = placeholder
[docs] def paintEvent(self, event):
"""
This reimplements QComboBox.paintEvent based on the C++ implementation.
It uses italic font to paint _placeholder_text when the index is -1
"""
painter = QtWidgets.QStylePainter(self)
painter.setPen(self.palette().color(QtGui.QPalette.Text))
opt = QtWidgets.QStyleOptionComboBox()
self.initStyleOption(opt)
if self.currentIndex() == -1:
# Paint the placeholder text in italics
font = painter.font()
font.setItalic(True)
painter.setFont(font)
opt.currentText = self._placeholder_text
# draw the combobox frame, focusrect, selected, etc.
painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, opt)
# draw the icon and text
painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, opt)
[docs]class StyleMixin:
"""
In order to style custom QWidget subclasses using stylesheet, this custom
paintEvent method must be implemented.
"""
[docs] def paintEvent(self, e):
"""
See `QtWidgets.QWidget` documentation for more information.
"""
opt = QtWidgets.QStyleOption()
opt.initFrom(self)
p = QtGui.QPainter(self)
self.style().drawPrimitive(QtWidgets.QStyle.PE_Widget, opt, p, self)
[docs]class SSplitter(QtWidgets.QSplitter):
[docs] def __init__(self,
orientation=Qt.Horizontal,
collapsible=False,
expanding=True,
layout=None,
widgets=None):
"""
A customized QSplitter widget
:param enum orientation: Either Qt.Horiztonal or Qt.Vertical
:param bool collapsible: Whether the panes can be collapsed or not
:param bool expanding: Whether the splitter can expand in the direction
opposite of the splitter orientation
:param `QtWidgets.QBoxLayout` layout: The layout to place this into
:param list widgets: A list of widgets to add to the splitter
"""
super().__init__(orientation)
self.setChildrenCollapsible(collapsible)
if expanding:
policy = self.sizePolicy()
if self.orientation() == Qt.Horizontal:
policy.setVerticalPolicy(policy.Expanding)
else:
policy.setHorizontalPolicy(policy.Expanding)
self.setSizePolicy(policy)
if widgets:
for widget in widgets:
self.addWidget(widget)
# Default to the same size for each widget. Note that contrary to
# the Qt docs, if the size value is smaller than the actual widget
# size, they will not be set to the same size. Only values larger
# than the actual size result in equal-size panes.
self.setSizes([10000] * len(widgets))
if layout is not None:
layout.addWidget(self)