Source code for schrodinger.ui.qt.multi_combo_box

import sys

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 utils as qt_utils

ENABLED_ROLE = Qt.UserRole - 1  # This is the standard Qt role enable state
WHITE_BRUSH = QtGui.QBrush(Qt.white)


[docs]class MacMultiComboBoxStyle(QtWidgets.QProxyStyle):
[docs] def subControlRect(self, control, option, subControl, widget): """ On Mac, add extra width to the popup for the checkbox. """ rect = super().subControlRect(control, option, subControl, widget) if (control == QtWidgets.QStyle.CC_ComboBox and subControl == QtWidgets.QStyle.SC_ComboBoxListBoxPopup): rect.setWidth(rect.width() + 20) return rect
[docs]class MultiComboBox(QtWidgets.QComboBox): """ A combo box that allows multiple items to be selected. Check marks are put next to each selected item. :ivar selectionChanged: A signal emitted whenever an item is selected or deselected. :vartype selectionChanged: `PyQt5.QtCore.pyqtSignal` """ selectionChanged = QtCore.pyqtSignal() popupClosed = QtCore.pyqtSignal()
[docs] def __init__(self, parent=None, include_all_and_none=False, delimiter=", "): """ :param parent: The Qt parent widget :type parent: `PyQt5.QtWidgets.QWidget` :param include_all_and_none: Whether "All" and "None" options should be added to the top of the item list. :type include_all_and_none: bool :param delimiter: The delimiter to use when listing all selected items :type delimiter: str """ super(MultiComboBox, self).__init__(parent) self.setModel(QtGui.QStandardItemModel()) # Must use the styled delegate for the checkboxes to render on Mac. # Same Qt bug as http://stackoverflow.com/questions/28112433 self.setItemDelegate(QtWidgets.QStyledItemDelegate(self)) if sys.platform == 'darwin': # Set style with extra width in the popup to prevent cutoff text # See PANEL-15196 self.setStyle(MacMultiComboBoxStyle()) self._delimiter = delimiter self.currentIndexChanged[int].connect(self._itemSelected) self._first_select = True self._all_index = None self._none_index = None if include_all_and_none: self.addAllAndNone() self.view().viewport().installEventFilter(self) popup = self.findChild(QtWidgets.QFrame, None, Qt.FindDirectChildrenOnly) # "popup" is a QComboBoxPrivateContainer instance. BIOLUM-3581 # Over-riding of hidePopup() signal in can not be used for this # purpose with Qt5, as it's not called on Mac when user clicks on # an area outside the pop-up to close it. See QTBUG-50055. popup.resetButton.connect(self.popupClosed)
[docs] def showPopup(self): # Override showPopup() to prevent the first mouse release from # changing check state of the item under the mouse. self.ignore_next_release = True super(MultiComboBox, self).showPopup()
[docs] def eventFilter(self, viewport, event): # Event handler for keeping the pop-up open when items are selected. # See Qt documentation for method documentation if event.type() == event.MouseButtonRelease: if self.ignore_next_release: self.ignore_next_release = False return True index = self.view().indexAt(event.pos()).row() if self.isIndexEnabled(index): self._itemSelected(index) return True return False
def _addItem(self, text, checkable): item = QtGui.QStandardItem(text) if checkable: item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsUserCheckable) item.setData(Qt.Unchecked, Qt.CheckStateRole) # Needed to overwrite the Maestro style, otherwise some rows will # have grey background after mouse hovers over them: # item.setData(WHITE_BRUSH, Qt.BackgroundRole) # NOTE: For now leaving this out as ideally we should fix Maestro's # style sheet. It's possible that different colors are desired on # different platforms. self.model().appendRow(item)
[docs] def addItems(self, items): # See Qt documentation for method documentation for text in items: self._addItem(text, checkable=True)
[docs] def addAllAndNone(self): """ Append "All" and "None" options to the item list """ if self._all_index is None: self._all_index = self.count() self._addItem('All', checkable=False) if self._none_index is None: self._none_index = self.count() self._addItem('None', checkable=False)
[docs] def setDelimiter(self, delimiter): """ Change the delimiter used when listing all selected items :param delimiter: The delimeter to use :type delimiter: str """ self._delimiter = delimiter self.update()
def _itemSelected(self, index): """ Respond to a new item being selected :param index: The index of the selected item :type index: int """ if self._first_select: # The first item to be added will be auto-selected, which we don't # want. To avoid this, we ignore the first selection event self._first_select = False elif index == self._all_index: self.selectAllItems() elif index == self._none_index: self.clearSelection() else: self._toggleSelection(index) # Clear the current index so the currentIndexChanged signal will always # be emitted even if the user clicks on the same item twice in a row with qt_utils.suppress_signals(self): self.setCurrentIndex(-1)
[docs] def selectAllItems(self): """ Select all listed items """ self.setAllItemsSelected(True)
[docs] def clearSelection(self): """ Deselect all listed items """ self.setAllItemsSelected(False)
[docs] def setAllItemsSelected(self, selected=True): """ Select or deselect all listed items :param selected: Whether to select or deselect :type selected: bool """ for index in range(self.count()): if index not in (self._all_index, self._none_index): if selected and not self.isIndexEnabled(index): continue self._setIndexChecked(index, selected) self.update() self.selectionChanged.emit()
[docs] def setItemSelected(self, item, selected=True): """ Set the selection status of the specified item :param item: The item to modify :type item: str :param selected: Whether to select or deselect :type selected: bool :raise ValueError: If the specified item does not exist or if it's "All" or "None" """ index = self.findText(item) if index == -1: raise ValueError("Specified item not found") self.setIndexSelected(index, selected)
def _setIndexChecked(self, index, checked): """ Private method to set the state of the combo item. """ item = self.model().item(index) check_state = Qt.Checked if checked else Qt.Unchecked item.setCheckState(check_state)
[docs] def setIndexSelected(self, index, selected=True): """ Set the selection status of the specified index :param index: The index of the item to modify :type index: int :param selected: Whether to select or deselect :type selected: bool :raise ValueError: IF the specified index corresponds to "All" or "None" """ if index >= self.count(): raise ValueError("Specified index not found") if index in (self._all_index, self._none_index): raise ValueError("Cannot select All or None") self._setIndexChecked(index, selected) self.update() self.selectionChanged.emit()
[docs] def isIndexSelected(self, index): """ :param index: The index of the item to check. :type index: int :return: Whether the item is selected/checked. :rtype: bool """ item = self.model().item(index) return (item.checkState() == Qt.Checked)
[docs] def setIndexEnabled(self, index, enable): """ Set the enable state of the specified index. :param index: The index of the item to modify :type index: int :param enable: Whether to enable or not. :type enable: bool """ value = None if enable else 0 self.setItemData(index, value, ENABLED_ROLE)
[docs] def isIndexEnabled(self, index): """ Return the enabled status for the given index. :param index: The index of the item to check. :type index: int :return: Whether the item is enabled. :rtype: bool """ value = self.itemData(index, ENABLED_ROLE) return (value != 0)
[docs] def setSelectedItems(self, items): """ Select the specified items. All other items will be deselected. :param items: The list of items to select (as strings) :type items: list :raise ValueError: If any specified item does not exist """ indexes = [self.findText(cur_item) for cur_item in items] if -1 in indexes: raise ValueError("Not all specified items found") self.setSelectedIndexes(indexes)
[docs] def setSelectedIndexes(self, indexes): """ Select the specified indexes. All other items will be deselected. :param indexes: The list of indexes to select (as ints) :type indexes: list """ count = self.count() if any(i >= count for i in indexes): raise ValueError("Specified index not found") if self._all_index in indexes or self._none_index in indexes: raise ValueError("Cannot select All or None") self.clearSelection() for index in indexes: self._setIndexChecked(index, True) self.update() self.selectionChanged.emit()
[docs] def getSelectedItems(self): """ Return a list of all selected items :return: All selected items (as strings) :rtype: list """ return [ self.itemText(i) for i in range(self.count()) if self.isIndexSelected(i) ]
[docs] def items(self): """ Return a list of all item texts except "All" and "None" :return: All items texts except "All" and "None" :rtype: list """ return [ self.itemText(i) for i in range(self.count()) if i not in (self._all_index, self._none_index) ]
[docs] def getSelectedIndexes(self): """ Return a list of all selected indexes :return: All selected indexes (as ints) :rtype: list """ return [i for i in range(self.count()) if self.isIndexSelected(i)]
def _toggleSelection(self, index): """ Toggle the selection status for the specified index :param index: The index to toggle :type index: int """ check = not self.isIndexSelected(index) self._setIndexChecked(index, check) self.selectionChanged.emit()
[docs] def currentText(self): # See Qt documentation for method documentation selected = self.getSelectedItems() text = self._delimiter.join(selected) return text
[docs] def paintEvent(self, event): """ See Qt documentation for method documentation :note: The C++ paintEvent() and initStyleOption() methods won't call a Python implementation of currentText() or initStyleOption(), presumably due to a bug in PyQt. To get around this, we reimplement the paintEvent() method in Python and force it to call the Python currentText() method """ painter = QtWidgets.QStylePainter(self) palette = self.palette() painter.setPen(palette.color(palette.Text)) opt = QtWidgets.QStyleOptionComboBox() self.initStyleOption(opt) opt.currentText = self.currentText() painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, opt) painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, opt)
[docs] def clear(self, keep_all_and_none=True): """ Clear all items from the combo box :param keep_all_and_none: If True, the "All" and "None" items added via `addAllAndNone` will be kept :type keep_all_and_none: bool """ if not keep_all_and_none or (self._all_index is None and self._none_index is None): super(MultiComboBox, self).clear() # If we've erased all items from the combo box, make sure to set # _first_select so we don't inadvertently auto-select the next item # added self._first_select = True self._all_index = None self._none_index = None else: for index in reversed(range(self.count())): if index not in (self._all_index, self._none_index): super(MultiComboBox, self).removeItem(index) self.selectionChanged.emit()
[docs] def removeItem(self, index): # See Qt documentation for method documentation if self._all_index is not None: if index == self._all_index: self._all_index = None elif index < self._all_index: self._all_index -= 1 if self._none_index is not None: if index == self._none_index: self._none_index = None elif index < self._none_index: self._none_index -= 1 super(MultiComboBox, self).removeItem(index) if self.count() == 0: # If we've erased all items from the combo box, make sure to set # _first_select so we don't inadvertently auto-select the next item # added self._first_select = True self.update() self.selectionChanged.emit()
[docs] def addAndSelectItem(self, text): """ Add a new item with the specified text and select it :param text: The text of the item to add :type text: str """ index = self.count() self.addItem(text) self._setIndexChecked(index, True) self.update() self.selectionChanged.emit()
[docs] def af2SettingsGetValue(self): """ This function adds support for the settings mixin. It allows to save checked item states in case this combo box is included in a settings panel. :return: List of selected rows. :rtype: list """ return self.getSelectedIndexes()
[docs] def af2SettingsSetValue(self, indexes): """ This function adds support for the settings mixin. It allows to set combo box check states when this table is included in a settings panel. :param indexes: List of rows to check. :type indexes: list """ self.setSelectedIndexes(indexes)
if __name__ == "__main__": # Demonstrate how to use this widget: app = QtWidgets.QApplication(sys.argv) combo = MultiComboBox() combo.addItems(['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']) combo.setSelectedIndexes([1, 2]) combo.setIndexEnabled(3, False) combo.addAllAndNone() combo.show() sys.exit(app.exec())