Search code examples
pythonpyqtpyqt5qtableview

Checkbox with persistent editor


I'm using a table to control the visibility and color of a plot. I would like a checkbox to toggle visibility and a drop-down to select the color. To this end, I have something like the following. It feels as through having a persistent editor prevents use of the checkbox.

The example is a bit contrived (in how the model/view are set up), but illustrates how the checkbox doesn't function while the editor is open.

How can I have a checkbox that can be used alongside a visible combobox? Is it better to use two columns?

import sys
from PyQt5 import QtWidgets, QtCore

class ComboDelegate(QtWidgets.QItemDelegate):

    def __init__(self, parent):
        super().__init__(parent=parent)

    def createEditor(self, parent, option, index):
        combo = QtWidgets.QComboBox(parent)
        li = []
        li.append("Red")
        li.append("Green")
        li.append("Blue")
        li.append("Yellow")
        li.append("Purple")
        li.append("Orange")
        combo.addItems(li)
        combo.currentIndexChanged.connect(self.currentIndexChanged)
        return combo

    def setEditorData(self, editor, index):
        editor.blockSignals(True)
        data = index.model().data(index)
        if data:
            idx = int(data)
        else:
            idx = 0
        editor.setCurrentIndex(0)
        editor.blockSignals(False)

    def setModelData(self, editor, model, index):
        model.setData(index, editor.currentIndex())

    @QtCore.pyqtSlot()
    def currentIndexChanged(self):
        self.commitData.emit(self.sender())


class PersistentEditorTableView(QtWidgets.QTableView):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    QtCore.pyqtSlot('QVariant', 'QVariant')
    def data_changed(self, top_left, bottom_right):
        for row in range(len(self.model().tableData)):
            self.openPersistentEditor(self.model().index(row, 0))


class TableModel(QtCore.QAbstractTableModel):
    def __init__(self, parent=None):
        super(TableModel, self).__init__(parent)
        self.tableData = [[1, 2, 3], [1, 2, 3], [1, 2, 3]]
        self.checks = {}

    def columnCount(self, *args):
        return 3

    def rowCount(self, *args):
        return 3

    def checkState(self, index):
        if index in self.checks.keys():
            return self.checks[index]
        else:
            return QtCore.Qt.Unchecked

    def data(self, index, role=QtCore.Qt.DisplayRole):
        row = index.row()
        col = index.column()
        if role == QtCore.Qt.DisplayRole:
            return '{0}'.format(self.tableData[row][col])
        elif role == QtCore.Qt.CheckStateRole and col == 0:
            return self.checkState(QtCore.QPersistentModelIndex(index))
        return None

    def setData(self, index, value, role=QtCore.Qt.EditRole):
        if not index.isValid():
            return False
        if role == QtCore.Qt.CheckStateRole:
            self.checks[QtCore.QPersistentModelIndex(index)] = value
            self.dataChanged.emit(index, index)
            return True
        return False

    def flags(self, index):
        fl = QtCore.QAbstractTableModel.flags(self, index)
        if index.column() == 0:
            fl |= QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsUserCheckable
        return fl


if __name__ == "__main__":

    app = QtWidgets.QApplication(sys.argv)

    view = PersistentEditorTableView()
    view.setItemDelegateForColumn(0, ComboDelegate(view))

    model = TableModel()
    view.setModel(model)
    model.dataChanged.connect(view.data_changed)
    model.layoutChanged.connect(view.data_changed)

    index = model.createIndex(0, 0)
    persistet_index = QtCore.QPersistentModelIndex(index)
    model.checks[persistet_index] = QtCore.Qt.Checked
    view.data_changed(index, index)

    view.show()
    sys.exit(app.exec_())

Solution

  • NOTE: After some rethinking and analysis of the Qt source code, I realized that my original answer, while valid, is a bit imprecise.

    The problem relies on the fact that all events received on an index with an active editor are automatically sent to the editor, but since the editor could have a geometry that is smaller than the visual rect of the index, if a mouse event is sent outside the geometry that event is ignored and not processed by the view as it normally would without an editor.

    UPDATE: In fact, the view does receive the event; the problem is that if an editor exists the event is automatically ignored furtherward (since it's assumed that the editor handled it) and nothing is sent to the editorEvent method of the delegate.

    You can intercept "mouse edit" events as long as you implement the virtual edit(index, trigger, event) function (which is not the same as the edit(index) slot). Note that this means that if you override that function, you cannot call the default edit(index) anymore, unless you create a separate function that calls the default implementation (via super().edit(index)).

    Consider that the following code works better if the delegate is actually a QStyledItemDelegate, instead of the simpler QItemDelegate class: the Qt dev team itself suggests to use the styled class instead of the basic one (which is intended for very basic or specific usage), as it's generally considered more consistent.

    class PersistentEditorTableView(QtWidgets.QTableView):
        # ...
        def edit(self, index, trigger, event):
            # if the edit involves an index change, there's no event
            if (event and index.column() == 0 and 
                index.flags() & QtCore.Qt.ItemIsUserCheckable and
                event.type() in (event.MouseButtonPress, event.MouseButtonDblClick) and 
                event.button() == QtCore.Qt.LeftButton):
                    opt = self.viewOptions()
                    opt.rect = self.visualRect(index)
                    opt.features |= opt.HasCheckIndicator
                    checkRect = self.style().subElementRect(
                        QtWidgets.QStyle.SE_ItemViewItemCheckIndicator, 
                        opt, self)
                    if event.pos() in checkRect:
                        if index.data(QtCore.Qt.CheckStateRole):
                            state = QtCore.Qt.Unchecked
                        else:
                            state = QtCore.Qt.Checked
                        return self.model().setData(
                            index, state, QtCore.Qt.CheckStateRole)
            return super().edit(index, trigger, event)
    

    Obviously, you could do something similar by implementing the mousePressEvent on the view, but that could complicate things if you need some different implementation of the mouse press event also, and you should also consider the double click events. Implementing edit() is conceptually better, since it's more consistent with the purpose: clicking -> toggling.

    There's only one last catch: keyboard events.
    A persistent editor automatically grabs the keyboard focus, so you can't use any key (usually, the space bar) to toggle the state if the editor handles that event; since a combobox handles some "toggle" events to show its popup, those events won't be received by the view (the focus is on the combo, not on the view!) unless you ignore the event by properly implementing the eventFilter of the delegate for both keyboard events and focus changes.

    Original answer

    There are various possibilities to work around this:

    1. as you proposed, use a separate column; this is not always possible or suggested, as the model structure could not allow it;
    2. create an editor that also includes a QCheckBox; as long as there will always be an open editor, this is not an issue, but could create some level of inconsistency if editor could actually be open and destroyed;
    3. add the combo to a container that has a fixed margin, so that mouse event not handled by the combo can be captured by the delegate event filter;

    The third possibility is a bit more complex, but it ensures that both displaying and interaction are consistent with the normal behavior.
    To achieve this, using a QStyledItemDelegate is suggested as it provides access to the style options.

    class ComboDelegate(QtWidgets.QStyledItemDelegate):
        # ...
        def createEditor(self, parent, option, index):
            option = QtWidgets.QStyleOptionViewItem(option)
            self.initStyleOption(option, index)
            style = option.widget.style()
            textRect = style.subElementRect(
                style.SE_ItemViewItemText, option, option.widget)
    
            editor = QtWidgets.QWidget(parent)
            editor.index = QtCore.QPersistentModelIndex(index)
            layout = QtWidgets.QHBoxLayout(editor)
            layout.setContentsMargins(textRect.left(), 0, 0, 0)
            editor.combo = QtWidgets.QComboBox()
            layout.addWidget(editor.combo)
            editor.combo.addItems(
                ("Red", "Green", "Blue", "Yellow", "Purple", "Orange"))
            editor.combo.currentIndexChanged.connect(self.currentIndexChanged)
            return editor
    
        def setEditorData(self, editor, index):
            editor.combo.blockSignals(True)
            data = index.model().data(index)
            if data:
                idx = int(data)
            else:
                idx = 0
            editor.combo.setCurrentIndex(0)
            editor.combo.blockSignals(False)
    
        def eventFilter(self, editor, event):
            if (event.type() in (event.MouseButtonPress, event.MouseButtonDblClick)
                and event.button() == QtCore.Qt.LeftButton):
                    style = editor.style()
                    size = style.pixelMetric(style.PM_IndicatorWidth)
                    left = editor.layout().contentsMargins().left()
                    r = QtCore.QRect(
                        (left - size) / 2, 
                        (editor.height() - size) / 2, 
                        size, size)
                    if event.pos() in r:
                        model = editor.index.model()
                        index = QtCore.QModelIndex(editor.index)
                        if model.data(index, QtCore.Qt.CheckStateRole):
                            value = QtCore.Qt.Unchecked
                        else:
                            value = QtCore.Qt.Checked
                        model.setData(
                            index, value, QtCore.Qt.CheckStateRole)
                    return True
            return super().eventFilter(editor, event)
    
        def updateEditorGeometry(self, editor, opt, index):
            # ensure that the editor fills the whole index rect
            editor.setGeometry(opt.rect)
    

    Unrelated, but still important:

    Consider that the @pyqtSlot decorator is usually not required in normal situations like these. Also note that you missed the @ for the data_changed decorator, and the signature is also invalid, since it's incompatible with the signals you've connected it to. A more correct slot decoration would have been the following:

    @QtCore.pyqtSlot(QtCore.QModelIndex, QtCore.QModelIndex, 'QVector<int>')
    @QtCore.pyqtSlot('QList<QPersistentModelIndex>', QtCore.QAbstractItemModel.LayoutChangeHint)
    def data_changed(self, top_left, bottom_right):
        # ...
    

    With the first decoration for the dataChanged signal, and the second for layoutChanged. But, as said before, it's generally unnecessary to use slots as they usually are only required for better threading handling and sometimes provide slightly improved performance (which is not that important to this purpose).
    Also note that if you want to ensure that there's always an open editor whenever the model changes its "layout", you should also connect to rowsInserted and columnsInserted signals, since those operations do not send the layout change signal.