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
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
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 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
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 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 editableIndex = self._firstEditableIndex(selectedIndex) self.setCurrentIndex(editableIndex) self.edit(editableIndex) else: QTableView.keyPressEvent(self, event)