PyQt6 - In QComboBox, display centered QIcon without text

80 views Asked by At

Using PyQt6, I'm trying to display an image with QIcon(QPixmap) centered with no text with a QComboBox.addItem().

> help(QComboBox.addItem)
Help on built-in function addItem:

addItem(...) method of PyQt6.sip.wrappertype instance
    addItem(self, str, userData: Any = None)
    addItem(self, QIcon, str, userData: Any = None)

So far, I've figured I had to play with QStyledItemDelegate for the dropdown list yet I still get to a meh result using following:

class MyComboBox(QComboBox):
    def __init__(self, *args, **kwargs):
        super().__init__()
        delegate = AlignDelegate(self)
        self.setItemDelegate(delegate)

class AlignDelegate(QStyledItemDelegate):
    def initStyleOption(self, option, index):
        super().initStyleOption(option, index)
        option.features &= ~QStyleOptionViewItem.ViewItemFeature.HasDisplay
        option.decorationAlignment = Qt.AlignmentFlag.AlignCenter
        option.decorationPosition = QStyleOptionViewItem.Position.Top

The dropdown QIcon are horizontally centered but vertically they are not and although, I try to remove the text using following line but unless the string is "", the text is still somewhere.

option.features &= ~QStyleOptionViewItem.ViewItemFeature.HasDisplay

Same goes for the selected line where, I can't figure how to not have text (besides "") and have the QIcon centered instead of the text.

class MyComboBox(QComboBox):
    def __init__(self, *args, **kwargs):
        super().__init__()
        self.setEditable(True)
        self.lineEdit().setReadOnly(True)
        self.lineEdit().setAlignment(Qt.AlignmentFlag.AlignCenter)
1

There are 1 answers

0
musicamante On

There are two problems in play:

  • the positioning of elements in a delegate is always based on the current style, and most styles assume the position of the icon based on the possibility of the displayed text, even if no text is shown;
  • the delegate only manages how items are shown in the popup: how the current item is shown in the normal display of a combo box completely ignores the delegate;

The attempt to alter the editable behavior is also pointless: an editable QComboBox embeds a QLineEdit (which can only show text), and the icon is always drawn by the combo box anyway, which eventually alters the line edit geometry in order to fit the icon; trying to set the line edit alignment is useless, because it has absolutely no impact on the icon position.

Popup display

Since most functions of Qt widgets (including delegates) always rely on the current style, this means that the only way to ensure that the icon is always centered is to override the paint() function of the delegate.

Note that we should always try to follow the default behavior: the default delegate of QComboBox is QItemDelegate, not QStyledItemDelegate.

QItemDelegate has separated functions for each element of an item; since we only want to show the icon, we just need to call drawBackground() (which also draws the selected state of the highlighted item) and then paint the icon.

class AlignDelegate(QItemDelegate):
    def drawCenteredIcon(self, qp, opt, index):
        icon = index.data(Qt.ItemDataRole.DecorationRole)

        if isinstance(icon, QIcon):
            if not opt.state & QStyle.StateFlag.State_Enabled:
                mode = QIcon.Mode.Disabled
            elif opt.state & QStyle.StateFlag.State_Selected:
                mode = QIcon.Mode.Selected
            else:
                mode = QIcon.Mode.Normal
            icon = icon.pixmap(opt.rect.size(), mode)
            rect = opt.rect

        elif isinstance(icon, QPixmap):
            rect = QRect(QPoint(), icon.size().scaled(opt.rect.size(), 
                Qt.AspectRatioMode.KeepAspectRatio))
            rect.moveCenter(opt.rect.center())

        else:
            return

        qp.drawPixmap(rect, icon)

    def paint(self, qp, opt, index):
        self.drawBackground(qp, opt, index)
        self.drawCenteredIcon(qp, opt, index)

    def sizeHint(self, opt, index):
        # return the default size
        return super().sizeHint(opt, QModelIndex())

If you still want to use the capabilities of QStyledItemDelegate (including style sheets), then we can do a similar approach by calling the related function of the style, which is drawPrimitive() along with PE_PanelItemViewItem.

class StyledAlignDelegate(QStyledItemDelegate):
    ...

    def paint(self, qp, opt, index):
        self.initStyleOption(opt, index)
        style = opt.widget.style()
        style.drawPrimitive(QStyle.PrimitiveElement.PE_PanelItemViewItem, 
            opt, qp, opt.widget)

        self.drawCenteredIcon(qp, opt, index)

    ...

Actual combo box display

As said above, the delegate only has effect on the popup (the view), not on the combo box.

In order to change such display, we need to follow the default behavior of QComboBox by overriding paintEvent() and "port" the original C++ code into Python.

The default paintEvent() implementation does the following:

  • draw the appearance of a standard empty combo box;
  • draw the actual contents of the current index (the text and eventually its icon), or the placeholder text if the index is -1;

Since we obviously only want to show the icon whenever the current index is valid, we can skip all that if currentIndex() < 0, otherwise, we will only do the first step, and eventually draw the icon.

Then, proper centering of the icon is achieved by calling the current style subControlRect() function with the SC_ComboBoxEditField subcontrol, which ensures that we get the proper rectangle in which the "label" would be shown, excluding the arrow button of the combo box.

Note that this means that we are possibly breaking the default behavior in case any future Qt release alters the default implementation, possibly due to bug reports/fixes, changes in QComboBox painting or even QStyle implementations.

class MyComboBox(QComboBox):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setItemDelegate(AlignDelegate(self))
        # alternatively, for the styled delegate
        # self.setItemDelegate(StyledAlignDelegate(self))

    def paintEvent(self, event):
        if (
            self.currentIndex() < 0 
            or self.itemIcon(self.currentIndex()).isNull()
        ):
            return super().paintEvent(event)

        opt = QStyleOptionComboBox()
        self.initStyleOption(opt)

        qp = QStylePainter(self)
        # maybe not necessary, but possibly required by some styles
        qp.setPen(self.palette().color(QPalette.ColorRole.Text))

        style = self.style()
        qp.drawComplexControl(style.ComplexControl.CC_ComboBox, opt)

        rect = style.subControlRect(
            style.ComplexControl.CC_ComboBox, opt, 
            style.SC_ComboBoxEditField, self
        )
        style.drawItemPixmap(qp, rect, Qt.AlignCenter, 
            opt.currentIcon.pixmap(rect.size()))