Source code for schrodinger.application.msv.gui.color_widgets
"""
Widgets for the "Define Custom Color Scheme" dialog and color popup.
"""
import copy
from schrodinger.application.msv.gui import color
from schrodinger.infra import util as infra_util
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import delegates
from schrodinger.ui.qt import pop_up_widgets
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt import table_helper
[docs]class ColorSchemeComboBoxMixin:
"""
A mixin for QComboBoxes that list color schemes.
"""
_SEPARATOR = object()
CUSTOM = "Custom"
_COLOR_SEQ_OPTS = (
color.SideChainChemistryScheme,
color.ResidueTypeScheme,
color.SimilarityScheme,
color.HydrophobicityKDScheme,
color.HydrophobicityHWScheme,
color.TaylorScheme,
color.ClustalXScheme,
color.SecondaryStructureScheme,
color.BFactorScheme,
_SEPARATOR,
color.HelixPropensityScheme,
color.StrandPropensityScheme,
color.TurnPropensityScheme,
color.ExposureTendencyScheme,
color.StericGroupScheme,
_SEPARATOR,
color.WorkspaceScheme,
color.ChainNameScheme,
color.PositionScheme,
color.ResidueChargeScheme,
CUSTOM
) # yapf: disable
def _populateComboBox(self,
show_workspace,
items_to_remove=(),
items_to_add=()):
"""
Populate the combo box using `self._COLOR_SEQ_OPTS`. Should be called
in `__init__`.
:param show_workspace: Whether to include the workspace color scheme in
the combo box.
:type show_workspace: bool
:param items_to_remove: Items from `self._COLOR_SEQ_OPTS` that should
not be included in the combo box.
:type items_to_remove: Iterable
:param items_to_add: Additional items to include in the combo box.
:type items_to_add: Iterable
"""
to_add = list(self._COLOR_SEQ_OPTS)
if not show_workspace:
to_add.remove(color.WorkspaceScheme)
for item in items_to_remove:
to_add.remove(item)
to_add.extend(items_to_add)
self._custom_index = to_add.index(self.CUSTOM)
self._custom_shown = False
to_add.remove(self.CUSTOM)
self._addItemsToComboBox(to_add)
return to_add
def _addItemsToComboBox(self, to_add):
"""
Add items to the combo box. The list passed in should be based on
`self._COLOR_SEQ_OPTS`.
:param to_add: A list of items to add. Each item may be either a color
scheme, a string, or `_SEPARATOR`.
:type to_add: Iterable(color.AbstractRowColorScheme or str or object)
"""
for i, item in enumerate(to_add):
if item is self._SEPARATOR:
self.insertSeparator(i)
elif isinstance(item, str):
# "Custom" or "Define Custom Scheme..."
self.addItem(item, item)
elif issubclass(item, color.AbstractRowColorScheme):
self.addItem(item.NAME, item)
else:
raise RuntimeError("Unrecognized option")
def _showCustom(self, value):
"""
Show the "Custom" option in the combo box.
:param value: The Qt.UserRole data to set for the custom option.
:type value: object
"""
if self._custom_shown:
self.setItemData(self._custom_index, value)
else:
self.insertItem(self._custom_index, self.CUSTOM, value)
self._custom_shown = True
def _hideCustom(self):
"""
Hide the custom option in the combo box.
"""
if self._custom_shown:
self.removeItem(self._custom_index)
self._custom_shown = False
[docs]class ColorSchemeComboBox(ColorSchemeComboBoxMixin, swidgets.SComboBox):
"""
The "Start with:" combo box in the "Define Custom Color Scheme" dialog.
"""
[docs] def __init__(self, parent=None):
super().__init__(parent)
# We can't customize the workspace scheme, so that's always omitted.
self._populateComboBox(show_workspace=False)
[docs] def currentScheme(self, ramp=None):
"""
Return an instance of the currently selected color scheme.
:param ramp: The color ramp to use for the scheme. Only has an effect
for the B-factor and Residue Position schemes. Will be ignored
otherwise. If not given, the existing color ramp will be used (i.e.
for custom schemes, the ramp the scheme was defined with; for non-
custom schemes, the default ramp).
:type ramp: schrodinger.structutils.ColorRamp
:return: The current color scheme
:rtype: color.AbstractRowColorScheme
"""
if self.currentText() == self.CUSTOM:
custom_scheme = self.currentData()
if (isinstance(custom_scheme, color.AbstractColorRampOnlyScheme) and
ramp is not None):
scheme_cls = type(custom_scheme)
return scheme_cls(ramp, custom=True)
else:
return copy.deepcopy(self.currentData())
else:
scheme_cls = self.currentData()
if issubclass(scheme_cls, color.AbstractColorRampOnlyScheme):
return scheme_cls(ramp, custom=True)
else:
return scheme_cls(custom=True)
[docs] def setCurrentScheme(self, scheme):
"""
Set the current item to be the specified color scheme. If a custom
color scheme is passed in, the custom scheme must have been set via
`setCustomScheme`.
:param scheme: The color scheme
:type scheme: color.AbstractRowColorScheme
"""
if scheme.custom:
if not self._custom_shown:
raise ValueError("No custom scheme set")
self.setCurrentIndex(self._custom_index)
else:
self.setCurrentData(type(scheme))
[docs] def clearCustomScheme(self):
"""
Remove the custom color scheme option from the combo box.
"""
self._hideCustom()
[docs] def setCustomScheme(self, scheme):
"""
Add a custom color scheme option to the combo box. Calling
`currentScheme` while "Custom" is selected in the combo box will return
a copy of this scheme.
:param scheme: The custom color scheme
:type scheme: color.AbstractRowColorScheme
"""
self._showCustom(scheme)
class ColorSchemeColumns(table_helper.TableColumns):
Key = table_helper.Column("Value/Range")
Color = table_helper.Column("Color", editable=True)
[docs]class ColorSchemeTableModel(table_helper.RowBasedTableModel):
"""
:cvar schemeChanged: A signal emitted when the color scheme changes.
Emitted with the new scheme.
:vartype schemeChanged: QtCore.pyqtSignal(color.AbstractRowColorScheme)
"""
Column = ColorSchemeColumns
schemeChanged = QtCore.pyqtSignal(color.AbstractRowColorScheme)
[docs] def __init__(self, parent=None):
super().__init__(parent)
self._keys = []
self._scheme = None
[docs] @table_helper.model_reset_method
def loadData(self, scheme):
"""
Load in a copy of the specified scheme.
:param scheme: The color scheme to load.
:type scheme: color.AbstractRowColorScheme
"""
scheme = copy.deepcopy(scheme)
self._keys = scheme.getKeys()
self._rows = self._scheme = scheme
self.schemeChanged.emit(copy.deepcopy(scheme))
[docs] @table_helper.model_reset_method
def clearData(self):
"""
Clear all data from this model.
"""
self._keys = []
self._rows = []
self._scheme = None
[docs] def rowCount(self, parent=None):
# See Qt documentation for method documentation
return len(self._keys)
def _getRowFromIndex(self, index):
# See RowBasedModelMixin for method documentation.
return self._keys[index.row()]
@table_helper.data_method(Qt.DisplayRole)
def _displayData(self, col, key):
if col == self.Column.Key:
return self._scheme.prettyKeyName(key)
@table_helper.data_method(Qt.DecorationRole)
def _decorationData(self, col, key):
if col == self.Column.Color:
return self._scheme.getBrushByKey(key).color()
def _setData(self, col, key, value, role, row_num):
# See RowBasedModelMixin for method documentation.
if col != self.Column.Color or role != Qt.EditRole:
return False
self._scheme.setColor(key, value.red(), value.green(), value.blue())
self.schemeChanged.emit(copy.deepcopy(self._scheme))
return True
[docs]class ColorDelegate(delegates.AbstractCustomDelegate):
[docs] def __init__(self, parent=None):
super().__init__(parent)
self._outline_pen = QtGui.QPen(Qt.darkGray, 1)
[docs] def initStyleOption(self, option, index):
super().initStyleOption(option, index)
decoration_data = index.data(Qt.DecorationRole)
if decoration_data is not None:
# QTreeView::initStyleOption will convert Qt::DecorationRole colors
# into icons, but we want an outline around the icon so we generate
# it ourselves
size = option.decorationSize
pixmap = QtGui.QPixmap(option.decorationSize)
painter = QtGui.QPainter(pixmap)
painter.setPen(self._outline_pen)
painter.setBrush(decoration_data)
rect = QtCore.QRect(0, 0, size.width() - 1, size.height() - 1)
painter.drawRect(rect)
painter.end()
option.icon = QtGui.QIcon(pixmap)
[docs] def paint(self, painter, option, index):
option, style = self.paintItemBackground(painter, option, index)
rect = style.alignedRect(Qt.LeftToRight, Qt.AlignCenter,
option.decorationSize, option.rect)
icon = option.icon
selected = option.state & style.State_Selected
mode = icon.Selected if selected else icon.Normal
icon.paint(painter, rect, mode=mode)
[docs] def updateEditorGeometry(self, editor, option, index):
# our editor is a separate dialog, not a widget within the same window,
# so we don't want to set its geometry
pass
[docs] def setEditorData(self, editor, index):
cur_color = index.data(Qt.DecorationRole)
editor.setCurrentColor(cur_color)
[docs] def setModelData(self, editor, model, index):
if editor.result() == editor.Accepted:
model.setData(index, editor.selectedColor())
[docs]class ColorSchemeTableView(QtWidgets.QTableView):
Column = ColorSchemeColumns
[docs] def __init__(self, parent=None):
super().__init__(parent)
self._color_delegate = ColorDelegate(self)
self.setSelectionBehavior(self.SelectRows)
self.setEditTriggers(self.AllEditTriggers)
self.setAlternatingRowColors(True)
self.verticalHeader().hide()
[docs] def setModel(self, model):
super().setModel(model)
self.setItemDelegateForColumn(self.Column.Color, self._color_delegate)
header = self.horizontalHeader()
header.setSectionResizeMode(self.Column.Key, header.Stretch)
[docs]class EditMinMaxPopUp(pop_up_widgets.PopUp):
"""
A pop up for editing the minimum and maximum colored values for a residue
property color scheme color ramp. Appears when the user clicks on
`EditMinMaxToolButton`.
"""
dataChanged = QtCore.pyqtSignal(float, float)
[docs] def setup(self):
# We do this import here to avoid a circular import that fails if and
# only if color_widgets gets imported before anything from the dialogs
# directory
from schrodinger.application.msv.gui.dialogs import edit_min_max_ui
self._aln_min = 0
self._aln_max = 0
self._cur_min = 0
self._cur_max = 0
self.ui = edit_min_max_ui.Ui_Form()
self.ui.setupUi(self)
self.ui.accept_btn.clicked.connect(self._onAccepted)
self.ui.detect_lbl.linkActivated.connect(self.restoreAlnValues)
self._validator = QtGui.QDoubleValidator(self)
self.ui.min_le.setValidator(self._validator)
self.ui.max_le.setValidator(self._validator)
self.ui.min_le.textEdited.connect(self._updateButtonsEnabled)
self.ui.max_le.textEdited.connect(self._updateButtonsEnabled)
self.ui.min_le.returnPressed.connect(self._onAccepted)
self.ui.max_le.returnPressed.connect(self._onAccepted)
def _onAccepted(self):
"""
Update the color scheme when the user clicks on the accept button or
hits enter on one of the line edits. Has no effect if either of the
line edits are empty.
"""
try:
min_val = float(self.ui.min_le.text())
max_val = float(self.ui.max_le.text())
except ValueError:
return
if min_val > max_val:
min_val, max_val = max_val, min_val
self.dataChanged.emit(min_val, max_val)
self.close()
def _updateButtonsEnabled(self):
"""
Update whether the accept button and "Detect" link are enabled. The
accept button is only enabled if the current values are valid (i.e.
non-empty) and different from the current color ramp settings. The
"Detect" link is only enabled if the current values are different from
the alignment.
"""
try:
min_val = float(self.ui.min_le.text())
max_val = float(self.ui.max_le.text())
except ValueError:
self.ui.accept_btn.setEnabled(False)
self.ui.detect_lbl.setEnabled(True)
return
matches_cur_vals = (abs(min_val - self._cur_min) < 10e-10 and
abs(max_val - self._cur_max) < 10e-10)
self.ui.accept_btn.setEnabled(not matches_cur_vals)
matches_aln_vals = (abs(min_val - self._aln_min) < 10e-10 and
abs(max_val - self._aln_max) < 10e-10)
self.ui.detect_lbl.setEnabled(not matches_aln_vals)
# Setting the label to disabled doesn't have any effect on the
# appearence of links (at least on Windows), so we remove the hyperlink
# markup when we disable the label
if matches_aln_vals:
self.ui.detect_lbl.setText("Detect")
else:
self.ui.detect_lbl.setText('<a href>Detect</a>')
def _setLineEditVals(self, min_val, max_val):
"""
Set the minimum and maximum values to display in the line edits.
:param min_val: The minimum value
:type min_val: float or int
:param max_val: The maximum value
:type max_val: float or int
"""
self.ui.min_le.setText(f"{min_val:.8g}")
self.ui.max_le.setText(f"{max_val:.8g}")
[docs] def setMinMax(self, min_val, max_val):
"""
Set the minimum and maximum values to display in the line edits based on
the current color ramp settings. The accept button will be enabled when
the user-entered values difer from these values.
:param min_val: The minimum value
:type min_val: float or int
:param max_val: The maximum value
:type max_val: float or int
"""
self._setLineEditVals(min_val, max_val)
self._cur_min = min_val
self._cur_max = max_val
self._updateButtonsEnabled()
[docs] def setAlnMinMax(self, min_val, max_val):
"""
Set the minimum and maximum values from the alignment. The "Detect"
link will be enabled when the user-entered values differ from the
alignment values.
:param min_val: The minimum value
:type min_val: float or int
:param max_val: The maximum value
:type max_val: float or int
"""
self._aln_min = min_val
self._aln_max = max_val
self._updateButtonsEnabled()
[docs] def restoreAlnValues(self):
"""
Put the minimum and maximum values from the alignment into the line
edits.
"""
self._setLineEditVals(self._aln_min, self._aln_max)
self._updateButtonsEnabled()
[docs]class EditMinMaxToolButton(pop_up_widgets.ToolButtonWithPopUp):
"""
A toolbutton for editing the minimum and maximum colored values for a
residue property color scheme color ramp. Launches the `EditMinMaxPopUp`
when clicked.
.. note::
`setAlnMinMax` and `setMinMax` must be called before showing the pop up.
"""
valuesChanged = QtCore.pyqtSignal(float, float)
_changingValues = infra_util.flag_context_manager("_changing_values")
[docs] def __init__(self, parent):
super().__init__(parent, EditMinMaxPopUp, Qt.NoArrow)
self._changing_values = False
[docs] @infra_util.skip_if("_changing_values")
def popUpUpdated(self, min_val, max_val):
with self._changingValues():
self.valuesChanged.emit(min_val, max_val)
[docs] def setMinMax(self, min_val, max_val):
"""
Set the minimum and maximum values to display in the line edits based on
the current color ramp settings. The accept button will be enabled when
the user-entered values difer from these values.
:param min_val: The minimum value
:type min_val: float or int
:param max_val: The maximum value
:type max_val: float or int
"""
self._pop_up.setMinMax(min_val, max_val)
[docs] def setAlnMinMax(self, min_val, max_val):
"""
Set the minimum and maximum values from the alignment. The "Detect"
link will be enabled when the user-entered values differ from the
alignment values.
:param min_val: The minimum value
:type min_val: float or int
:param max_val: The maximum value
:type max_val: float or int
"""
self._pop_up.setAlnMinMax(min_val, max_val)