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_())
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.
There are various possibilities to work around this:
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.