pythondelegatesqtableviewpyside6

Center a Checkbox Delegate in QTableView with PySide6


I was using a Checkbox Delegate in my QTableView originally created from around the time of PyQt 4.8 or PySide 1.2.1. It was also working in PySide2, but when I tried to update my application to PySide6, it no longer worked (I now no longer remember exactly what the problem was).

A newer version was posted for PyQt5 at https://stackoverflow.com/a/50314085/224310 which mostly worked except that the delegate was a subclass of QItemDelegate instead of QStyledItemDelegate the checkboxes were drawn horribly large. Others seemed to have the same issue, as I found at https://forum.qt.io/topic/142124/pyqt-checkbox-resolution-for-different-scales, but the poster indicated they ultimately solved their problem "by implementing the checkboxes through Qt.ItemIsUserCheckable, rather than through a delegate". I tried the same and it worked, but it seems that checkboxes implemented through Qt.ItemIsUserCheckable are not able to be centered in a column of a QTableView. To center them you need... a delegate.

I've spent hours upon hours researching and trying different solutions and it amazes me that what seems like a common use-case of a column of centered checkboxes in a QTableView is so difficult to implement in Python. (Ok, I've worked with Qt long enough that I'm not really amazed.)

Does anyone have any suggestions on how to implement a column of check boxes in PySide6 that are centered in a column and do not look horrible at large resolutions?

(I'll be answering my own question and posting my ultimate solution below, but I'd welcome other (better) suggestions as well!)


Solution

  • Reading posts with someone else's similar struggles, I ran across a link to a C++ solution at https://wiki.qt.io/Center_a_QCheckBox_or_Decoration_in_an_Itemview. I was not able to find a similar solution written in Python directly. I don't know C++, but with the help of an online conversion tool, I was able to get version that looked somewhat-like-Python, which I was then able to rewrite to work with PySide6 and with something more modern (I'm using Python 3.12).

    
    from PySide6 import QtCore, QtWidgets, QtGui
    
    class StyledCheckboxDelegate(QtWidgets.QStyledItemDelegate):
        """Centered checkbox delegate to use in a QTableView.
    
        Adapted from https://wiki.qt.io/Center_a_QCheckBox_or_Decoration_in_an_Itemview
        """
    
        def __init__(self, parent=None):
    
            super().__init__(parent=parent)
    
    
        def paint(self, painter, option, index):
    
            self.initStyleOption(option, index)
            widget = option.widget
            style = widget.style() if widget else QtWidgets.QApplication.style()
            style.drawPrimitive(
                    QtWidgets.QStyle.PrimitiveElement.PE_PanelItemViewItem,
                    option,
                    painter,
                    widget
                    )
    
            if (QtWidgets.QStyleOptionViewItem.ViewItemFeature.HasCheckIndicator
                    in option.features):
                if option.checkState == QtCore.Qt.CheckState.Unchecked:
                    option.state |= QtWidgets.QStyle.StateFlag.State_Off
    
                elif option.checkState == QtCore.Qt.CheckState.PartiallyChecked:
                    option.state |= QtWidgets.QStyle.StateFlag.State_NoChange
    
                elif option.checkState == QtCore.Qt.CheckState.Checked:
                    option.state |= QtWidgets.QStyle.StateFlag.State_On
    
                rect = style.subElementRect(
                    QtWidgets.QStyle.SubElement.SE_ItemViewItemCheckIndicator,
                    option,
                    widget
                    )
                option.rect = QtWidgets.QStyle.alignedRect(
                    option.direction,
                    QtCore.Qt.AlignmentFlag(
                            index.data(QtCore.Qt.ItemDataRole.TextAlignmentRole).value
                            ),
                    rect.size(),
                    option.rect
                    )
                option.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus
                style.drawPrimitive(
                    QtWidgets.QStyle.PrimitiveElement.PE_IndicatorItemViewItemCheck,
                    option,
                    painter,
                    widget
                    )
    
            elif not option.icon.isNull():
                icon_rect = style.subElementRect(
                        QtWidgets.QStyle.SubElement.SE_ItemViewItemDecoration,
                        option,
                        widget
                        )
                icon_rect = QtWidgets.QStyle.alignedRect(
                        option.direction,
                        QtCore.Qt.AlignmentFlag(
                                index.data(QtCore.Qt.ItemDataRole.TextAlignmentRole).value
                                ),
                        icon_rect.size(),
                        option.rect
                        )
                mode = QtGui.QIcon.Mode.Normal
                if QtWidgets.QStyle.StateFlag.State_Enabled not in option.state:
                    mode = QtGui.QIcon.Mode.Disabled
    
                elif QtWidgets.QStyle.StateFlag.State_Selected in option.state:
                    mode = QtGui.QIcon.Mode.Selected
    
                state = (
                        QtGui.QIcon.State.On
                        if QtWidgets.QStyle.StateFlag.State_Open in option.state
                        else QtGui.QIcon.State.Off
                        )
                option.icon.paint(
                        painter,
                        icon_rect,
                        option.decorationAlignment,
                        mode,
                        state
                        )
    
            else:
                super().paint(painter, option, index)
    
    
        def editorEvent(self, event, model, option, index):
    
            # Make sure that the item is checkable
            flags = model.flags(index)
            if (QtCore.Qt.ItemFlag.ItemIsUserCheckable not in flags
                    or QtWidgets.QStyle.StateFlag.State_Enabled not in option.state
                    or QtCore.Qt.ItemFlag.ItemIsEnabled not in flags):
                return False
    
            # Make sure that we have a check state
            state = index.data(QtCore.Qt.ItemDataRole.CheckStateRole)
            if state is None:
                return False
    
            widget = option.widget
            style = widget.style() if widget else QtWidgets.QApplication.style()
    
            # Make sure that we have the right event type
            if event.type() in (
                    QtCore.QEvent.Type.MouseButtonRelease,
                    QtCore.QEvent.Type.MouseButtonDblClick,
                    QtCore.QEvent.Type.MouseButtonPress,
                    ):
                view_opt = QtWidgets.QStyleOptionViewItem(option)
                self.initStyleOption(view_opt, index)
                check_rect = style.subElementRect(
                        QtWidgets.QStyle.SubElement.SE_ItemViewItemCheckIndicator,
                        view_opt,
                        widget,
                        )
                check_rect = QtWidgets.QStyle.alignedRect(
                        view_opt.direction,
                        QtCore.Qt.AlignmentFlag(
                                index.data(QtCore.Qt.ItemDataRole.TextAlignmentRole).value
                                ),
                        check_rect.size(),
                        view_opt.rect,
                        )
                if (isinstance(event, QtGui.QMouseEvent)
                        and (event.button() != QtCore.Qt.MouseButton.LeftButton
                             or not check_rect.contains(event.position().toPoint())
                             )):
                    return False
    
                if event.type() in (
                        QtCore.QEvent.Type.MouseButtonPress,
                        QtCore.QEvent.Type.MouseButtonDblClick,
                        ):
                    return True
    
            elif event.type() == QtCore.QEvent.Type.KeyPress:
                if event.key() not in (
                        QtCore.Qt.Key.Key_Space,
                        QtCore.Qt.Key.Key_Select,
                        ):
                    return False
    
            else:
                return False
    
            # convert to an Enum for easy comparison
            state = QtCore.Qt.CheckState(state)
            if QtCore.Qt.ItemFlag.ItemIsUserTristate in flags:
                state = QtCore.Qt.CheckState((state.value + 1) % 3)
    
            else:
                state = (
                        QtCore.Qt.CheckState.Checked
                        if state == QtCore.Qt.CheckState.Unchecked
                        else QtCore.Qt.CheckState.Unchecked
                        )
    
            # set the new checkbox state in the model (as its Enum value)
            return model.setData(
                    index,
                    state.value,
                    QtCore.Qt.ItemDataRole.CheckStateRole,
                    )
    
    
    
    if __name__ == "__main__":
    
        app = QtWidgets.QApplication(sys.argv)
    
        model = QtGui.QStandardItemModel()
        model.setColumnCount(1)
        model.setRowCount(2)
    
        checkable_item = QtGui.QStandardItem()
        checkable_item.setFlags(
                checkable_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable
                )
        checkable_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
        checkable_item.setCheckState(QtCore.Qt.CheckState.Checked)
        model.setItem(0, 0, checkable_item)
    
        checkable_item = QtGui.QStandardItem()
        checkable_item.setFlags(
                checkable_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable
                )
        checkable_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
        checkable_item.setCheckState(QtCore.Qt.CheckState.Checked)
        model.setItem(1, 0, checkable_item)
    
        table_view = QtWidgets.QTableView()
        table_view.setModel(model)
        table_view.setItemDelegate(StyledCheckboxDelegate())
        #table_view.setItemDelegateForColumn(0, StyledCheckboxDelegate()) # when only a single column should use the delegate
        table_view.show()
        sys.exit(app.exec())
    
    

    Hopefully this saves someone else (or my future self) some hours of time looking for a similar solution!