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'
[docs]class TextOperator(Enum): CONTAINS = 'text_contains'
[docs]class BoolOperator(Enum): TRUE = 'bool_true' FALSE = 'bool_false'
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 getLimits(self): # Implemented in subclasses. raise NotImplementedError
[docs] def setLimits(self, values): # Implemented in subclasses. raise NotImplementedError
[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))