Skip to content

Instantly share code, notes, and snippets.

@ales-erjavec
Last active June 20, 2023 09:45
Show Gist options
  • Save ales-erjavec/7624dd1d183dfbfb3354600b285abb94 to your computer and use it in GitHub Desktop.
Save ales-erjavec/7624dd1d183dfbfb3354600b285abb94 to your computer and use it in GitHub Desktop.
A QComboBox supporting multiple item selection
"""
Check Combo Box
---------------
A QComboBox subclass designed for multiple item selection.
The combo box popup allows the user to check/uncheck multiple items at
once.
"""
import sys
from PyQt5.QtCore import Qt, QEvent, QRect, QTimer
from PyQt5.QtGui import (
QPalette, QFontMetrics, QBrush, QColor, QPixmap, QGradient, QIcon
)
from PyQt5.QtWidgets import (
QComboBox, QAbstractItemView, QAbstractItemDelegate, QStyledItemDelegate,
QApplication, QStyle, QStyleOption, QStyleOptionComboBox,
QStyleOptionMenuItem, QStyleOptionViewItem, QStylePainter
)
class CheckComboBox(QComboBox):
"""
A QComboBox allowing multiple item selection.
"""
class ComboItemDelegate(QStyledItemDelegate):
"""
Helper styled delegate (mostly based on existing private Qt's
delegate used by the QComboBox). Used to style the popup like a
list view (e.g windows style).
"""
def isSeparator(self, index):
return str(index.data(Qt.AccessibleDescriptionRole)) == "separator"
def paint(self, painter, option, index):
if option.widget is not None:
style = option.widget.style()
else:
style = QApplication.style()
option = QStyleOptionViewItem(option)
option.showDecorationSelected = True
# option.state &= ~QStyle.State_HasFocus & ~QStyle.State_MouseOver
if self.isSeparator(index):
opt = QStyleOption()
opt.rect = QRect(option.rect)
if isinstance(option.widget, QAbstractItemView):
opt.rect.setWidth(option.widget.viewport().width())
style.drawPrimitive(QStyle.PE_IndicatorToolBarSeparator,
opt, painter, option.widget)
else:
super(CheckComboBox.ComboItemDelegate, self).paint(painter, option, index)
class ComboMenuDelegate(QAbstractItemDelegate):
"""
Helper styled delegate (mostly based on existing private Qt's
delegate used by the QComboBox). Used to style the popup like a
menu. (e.g osx aqua style).
"""
def isSeparator(self, index):
return str(index.data(Qt.AccessibleDescriptionRole)) == "separator"
def paint(self, painter, option, index):
menuopt = self._getMenuStyleOption(option, index)
if option.widget is not None:
style = option.widget.style()
else:
style = QApplication.style()
style.drawControl(QStyle.CE_MenuItem, menuopt, painter,
option.widget)
def sizeHint(self, option, index):
menuopt = self._getMenuStyleOption(option, index)
if option.widget is not None:
style = option.widget.style()
else:
style = QApplication.style()
return style.sizeFromContents(
QStyle.CT_MenuItem, menuopt, menuopt.rect.size(),
option.widget
)
def _getMenuStyleOption(self, option, index):
menuoption = QStyleOptionMenuItem()
palette = option.palette.resolve(QApplication.palette("QMenu"))
foreground = index.data(Qt.ForegroundRole)
if isinstance(foreground, (QBrush, QColor, QPixmap)):
foreground = QBrush(foreground)
palette.setBrush(QPalette.Text, foreground)
palette.setBrush(QPalette.ButtonText, foreground)
palette.setBrush(QPalette.WindowText, foreground)
background = index.data(Qt.BackgroundRole)
if isinstance(background, (QBrush, QColor, QPixmap)):
background = QBrush(background)
palette.setBrush(QPalette.Background, background)
menuoption.palette = palette
decoration = index.data(Qt.DecorationRole)
if isinstance(decoration, QIcon):
menuoption.icon = decoration
if self.isSeparator(index):
menuoption.menuItemType = QStyleOptionMenuItem.Separator
else:
menuoption.menuItemType = QStyleOptionMenuItem.Normal
if index.flags() & Qt.ItemIsUserCheckable:
menuoption.checkType = QStyleOptionMenuItem.NonExclusive
else:
menuoption.checkType = QStyleOptionMenuItem.NotCheckable
check = index.data(Qt.CheckStateRole)
menuoption.checked = check == Qt.Checked
if option.widget is not None:
menuoption.font = option.widget.font()
else:
menuoption.font = QApplication.font("QMenu")
menuoption.maxIconWidth = option.decorationSize.width() + 4
menuoption.rect = option.rect
menuoption.menuRect = option.rect
menuoption.menuHasCheckableItems = True
menuoption.tabWidth = 0
# TODO: self.displayText(QVariant, QLocale)
# TODO: Why is this not a QStyledItemDelegate?
display = index.data(Qt.DisplayRole)
if isinstance(display, str):
menuoption.text = display
else:
menuoption.text = str(display)
menuoption.fontMetrics = QFontMetrics(menuoption.font)
state = option.state & (QStyle.State_MouseOver |
QStyle.State_Selected |
QStyle.State_Active)
if index.flags() & Qt.ItemIsEnabled:
state = state | QStyle.State_Enabled
menuoption.palette.setCurrentColorGroup(QPalette.Active)
else:
state = state & ~QStyle.State_Enabled
menuoption.palette.setCurrentColorGroup(QPalette.Disabled)
if menuoption.checked:
state = state | QStyle.State_On
else:
state = state | QStyle.State_Off
menuoption.state = state
return menuoption
def __init__(self, parent=None, placeholderText="", separator=", ",
**kwargs):
super(CheckComboBox, self).__init__(parent, **kwargs)
self.setFocusPolicy(Qt.StrongFocus)
self.__popupIsShown = False
self.__supressPopupHide = False
self.__blockMouseReleaseTimer = QTimer(self, singleShot=True)
self.__initialMousePos = None
self.__separator = separator
self.__placeholderText = placeholderText
self.__updateItemDelegate()
def mousePressEvent(self, event):
"""Reimplemented."""
self.__popupIsShown = False
super(CheckComboBox, self).mousePressEvent(event)
if self.__popupIsShown:
self.__initialMousePos = self.mapToGlobal(event.pos())
self.__blockMouseReleaseTimer.start(
QApplication.doubleClickInterval())
def changeEvent(self, event):
"""Reimplemented."""
if event.type() == QEvent.StyleChange:
self.__updateItemDelegate()
super(CheckComboBox, self).changeEvent(event)
def showPopup(self):
"""Reimplemented."""
super(CheckComboBox, self).showPopup()
view = self.view()
view.installEventFilter(self)
view.viewport().installEventFilter(self)
self.__popupIsShown = True
def hidePopup(self):
"""Reimplemented."""
self.view().removeEventFilter(self)
self.view().viewport().removeEventFilter(self)
self.__popupIsShown = False
self.__initialMousePos = None
super(CheckComboBox, self).hidePopup()
self.view().clearFocus()
def eventFilter(self, obj, event):
"""Reimplemented."""
if self.__popupIsShown and \
event.type() == QEvent.MouseMove and \
self.view().isVisible() and self.__initialMousePos is not None:
diff = obj.mapToGlobal(event.pos()) - self.__initialMousePos
if diff.manhattanLength() > 9 and \
self.__blockMouseReleaseTimer.isActive():
self.__blockMouseReleaseTimer.stop()
# pass through
if self.__popupIsShown and \
event.type() == QEvent.MouseButtonRelease and \
self.view().isVisible() and \
self.view().rect().contains(event.pos()) and \
self.view().currentIndex().isValid() and \
self.view().currentIndex().flags() & Qt.ItemIsSelectable and \
self.view().currentIndex().flags() & Qt.ItemIsEnabled and \
self.view().currentIndex().flags() & Qt.ItemIsUserCheckable and \
self.view().visualRect(self.view().currentIndex()).contains(event.pos()) and \
not self.__blockMouseReleaseTimer.isActive():
model = self.model()
index = self.view().currentIndex()
state = model.data(index, Qt.CheckStateRole)
model.setData(index,
Qt.Checked if state == Qt.Unchecked else Qt.Unchecked,
Qt.CheckStateRole)
self.view().update(index)
self.update()
return True
if self.__popupIsShown and event.type() == QEvent.KeyPress:
if event.key() == Qt.Key_Space:
# toogle the current items check state
model = self.model()
index = self.view().currentIndex()
flags = model.flags(index)
state = model.data(index, Qt.CheckStateRole)
if flags & Qt.ItemIsUserCheckable and \
flags & Qt.ItemIsTristate:
state = Qt.CheckState((int(state) + 1) % 3)
elif flags & Qt.ItemIsUserCheckable:
state = Qt.Checked if state != Qt.Checked else Qt.Unchecked
model.setData(index, state, Qt.CheckStateRole)
self.view().update(index)
self.update()
return True
# TODO: handle Qt.Key_Enter, Key_Return?
return super(CheckComboBox, self).eventFilter(obj, event)
def paintEvent(self, event):
"""Reimplemented."""
painter = QStylePainter(self)
option = QStyleOptionComboBox()
self.initStyleOption(option)
painter.drawComplexControl(QStyle.CC_ComboBox, option)
# draw the icon and text
checked = self.checkedIndices()
if checked:
items = [self.itemText(i) for i in checked]
option.currentText = self.__separator.join(items)
else:
option.currentText = self.__placeholderText
option.palette.setCurrentColorGroup(QPalette.Disabled)
option.currentIcon = QIcon()
painter.drawControl(QStyle.CE_ComboBoxLabel, option)
def itemCheckState(self, index):
"""
Return the check state for item at `index`
Parameters
----------
index : int
Returns
-------
state : Qt.CheckState
"""
state = self.itemData(index, role=Qt.CheckStateRole)
if isinstance(state, int):
return Qt.CheckState(state)
else:
return Qt.Unchecked
def setItemCheckState(self, index, state):
"""
Set the check state for item at `index` to `state`.
Parameters
----------
index : int
state : Qt.CheckState
"""
self.setItemData(index, state, Qt.CheckStateRole)
def checkedIndices(self):
"""
Return a list of indices of all checked items.
Returns
-------
indices : List[int]
"""
return [i for i in range(self.count())
if self.itemCheckState(i) == Qt.Checked]
def setPlaceholderText(self, text):
"""
Set the placeholder text.
This text is displayed on the checkbox when there are no checked
items.
Parameters
----------
text : str
"""
if self.__placeholderText != text:
self.__placeholderText = text
self.update()
def placeholderText(self):
"""
Return the placeholder text.
Returns
-------
text : str
"""
return self.__placeholderText
def wheelEvent(self, event):
"""Reimplemented."""
event.ignore()
def keyPressEvent(self, event):
"""Reimplemented."""
# Override the default QComboBox behavior
if event.key() == Qt.Key_Down and event.modifiers() & Qt.AltModifier:
self.showPopup()
return
ignored = {Qt.Key_Up, Qt.Key_Down,
Qt.Key_PageDown, Qt.Key_PageUp,
Qt.Key_Home, Qt.Key_End}
if event.key() in ignored:
event.ignore()
return
super(CheckComboBox, self).keyPressEvent(event)
def __updateItemDelegate(self):
opt = QStyleOptionComboBox()
opt.initFrom(self)
if self.style().styleHint(QStyle.SH_ComboBox_Popup, opt, self):
delegate = CheckComboBox.ComboMenuDelegate(self)
else:
delegate = CheckComboBox.ComboItemDelegate(self)
self.setItemDelegate(delegate)
def example():
app = QApplication(list(sys.argv))
cb = CheckComboBox(placeholderText="None")
model = cb.model()
cb.addItem("First")
model.item(0).setCheckable(True)
cb.addItem("Second")
model.item(1).setCheckable(True)
cb.addItem("Third")
model.item(2).setCheckable(True)
cb.insertSeparator(3)
cb.addItem("Fourth - Disabled")
model.item(4).setEnabled(False)
cb.show()
cb.raise_()
return app.exec_()
if __name__ == "__main__":
sys.exit(example())
@MichaelSuen-thePointer
Copy link

Sorry to bother, but I found that only check and uncheck the first element can trigger paintEvent immediately, and check other elements will not, do you know why and how to properly trigger that without calling repaint manually?

@GeneratorMata
Copy link

GeneratorMata commented Oct 15, 2019

It's worked!

def eventFilter(self, obj, event):
        """Reimplemented."""
        if self.__popupIsShown and \
                event.type() == QEvent.MouseMove and \
                self.view().isVisible() and self.__initialMousePos is not None:
            diff = obj.mapToGlobal(event.pos()) - self.__initialMousePos
            if diff.manhattanLength() > 9 and \
                    self.__blockMouseReleaseTimer.isActive():
                self.__blockMouseReleaseTimer.stop()
            # pass through

        if self.__popupIsShown and \
                event.type() == QEvent.MouseButtonRelease and \
                self.view().isVisible() and \
                self.view().rect().contains(event.pos()) and \
                self.view().currentIndex().isValid() and \
                self.view().currentIndex().flags() & Qt.ItemIsSelectable and \
                self.view().currentIndex().flags() & Qt.ItemIsEnabled and \
                self.view().currentIndex().flags() & Qt.ItemIsUserCheckable and \
                self.view().visualRect(self.view().currentIndex()).contains(event.pos()) and \
                not self.__blockMouseReleaseTimer.isActive():
            model = self.model()
            index = self.view().currentIndex()
            state = model.data(index, Qt.CheckStateRole)
            model.setData(index,
                          Qt.Checked if state == Qt.Unchecked else Qt.Unchecked,
                          Qt.CheckStateRole)
            self.view().update(index)
            self.paintEvent(event)
            return True

        if self.__popupIsShown and event.type() == QEvent.KeyPress:
            if event.key() == Qt.Key_Space:
                # toogle the current items check state
                model = self.model()
                index = self.view().currentIndex()
                flags = model.flags(index)
                state = model.data(index, Qt.CheckStateRole)
                if flags & Qt.ItemIsUserCheckable and \
                        flags & Qt.ItemIsTristate:
                    state = Qt.CheckState((int(state) + 1) % 3)
                elif flags & Qt.ItemIsUserCheckable:
                    state = Qt.Checked if state != Qt.Checked else Qt.Unchecked
                model.setData(index, state, Qt.CheckStateRole)
                return True
            # TODO: handle Qt.Key_Enter, Key_Return?

        return super(CheckComboBox, self).eventFilter(obj, event)
def paintEvent(self, event):
        """Reimplemented."""
        painter = QStylePainter(self)
        option = QStyleOptionComboBox()
        self.initStyleOption(option)
        painter.drawComplexControl(QStyle.CC_ComboBox, option)
        # draw the icon and text
        checked = self.checkedIndices()
        if checked:
            items = [self.itemText(i) for i in checked]
            option.currentText = self.__separator.join(items)
            self.update()
        else:
            option.currentText = self.__placeholderText
            option.palette.setCurrentColorGroup(QPalette.Disabled)

        option.currentIcon = QIcon()
        painter.drawControl(QStyle.CE_ComboBoxLabel, option)

@MichaelSuen-thePointer
Copy link

Glad to see you repond, thank you very much! ; )

@maginator
Copy link

Hi I have a problem when I try to set some entries default as checked it is not written in the textbox as it happens when I click the entries manually. Do you have an Idear how to fix it? 😄

@sahalotz
Copy link

sahalotz commented Dec 10, 2022

I'm trying this code with Pyside6 but i wasn't able to select items(like its not checking the check box). Code is working fine with PyQt5. Any suggestions that how I can convert it to PySide6?
image

Also i get the following error when i run this application.
Screenshot 2022-12-10 162906

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment