How To Customize QTableView's Editing Behavior



QTableView's default editing behavior seems to have been designed with spreadsheet-like usage in mind. When your table needs a more "row-based" editing with some columns being read-only, it becomes rather difficult to customize QTableView to support that. I recently needed to do it for moneyGuru and this article explains how I did it.

QTableView's default behavior

The biggest problem with QTableView's default behavior is that it always starts editing based on its currentIndex and this, even if selectionBehavior is set to QAbstractItemView.SelectRows and this, for any kind of edit trigger. This means that if, for example, you have a table with two columns, Name and Description, and that a user selects a row by clicking on that row's Description cell, pressing Return will start editing the Description column, not the Name column as you would expect. Moreover, if Description was not editable, then pressing Return won't do anything at all, even if Name is editable.

Additionally, Tab and Backtab navigation is quite funky. It continues editing even when reaching the end of a line (it starts editing the line below). More annoying, it doesn't step over cells that are not editable. Thus, if you tab next to a cell that is not editable, editing will stop right there, even if there's an editable cell right of that non-editable cell.

Last, even though QAbstractItemModel's submit and revert slots are nice to have, they are not used by QTableView where you'd expect them to be. When editing stops by a "focus away" (the user just clicks elsewhere), neither submit or revert is called. Same thing for editing interruptions caused by tabbing.

How to fix it

In moneyGuru, I want rows to be editable by pressing Return or cell double-clicking. I want the edits to be row-based, that is, that a row's edits is buffered until the user either commits those edits by pressing Return again, clicks away or tabs until the end of the row. This means that whenever editing ends in any other way other than the user pressing Escape, the model's submit() method has to be called. Also, some cells in moneyGuru's tables are not editable, but they're not supposed to stop the user from tabbing over them.

To obtain that behavior, I had to subclass QTableView (in fact, it's a little more complex than that for moneyGuru, but for the purpose of this article, I keep things simple). At first, I tried to modify editing behavior by overriding edit(index, trigger, event), but it makes things much more complicated because this method is called all the time with all kinds of triggers and events that are hard to make sense from. Moreover, I kept getting "editing failed" logs in stderr for which I had no idea of what caused them.

After some messing around, it turns our that it's much easier to obtain that behavior by overriding closeEditor(editor, hint). closeEditor is called whenever the editor for a cell has just been closed. More important, hint tells you if it has been closed by tabbing or back-tabbing with the EditNextItem and EditPreviousItem hints. With this, it's possible to fix both the step-over-non-editable-cells and stop-at-line-ends problems.

For the editing start position problem, the best solution I found is to simply override keyPressEvent(event), check for Return presses and then manually initiate the editing process with the correct index.

Then, all you need are helper methods to find first/next/previous editable index from a base index. Code speaks better than words:

class TableView(QTableView):
    def _firstEditableIndex(self, originalIndex, columnIndexes=None):
        """Returns the first editable index in `originalIndex`'s row or None.

        If `columnIndexes` is not None, the scan for an editable index will be
        limited to these columns.
        """
        model = self.model()
        h = self.horizontalHeader()
        editedRow = originalIndex.row()
        if columnIndexes is None:
            # We use logicalIndex() because it's possible the columns have been
            # re-ordered.
            columnIndexes = [h.logicalIndex(i) for i in range(h.count())]
        create = lambda col: model.createIndex(editedRow, col, None)
        scannedIndexes = [create(i) for i in columnIndexes if not h.isSectionHidden(i)]
        isEditable = lambda index: model.flags(index) & Qt.ItemIsEditable
        editableIndexes = filter(isEditable, scannedIndexes)
        return editableIndexes[0] if editableIndexes else None

    def _previousEditableIndex(self, originalIndex):
        """Returns the first editable index at the left of `originalIndex` or None.
        """
        h = self.horizontalHeader()
        myCol = originalIndex.column()
        columnIndexes = [h.logicalIndex(i) for i in range(h.count())]
        # keep only columns before myCol
        columnIndexes = columnIndexes[:columnIndexes.index(myCol)]
        # We want the previous item, the columns have to be in reverse order
        columnIndexes = reversed(columnIndexes)
        return self._firstEditableIndex(originalIndex, columnIndexes)

    def _nextEditableIndex(self, originalIndex):
        """Returns the first editable index at the right of `originalIndex` or None.
        """
        h = self.horizontalHeader()
        myCol = originalIndex.column()
        columnIndexes = [h.logicalIndex(i) for i in range(h.count())]
        # keep only columns after myCol
        columnIndexes = columnIndexes[columnIndexes.index(myCol)+1:]
        return self._firstEditableIndex(originalIndex, columnIndexes)

    def closeEditor(self, editor, hint):
        # The problem we're trying to solve here is the edit-and-go-away problem. 
        # When ending the editing with submit or return, there's no problem, the
        # model's submit()/revert() is correctly called. However, when ending
        # editing by clicking away, submit() is never called. Fortunately,
        # closeEditor is called and, AFAIK, it's the only case where it's called
        # with NoHint (0). So, in these cases, we want to call model.submit()
        if hint == QAbstractItemDelegate.NoHint:
            QTableView.closeEditor(self, editor,
                QAbstractItemDelegate.SubmitModelCache)

        # And here, what we're trying to solve is the problem with editing
        # next/previous lines. If there are no more editable indexes, stop
        # editing right there. Additionally, we are making tabbing step over
        # non-editable cells
        elif hint in (QAbstractItemDelegate.EditNextItem,
                      QAbstractItemDelegate.EditPreviousItem):
            if hint == QAbstractItemDelegate.EditNextItem:
                editableIndex = self._nextEditableIndex(self.currentIndex())
            else:
                editableIndex = self._previousEditableIndex(self.currentIndex())
            if editableIndex is None:
                QTableView.closeEditor(self, editor,
                    QAbstractItemDelegate.SubmitModelCache)
            else:
                QTableView.closeEditor(self, editor, 0)
                self.setCurrentIndex(editableIndex)
                self.edit(editableIndex)
        else:
            QTableView.closeEditor(self, editor, hint)

    def keyPressEvent(self, event):
        if (event.key() == Qt.Key_Return) and \
            (self.state() != QAbstractItemView.EditingState):
            selectedRows = self.selectionModel().selectedRows()
            if selectedRows:
                selectedIndex = selectedRows[0]
                editableIndex = self._firstEditableIndex(selectedIndex)
                self.setCurrentIndex(editableIndex)
                self.edit(editableIndex)
        else:
            QTableView.keyPressEvent(self, event)