Source code for schrodinger.ui.qt.filter_dialog_dir.filter_widgets
import itertools
import math
from enum import Enum
from collections import OrderedDict
from collections import defaultdict
import numpy
from schrodinger import structure
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.ui.qt import propertyselector
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt.filter_dialog_dir.filter_core import Criterion
from schrodinger.ui.qt.filter_dialog_dir.filter_core import Filter
from . import filter_widget_ui
[docs]class NumericOperator(Enum):
BETWEEN = 'numeric_between'
LESS = 'numeric_less'
GREATER = 'numeric_greater'
NUMERIC_CRITERIA = OrderedDict([('between (inclusive)',
NumericOperator.BETWEEN),
('less than', NumericOperator.LESS),
('greater than', NumericOperator.GREATER)])
TEXT_CRITERIA = OrderedDict([('contains', TextOperator.CONTAINS)])
BOOL_CRITERIA = OrderedDict([('has value True', BoolOperator.TRUE),
('has value False', BoolOperator.FALSE)])
MAX_VALUE = numpy.inf
DEFAULT_DECIMALS = 2
PROP_TYPE_MAP = {'i': int, 'r': float, 's': str, 'b': bool}
[docs]def data_name_to_view_name(prop):
"""
Get a display/view/user name from a data name of a CT property.
"""
return structure.PropertyName(prop).userName()
[docs]def adjust_min_max_values(min_val, max_val, decimals):
"""
This function returns adjusted min/max values based on the
number of decimal places that are displayed in widgets.
:param min_val: original minimum value
:type min_val: float, int or None
:param max_val: original maximum value
:type max_val: float, int or None
:param decimals: number of decimal points to display in min/max
spin boxes or the double slider (default value is 2)
:type decimals: int
:return: tuple that contains new minimum and maximum values
:rtype: tuple
"""
new_min_val = None
new_max_val = None
exp = pow(10, decimals)
if min_val is not None:
new_min_val = math.floor(min_val * exp) / exp
if max_val is not None:
new_max_val = math.ceil(max_val * exp) / exp
return new_min_val, new_max_val
[docs]class BaseCriterion(QtCore.QObject):
"""
Base class representing single property criterion and its widgets.
Criterion widgets would include property checkbox, criterion type
combo box, criterion limiter widget and a gear button. Subclasses
should define criterion limiter widget since it changes with property
type: dual slider or two spin boxes for numeric types, line edit for
string type and none for boolean type.
:cvar CRITERION_CHOICES: criterion choices that would be shown in the
criterion type combo box, such as 'between (inclusive)' etc. This
variable should be defined in subclasses.
:type CRITERION_CHOICES: dict
:cvar criterionChanged: this signal is emitted when either property checkbox
state is changed or new criterion type is selected.
:type criterionChanged: QtCore.pyqtSignal
:cvar removeCriterion: this signal is emitted when user clicks 'Remove'
item in the gear menu.
:type removeCriterion: QtCore.pyqtSignal
"""
CRITERION_CHOICES = None
criterionChanged = QtCore.pyqtSignal()
removeCriterion = QtCore.pyqtSignal()
[docs] def __init__(self, parent, prop, checked, removable):
"""
Initializer
:param parent: parent widget
:type parent: QtWidgets.QWidget
:param prop: name of property used in this criterion
:type prop: str
:param checked: indicates whether this criterion is 'checked'.
:type checked: bool
:param removable: indicates whether this criterion can be removed
from the filter widget.
:type removable: bool
"""
super(BaseCriterion, self).__init__(parent)
self.prop = prop
self.checked = checked
self.removable = removable
self.createWidgets()
self._enabled = None
self.setEnabled(True)
[docs] def createWidgets(self):
"""
Creates widgets used in this criterion. Here we create criterion
type combo box and gear button. Limiter widget should be defined
in subclasses.
"""
self.limiter_widget = None
self.prop_cb = QtWidgets.QCheckBox(data_name_to_view_name(self.prop))
self.prop_cb.setChecked(self.checked)
self.prop_cb.stateChanged.connect(self.criterionChanged)
self.type_combo = swidgets.SComboBox(itemdict=self.CRITERION_CHOICES)
self.type_combo.currentIndexChanged.connect(self.criterionTypeChanged)
self.type_combo.currentIndexChanged.connect(self.criterionChanged)
# Tool button for managing the criteria:
self.gear_menu = QtWidgets.QMenu()
self.gear_menu.addAction("Reset", self.reset)
if self.removable:
self.gear_menu.addAction("Remove", self.removeCriterion)
self.gear_tool_btn = QtWidgets.QToolButton()
self.gear_tool_btn.setMinimumSize(QtCore.QSize(35, 0))
self.gear_tool_btn.setPopupMode(QtWidgets.QToolButton.InstantPopup)
self.gear_tool_btn.setAutoRaise(True)
self.gear_tool_btn.setArrowType(QtCore.Qt.NoArrow)
self.gear_tool_btn.setIcon(QtGui.QIcon(':/icons/small_settings.png'))
self.gear_tool_btn.setMenu(self.gear_menu)
[docs] def reset(self):
"""
Reset criterion. Additional reset functionality should be
implemented in subclasses.
"""
self.prop_cb.setChecked(False)
[docs] def getCriterion(self):
"""
Returns criterion object.
:return: criterion object
:rtype: Criterion
"""
return Criterion(self.prop, self.getLimits(), self.isChecked())
[docs] def valueMatches(self, value):
"""
Return True if the given value matches the criteria; False otherwise.
:param value: Value to test
:type value: float, int, or str
"""
return self.getCriterion().valueMatches(value)
[docs] def criterionTypeChanged(self):
"""
This function is called when criterion type is changed. This function
should be redefined in subclasses if any action should be taken.
"""
[docs] def widgets(self):
"""
Returns a list of widgets for this criterion.
:return: list of widgets
:rtype: list
"""
all_widgets = [self.prop_cb, self.type_combo, self.gear_tool_btn]
if self.limiter_widget is not None:
all_widgets.append(self.limiter_widget)
return all_widgets
[docs] def isChecked(self):
"""
Returns True if this criterion is toggled on and False otherwise.
:return: check state of this criterion
:rtype: bool
"""
return self.prop_cb.isChecked()
[docs] def setChecked(self, checked: bool):
"""
Set the property check box to either checked or unchecked.
"""
self.prop_cb.setChecked(checked)
@property
def prop_type(self):
for type_char, prop_type in PROP_TYPE_MAP.items():
if self.prop.startswith(type_char):
return prop_type
[docs] def setEnabled(self, enabled: bool):
"""
Set all child widgets enabled/disabled.
"""
for child in self.widgets():
child.setEnabled(enabled)
self._enabled = enabled
[docs] def isEnabled(self) -> bool:
"""
Return whether or not the child widgets are enabled/disabled.
"""
return self._enabled
[docs]class NumericCriterion(BaseCriterion):
"""
Numeric type (float or int) property criterion that uses two spin
box widgets or a dual slider.
"""
CRITERION_CHOICES = NUMERIC_CRITERIA
[docs] def __init__(self,
parent,
prop,
checked,
min_val,
max_val,
decimals=2,
use_dual_slider=True,
removable=True):
"""
Initializer
:param parent: parent object
:type parent: QtWidgets.QWidget
:param prop: property name
:type prop: str
:param min_val: minimum value
:type min_val: int or float
:param max_val: maximum value
:type max_val: int or float
:param decimals: number of decimal points to display in min/max
spin boxes or the double slider (default value is 2)
:type decimals: int
:param use_dual_slider: indicates whether dual slider widget should
be used when 'between (inclusive)' type is selected.
:type use_dual_slider: bool
:param removable: this argument indicates whether it should be
possible to remove criterion from the widget.
:type removable: bool
"""
self.min_val, self.max_val = adjust_min_max_values(
min_val, max_val, decimals)
self.decimals = decimals
self.use_dual_slider = use_dual_slider
super(NumericCriterion, self).__init__(parent, prop, checked, removable)
[docs] def createWidgets(self):
"""
See base class for documentation.
"""
super(NumericCriterion, self).createWidgets()
self.limiter_widget = QtWidgets.QStackedWidget()
# Create limiter widget that uses two double spin boxes.
self.limiter_frame = swidgets.SFrame(layout_type=swidgets.HORIZONTAL)
wlayout = self.limiter_frame.mylayout
self.min_sb = swidgets.SDoubleSpinBox(minimum=-MAX_VALUE,
maximum=MAX_VALUE,
value=self.min_val,
layout=wlayout,
decimals=self.decimals)
self.limiter_lbl = swidgets.SLabel('and', layout=wlayout)
self.max_sb = swidgets.SDoubleSpinBox(minimum=-MAX_VALUE,
maximum=MAX_VALUE,
value=self.max_val,
layout=wlayout,
decimals=self.decimals)
# Make a guess about double spinbox minimum size based on font used
# and typical property values. Extra padding is added for the frame
# that contains up and down arrows.
font = self.max_sb.lineEdit().font()
min_size = QtCore.QSize(
QtGui.QFontMetrics(font).width('100.00') + 25,
QtGui.QFontMetrics(font).height())
max_size = QtCore.QSize(200, 16777215)
size_policy = self.min_sb.sizePolicy()
size_policy.setHorizontalStretch(2)
for spin_box in [self.min_sb, self.max_sb]:
spin_box.setMinimumSize(min_size)
spin_box.setMaximumSize(max_size)
spin_box.setSizePolicy(size_policy)
spin_box.valueChanged.connect(self.criterionChanged)
if self.prop_type == int:
spin_box.setDecimals(0)
wlayout.addStretch()
# Add widgets to the stacked widget
self.limiter_widget.addWidget(self.limiter_frame)
if self.use_dual_slider:
# Create double slider widget if needed.
self.slider_widget = swidgets.SpinBoxDoubleSlider(
decimals=self.decimals)
self.slider_widget.setRange(self.min_val, self.max_val)
self.slider_widget.cutoffChanged.connect(self.criterionChanged)
if self.prop_type == int:
self.slider_widget.min_sb.setDecimals(0)
self.slider_widget.max_sb.setDecimals(0)
self.limiter_widget.addWidget(self.slider_widget)
self.limiter_widget.setCurrentWidget(self.slider_widget)
[docs] def reset(self):
# See base class for documentation.
super(NumericCriterion, self).reset()
self.min_sb.setValue(self.min_val)
self.max_sb.setValue(self.max_val)
if self.use_dual_slider:
self.slider_widget.setValues(*self.slider_widget.getRange())
self.type_combo.setCurrentData(NumericOperator.BETWEEN)
[docs] def getLimits(self):
"""
Returns tuple that contains minimum and maximum values. When type is
'less than' minimum value is None. When type is 'greater than'
maximum value is None. Will return None if a non-standard numeric
operator is selected.
:return: minimum and maximum values or None
:rtype: tuple or None
"""
criterion_type = self.type_combo.currentData()
if criterion_type == NumericOperator.BETWEEN:
if self.use_dual_slider:
return self.slider_widget.getValues()
else:
return self.min_sb.value(), self.max_sb.value()
elif criterion_type == NumericOperator.LESS:
return None, self.max_sb.value()
elif criterion_type == NumericOperator.GREATER:
return self.min_sb.value(), None
return None
[docs] def setLimits(self, values):
"""
Set minimum and maximum values for this criterion.
:param values: tuple that contains minimum and maximum values
:type values: tuple
"""
min_value, max_value = adjust_min_max_values(values[0], values[1],
self.decimals)
if min_value is None:
self.max_sb.setValue(max_value)
elif max_value is None:
self.min_sb.setValue(min_value)
else:
if self.use_dual_slider:
self.slider_widget.setValues(min_value, max_value)
else:
self.min_sb.setValue(min_value)
self.max_sb.setValue(max_value)
[docs] def criterionTypeChanged(self):
"""
This function is called when selection is changed in type combo box.
Depending on criterion type it shows appropriate widgets.
"""
self.limiter_widget.setCurrentWidget(self.limiter_frame)
criterion_type = self.type_combo.currentData()
if criterion_type == NumericOperator.BETWEEN:
for w in [self.min_sb, self.limiter_lbl, self.max_sb]:
w.setVisible(True)
if self.use_dual_slider:
self.limiter_widget.setCurrentWidget(self.slider_widget)
elif criterion_type == NumericOperator.LESS:
self.min_sb.setVisible(False)
self.limiter_lbl.setVisible(False)
self.max_sb.setVisible(True)
elif criterion_type == NumericOperator.GREATER:
self.min_sb.setVisible(True)
self.limiter_lbl.setVisible(False)
self.max_sb.setVisible(False)
[docs]class StringCriterion(BaseCriterion):
"""
String property criterion. Renders as an entry field. Asterisk (*) means
match anything. For example, "*" matches all values, and "1*0" will match
10, 1000, 150, etc. Question mark (?) matches any single character.
"""
CRITERION_CHOICES = TEXT_CRITERIA
[docs] def createWidgets(self):
# See base class for documentation.
super(StringCriterion, self).createWidgets()
self.limiter_widget = swidgets.SLineEdit()
self.limiter_widget.setToolTip('Type in the text to match')
self.limiter_widget.textChanged.connect(self.criterionChanged)
[docs] def reset(self):
# See base class for documentation.
super(StringCriterion, self).reset()
self.limiter_widget.setText('')
[docs] def getLimits(self):
"""
Returns text of 'contains' text box.
:return: criterion text
:rtype: str
"""
return self.limiter_widget.text()
[docs] def setLimits(self, value):
"""
Sets text that should be shown in 'contains' text box.
:param value: criterion text
:type value: str
"""
return self.limiter_widget.setText(value)
[docs]class BoolCriterion(BaseCriterion):
"""
Bool property criterion. It does not have a limiter widget. Instead,
limits is just either True or False depending on current selection in
criterion choices combo box widget.
"""
CRITERION_CHOICES = BOOL_CRITERIA
[docs] def reset(self):
# See base class for documentation.
super(BoolCriterion, self).reset()
self.type_combo.setCurrentData(BoolOperator.TRUE)
[docs] def getLimits(self):
"""
Returns True or False depending on whether 'has value True' or
'has value False' option is selected in type combo box.
:return: boolean value for this criterion
:rtype: bool
"""
return self.type_combo.currentData() == BoolOperator.TRUE
[docs] def setLimits(self, value):
"""
Sets boolean value for this criterion. This value is used to set
selection in type combo box.
:param value: boolean value
:type value: bool
"""
self.type_combo.setCurrentData(value)
[docs]class CriteriaLayout(QtWidgets.QGridLayout):
"""
Grid layout that contains widgets for the filter criteria.
"""
[docs] def addCriterionRow(self, criterion):
"""
Adds criterion widgets to the layout.
:param criterion: criterion object that contains widgets that will
be added to this layout.
:type criterion: BaseCriterion
"""
widget_counter = itertools.count(0)
row = self.rowCount()
self.addWidget(criterion.prop_cb, row, next(widget_counter))
self.addWidget(criterion.type_combo, row, next(widget_counter))
if criterion.limiter_widget:
self.addWidget(criterion.limiter_widget, row, next(widget_counter))
self.addWidget(criterion.gear_tool_btn, row, next(widget_counter))
hspacer = QtWidgets.QSpacerItem(0, 0, QtWidgets.QSizePolicy.Expanding)
self.addItem(hspacer, row, next(widget_counter))
[docs]class FilterWidget(QtWidgets.QWidget):
"""
Filter widget that is used in filter dialogs.
"""
criteriaChanged = QtCore.pyqtSignal()
[docs] def __init__(self,
parent=None,
use_dual_slider=True,
show_matches=True,
removable=True,
**kwargs):
"""
Class initializer.
:param parent: parent widget of this dialog.
:type parent: QWidget
:param use_dual_slider: indicates whether dual slider widget should
be shown for 'between (inclusive)' type.
:type use_dual_slider: bool
:param show_matches: determines whether label showing number of matches
should be shown. We need to hide it when using filter dialog and
no structures are available yet. For example, Custom R-group
Enumeration GUI.
:type show_matches: bool
:param removable: indicates whether criteria should be 'removable'.
:type removable: bool
"""
self.show_matches = show_matches
self.removable = removable
self.use_dual_slider = use_dual_slider
super(FilterWidget, self).__init__(parent=parent, **kwargs)
self.ui = filter_widget_ui.Ui_Form()
self.ui.setupUi(self)
self.setup()
[docs] def setup(self):
"""
Instantiate tab widget and tabs.
"""
self.ui.match_label.setVisible(self.show_matches)
# Setup criteria grid layout
self.scroll_area_layout = QtWidgets.QVBoxLayout(
self.ui.criteria_area_contents)
self.criteria_grid_layout = CriteriaLayout()
self.scroll_area_layout.addLayout(self.criteria_grid_layout)
self.ui.reset_all_btn.clicked.connect(self.resetAll)
self.values_by_lig = []
self.criteria = []
self.criteriaChanged.connect(self.updateMatchLabel)
[docs] def getCriteria(self):
"""
Returns list of filtering criteria.
:return: criteria list
:rtype: list
"""
return self.criteria
[docs] def addCriterion(self, prop, limiter=None, checked=True):
"""
Adds new criterion to this dialog.
:param prop: property name
:type prop: str
:param limiter: 'limiter' for this criterion, which is a filter string
for str types, a range of values for int/real types and True or False
value for boolean types.
:type limiter: str or Tuple[int, int] or Tuple[float, float] or
bool or None
:param checked: this argument indicates whether this criterion should
be toggled on or off.
:type checked: bool
"""
criterion = self.createCriterionForProp(prop, limiter, checked)
self.criteria_grid_layout.addCriterionRow(criterion)
criterion.criterionChanged.connect(self.criteriaChanged)
criterion.removeCriterion.connect(
lambda: self.removeCriterion(criterion))
self.criteria.append(criterion)
self.updateMatchLabel()
[docs] def removeCriterion(self, criterion_to_delete):
"""
Removes given criterion from filter dialog.
:param criterion_to_delete: criterion object
:type criterion_to_delete: BaseCriterion
"""
criterion_to_delete.criterionChanged.disconnect(self.criteriaChanged)
self.criteria.remove(criterion_to_delete)
for widget in criterion_to_delete.widgets():
idx = self.criteria_grid_layout.indexOf(widget)
layout_widget = self.criteria_grid_layout.takeAt(idx).widget()
if layout_widget is not None:
layout_widget.deleteLater()
del criterion_to_delete
# Clear grid layout
for cnt in reversed(range(self.criteria_grid_layout.count())):
widget = self.criteria_grid_layout.takeAt(cnt).widget()
# Add criteria rows back to grid layout
for criterion in self.criteria:
self.criteria_grid_layout.addCriterionRow(criterion)
self.updateMatchLabel()
[docs] def updateFilterObj(self):
"""
Updates filter object using current criteria settings.
"""
self.filter_obj.criteria = [
criterion.getCriterion() for criterion in self.criteria
]
[docs] def updateWidget(self, filter_obj, props_for_ligs):
"""
Updates filter widget for the given filter object and properties
present in the ligands.
:param filter_obj: filter object
:type filter_obj: Filter
:param props_for_ligs: list of property dictionaries that should be used
to construct this filter
:type props_for_ligs: list(dict(str, object))
"""
self.filter_obj = filter_obj
# Keys are property names; values are values used:
self.values_by_prop = defaultdict(list)
self.values_by_lig = []
# Go through each ligand:
for lig_prop_map in props_for_ligs:
self.values_by_lig.append(lig_prop_map)
for prop, value in lig_prop_map.items():
self.values_by_prop[prop].append(value)
self.criteria = []
for criterion in filter_obj.criteria:
self.addCriterion(criterion.prop, criterion.limiter,
criterion.checked)
# Add an "Add Property" action menu to the bottom of the frame
# only if filter criteria are 'removable'.
if self.removable:
self.add_prop_layout = QtWidgets.QHBoxLayout()
self.add_prop_btn = QtWidgets.QPushButton("Add Property...", self)
self.add_prop_btn.clicked.connect(self.addPropertyClicked)
self.add_prop_layout.addWidget(self.add_prop_btn)
self.add_prop_layout.addStretch()
self.scroll_area_layout.addLayout(self.add_prop_layout)
self.scroll_area_layout.addStretch(stretch=2)
self.criteriaChanged.emit()
[docs] def addPropertyClicked(self):
"""
Open a property selector dialog, for letting the user select a property
to filter by.
"""
used_names = [filt.prop for filt in self.criteria]
propnames = [p for p in self.values_by_prop if p not in used_names]
if not propnames:
QtWidgets.QMessageBox.warning(self, "Warning",
"No more properties to add")
return
dialog = propertyselector.PropertySelectorDialog(self,
multi=True,
accept_text='OK')
selected = dialog.chooseFromList(propnames)
if selected is None:
# User cancelled the dialog
return
for propname in selected:
# Cast to str to convert PropertyName to data name str:
self.addCriterion(str(propname))
self.adjustSize()
[docs] def createCriterionForProp(self, prop, limiter, checked):
"""
Create a criterion object for the give property, and initialize the
valid range/values based on properties in self.values_by_prop.
:param prop: property name
:type prop: str
:param limiter: 'limiter' for this criterion, which is a filter string
for str types, a range of values for int/real types and True or False
value for boolean types.
:type limiter: str or bool or tuple or None
:param checked: this argument indicates whether this criterion should
be toggled on or off.
:type checked: bool
"""
values = set(self.values_by_prop[prop])
if prop.startswith('s_'):
criterion = StringCriterion(self.ui.criteria_area_contents, prop,
checked, self.removable)
elif prop.startswith('b_'):
criterion = BoolCriterion(self.ui.criteria_area_contents, prop,
checked, self.removable)
else:
# int or float
if None in values:
values.remove(None)
decimals = DEFAULT_DECIMALS
if prop.startswith('i_'):
decimals = 0
criterion = NumericCriterion(self.ui.criteria_area_contents,
prop,
checked,
min(values),
max(values),
decimals=decimals,
use_dual_slider=self.use_dual_slider,
removable=self.removable)
if limiter is not None:
criterion.setLimits(limiter)
return criterion
[docs] def resetAll(self):
"""
Reset all criteria (show all ligands):
"""
for criterion in self.criteria:
criterion.reset()
[docs] def updateMatchLabel(self):
"""
Update the label at the bottom of the dialog box, showing how many
ligands match all criteria.
"""
num_total = len(self.values_by_lig)
if any([criterion.isChecked() for criterion in self.criteria]):
criteria = [criterion.getCriterion() for criterion in self.criteria]
temp_filter_obj = Filter('', criteria)
num_matched = num_total
for i, lig_props in enumerate(self.values_by_lig):
if not temp_filter_obj.doPropsMatch(lig_props):
num_matched -= 1
else:
# If no criterion is checked filter will not match any ligands.
num_matched = 0
self.ui.match_label.setText('Filter will match '
'%i of %i ligands' %
(num_matched, num_total))