0

The code below mostly does what I want: I start out with data of 2D table (list of lists), where 1st and 3rd column start out as random numbers, but 2nd column starts with None. This data is used for a table model of a QTableView, which for the cells in the 2nd None column, displays QComboBox with several entries that are also random numbers. The idea is then, that making a choice of a value in a QComboBox sets the corresponding integer value in the 2D table at the given row and column. This is done well by the combo.currentIndexChanged.connect... line.

However, I have commented that combo.currentIndexChanged.connect... line in the code below, just to emphasize the weird behavior that I'm seeing.

First, I'd like to note something that was not obvious to me, which is that

  • Without openPersistentEditor, the corresponding cell will never run ComboBoxDelegate.createEditor (and ComboBoxDelegate.setModelData), and correspondingly never show dropdowns/QComboBoxes.
  • Cells that do not have openPersistentEditor, but return flags with Qt.ItemIsEditable, will still allow for double-click and edit via the default line edit (and will only call setData on change).

Now, then, the weird behavior is:

  • I start the code, I get the window rendered with the table, and I immediately close the application. In the printout, I get:
setModelData self.sender()=None index.row()=2 index.column()=1 editor.currentText()='70'
setData self.sender()=None row=2 col=1 value='70'
... self._data=[[67, None, 41], [99, None, 5], [27, '70', 47]]

So, without any interaction at all, somehow both QStyledItemDelegate.setModelData and QAbstractTableModel.setData triggered on application exit; and while the value of the QComboBox, at this time, at 3rd row 2nd column was indeed '70', this got written into my table - as a string - without any intention, which I do not want.

Note: since there is a call to setData from setModelData, I'd assume the setData is dependent on setModelData; however, even if the method setModelData is completely commented away from the code (with openPersistentEditor left), the same experiment prints out:

setData self.sender()=None row=2 col=1 value='70'
... self._data=[[67, None, 41], [99, None, 5], [27, '70', 47]]

... so setData of the model gets independently triggered by application close (even without setModelData)


Here is another example:

  • I start the application, I get the window rendered with the table

  • I click on empty space in the application window - I get a printout:

    setModelData self.sender()=None index.row()=2 index.column()=1 editor.currentText()='70'
    setData self.sender()=None row=2 col=1 value='70'
    ... self._data=[[67, None, 41], [99, None, 5], [27, '70', 47]]
    
  • I close the application - there is no printout.


Another example:

  • I start the application, I get the window rendered with the table
  • I click on empty space in the application window - I get a printout:
    setModelData self.sender()=None index.row()=2 index.column()=1 editor.currentText()='70'
    setData self.sender()=None row=2 col=1 value='70'
    ... self._data=[[67, None, 41], [99, None, 5], [27, '70', 47]]
    
  • I click on the QComboBox at row=2 col=1 (third row, second column) - popup is shown, there is no printout
  • I click on the QComboBox at second row, second column - popup is shown, and exact same previous printout is repeated

What causes these triggers of QStyledItemDelegate.setModelData and QAbstractTableModel.setData on application exit, click on empty space - and every time a QComboBox opens its popup in response to mouse left-click (though only if it is not the same QComboBox reported in the last printout); and how can I prevent them, so it is only the currentIndexChanged handler that is allowed to call setData?

Here is the example:

import sys
import random
from PyQt5.QtWidgets import QApplication, QTableView, QComboBox, QStyledItemDelegate
from PyQt5.QtCore import Qt, QAbstractTableModel

class RandomDataModel(QAbstractTableModel):
    def __init__(self, data, parent=None):
        super().__init__(parent)
        self._data = data

    def rowCount(self, parent=None):
        return len(self._data)

    def columnCount(self, parent=None):
        return len(self._data[0])

    def data(self, index, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            return self._data[index.row()][index.column()]
        return None

    def setData(self, index, value, role=Qt.EditRole):
        row, col = index.row(), index.column()
        if role == Qt.EditRole:
            print(f"setData {self.sender()=} {row=} {col=} {value=}")
            self._data[row][col] = value
            self.dataChanged.emit(index, index)
            print(f"... {self._data=}")
            return True
        return False

    def flags(self, index):
        return Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsSelectable

class ComboBoxDelegate(QStyledItemDelegate):
    def createEditor(self, parent, option, index):
        # self.parent() here is MainWindow (that is, QTableView)
        #print(f"createEditor {self.parent()=} {parent=}")
        combo = QComboBox(parent)
        combo.addItems([str(random.randint(1, 100)) for _ in range(3)])
        #combo.currentIndexChanged.connect(lambda idx, qmidx=index: self.commitComboValue(idx, qmidx))
        return combo

    def setModelData(self, editor, model, index):
        print(f"setModelData {self.sender()=} {index.row()=} {index.column()=} {editor.currentText()=}")
        model.setData(index, editor.currentText())

    def commitComboValue(self, index, qmidx):
        # self.parent() here is MainWindow (that is, QTableView)
        # self.sender() is QComboBox
        row, col = qmidx.row(), qmidx.column()
        value = int(self.sender().itemText(index))
        print(f"commitComboValue {row=} {col=} {index=} {value=} {self.sender()=}")
        table_view = self.parent()
        table_view.model().setData(qmidx, value)

class MainWindow(QTableView):
    def __init__(self):
        super().__init__()

        data = [
            [random.randint(1, 100), None, random.randint(1, 100)],
            [random.randint(1, 100), None, random.randint(1, 100)],
            [random.randint(1, 100), None, random.randint(1, 100)],
        ]

        self.the_model = RandomDataModel(data)
        self.delegate = ComboBoxDelegate(self)

        self.setModel(self.the_model)
        self.setItemDelegateForColumn(1, self.delegate)

        for row in range(3):
            self.openPersistentEditor(self.the_model.index(row, 1))

        self.resize(300, 300)

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.setWindowTitle("QTableView Example")
    window.show()
    sys.exit(app.exec_())
2
  • 1
    I cannot check your code thoughtfully right now, but I can provide an important suggestion: delegates read/write data on the model based on the user property of the widget (which is sadly undocumented officially), which means that the delegate assumes that a property must be set/updated on the editor or the delegate's view based on that. For QComboBox, the user property is currentText, which is why you get a string set on the model. While not always desirable, it's the expected behavior (especially for cases like QtSql models or QDataWidgetMapper). Commented Aug 7 at 23:09
  • 1
    Also, the delegate automatically triggers a commitData or closeEditor whenever the editor loses focus (directly or not, except for its direct children, as long as they're not popups), which is why you get a setModelData() even on application quit. If you want to update the model based on the index of the combo or its value (not its text) and only on active popup/wheel/keyboard selection, you need to override more carefully (including editorEvent() and eventFilter()) and eventually add further methods connected to the QComboBox signals. Commented Aug 7 at 23:13

1 Answer 1

0

Thanks to the comments from @musicamante, I arrived at a sort of a workaround.

First, since the commitComboValue actually works fine (as a slot handler of combo.currentIndexChanged.connect) in respect to casting the value from string to integer, I thought I could avoid messing with the currentText user property.

So, I tried following up on:

Also, the delegate automatically triggers a commitData or closeEditor whenever the editor loses focus (directly or not, except for its direct children, as long as they're not popups), which is why you get a setModelData() even on application quit

Again, given that the combo.currentIndexChanged.connect...commitComboValue,,, works fine for me, I thought at first about "disconnecting" these "extra" signals (e.g. closeEditor) - however, looking at the Qt5 source code, that does not look simple at all. All I could gather is that more-less the only actual call to setModelData is in QAbstractItemView::commitData:

... and there is call to commitData in QAbstractItemView::currentChanged, and also the delegate commitData signal is connected to QAbstractItemView::commitData as slot. Also, commitData as signal is emitted from QAbstractItemDelegate, among others from QAbstractItemDelegatePrivate::_q_commitDataAndCloseEditor:

Seeing how it would be very difficult to untangle this complex web of signals and slots, I tried looking into something else. Recall my earlier conclusion:

Note: since there is a call to setData from setModelData, I'd assume the setData is dependent on setModelData; however, even if the method setModelData is completely commented away from the code (with openPersistentEditor left), the same experiment prints out [... setData] ... .. so setData of the model gets independently triggered by application close (even without setModelData)

At first, this implied to me that it is impossible to stop calling setData on events like application close; however, it turns out that when setModelData is runs (is printed), the subsequent printout of setData is indeed due to the direct call!

So, all I needed was to do the following changes in the OP code:

# ...
class ComboBoxDelegate(QStyledItemDelegate):
    def createEditor(self, parent, option, index):
        # ...
        combo.currentIndexChanged.connect(lambda idx, qmidx=index: self.commitComboValue(idx, qmidx)) # UNCOMMENT THIS LINE
        return combo

    def setModelData(self, editor, model, index):
        print(f"setModelData {self.sender()=} {index.row()=} {index.column()=} {editor.currentText()=}")
        #model.setData(index, editor.currentText()) # COMMENT THIS LINE
# ...

... and I could realise, that e.g. on direct application exit, I get only the printout:

setModelData self.sender()=None index.row()=2 index.column()=1 editor.currentText()='70'

... but no setData is printed, that is, called!

Consequently, the only time setData, is in response to currentIndexChanged of the QComboBox, that is, in response to its slot handler commitComboValue! For instance, I'd get printout like this (actions added to the printout) - note that the data does not contain "spurious" string entries anymore:

# app start; click empty space on window
setModelData self.sender()=None index.row()=2 index.column()=1 editor.currentText()='50'

# click to open QComboBox dropdown in second row (row=1)
# click to choose/select second item in dropdown
commitComboValue row=1 col=1 index=1 value=41 self.sender()=<PyQt5.QtWidgets.QComboBox object at 0x000001d36bff1e20>
setData self.sender()=<PyQt5.QtWidgets.QComboBox object at 0x000001d36bff1e20> row=1 col=1 value=41
... self._data=[[17, None, 94], [58, 41, 95], [78, None, 30]]

# click empty space on window
setModelData self.sender()=None index.row()=1 index.column()=1 editor.currentText()='41'

# click to open QComboBox dropdown in first row (row=0)
# click to choose/select second item in dropdown
commitComboValue row=0 col=1 index=1 value=1 self.sender()=<PyQt5.QtWidgets.QComboBox object at 0x000001d36bff1d90>
setData self.sender()=<PyQt5.QtWidgets.QComboBox object at 0x000001d36bff1d90> row=0 col=1 value=1
... self._data=[[17, 1, 94], [58, 41, 95], [78, None, 30]]

# close the application
setModelData self.sender()=None index.row()=0 index.column()=1 editor.currentText()='1'

To summarize: it seems all of these application events like application close, click on empty window background, click to open popup of a QComboBox different from the last one, which I considered spurious, seem to call setModelData in this example - and we can suppress changing of the internal data there simply by avoiding the call to setData there - then, only the explicit call to setData from the currentIndexChanged slot handler remains active; which is what was asked for in the OP question.

Now, it is a shame that we cannot determine the reason for the call to setModelData from within that function (at least, self.sender() is None there), because I could imagine a situation where I'd want to allow a call to setData on application close, without allowing such a call on e.g. click of empty background of window - but that would be for a different question, as it is not an issue in the OP.

Sign up to request clarification or add additional context in comments.

3 Comments

sender() only makes sense for functions eventually caused by a signal (which normally results in the QObject that "owns" and therefore emitted the signal) whereas setModelData() may also be called as a consequence of events (which don't have an owner), primarily due to QAbstractItemView.currentChanged() causing its commitData. There are possible ways to track down the "cause" of that call, but that should be set in some way before it's been called, and that may not always be reliable due to the way events may be processed, especially when the application is closing.
If you only want data to be set when the user actively selects a new index in the combobox, then, yes, setModelData() should ignore everything for that column index (specifically because the user property, which is the property eventually used by default by delegates when updating the model from an editor, is set to the currentText property of QComboBox). Also, if you want to prevent arbitrary effects of setData() calls with invalid data types, then you should implement it in the setData override of your model, so that you can ignore any attempt if it doesn't match the data type.
Then again, setModelData() may do that as well, based on the column/row combination (assuming you use one delegate for multiple rows and/or columns) and checking the value type from the editor user property (or whatever you want to read from it, if you know the editor type). Finally, there are further ways to detect the reason for a commit attempt, including installing event filters or (as written above) checking the eventFilter() of the delegate (which by default detects event intended for the editors) and verifying if the event may trigger an unwanted update (eg: focus out, close, etc.).

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.