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 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())