qtselectionpysideqtableviewqstyleditemdelegate

QStyledItemDelegate disabling button in last column moves selection to next row


Setup description

Detailed description of functionality

Problem description

Minimal functioning example:

from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QAbstractItemView
from PySide6.QtWidgets import  QTableView, QWidget, QStyledItemDelegate, QPushButton
from PySide6.QtCore import Qt, QModelIndex, QAbstractTableModel, QItemSelectionModel

class TrueButtonDelegate(QStyledItemDelegate):
    
    def __init__(self, parent):
        QStyledItemDelegate.__init__(self, parent)    

    def paint(self, painter, option, index):
        self.parent().openPersistentEditor(index) # this should be somewhere else, not in paint

    def createEditor(self, parent, option, index):
        editor = QPushButton('True', parent)
        editor.setEnabled(False)
        editor.clicked.connect(self.buttonClicked)
        return editor
    
    def setEditorData(self, editor, index):
        if not index.data():
            editor.setText('True')
            editor.setEnabled(True)
            editor.setFlat(False)
            
        else:
            editor.setText('')
            editor.setEnabled(False)
            editor.setFlat(True)
    
    def setModelData(self, editor, model, index):
        model.setData(index, True, role=Qt.EditRole)
        
    def buttonClicked(self):
        self.commitData.emit(self.sender())    
    
    
    def eventFilter(self, obj, event):
        if event.type() == event.Type.Wheel:
            event.setAccepted(False)
            return True
        return super().eventFilter(obj, event)

class FalseButtonDelegate(QStyledItemDelegate):
    def __init__(self, parent):
        QStyledItemDelegate.__init__(self, parent)    

    def paint(self, painter, option, index):
        self.parent().openPersistentEditor(index) # this should be somewhere else, not in paint

    def createEditor(self, parent, option, index):
        editor = QPushButton('False', parent)
        editor.setEnabled(True)
        editor.clicked.connect(self.buttonClicked)
        return editor
    
    def setEditorData(self, editor, index):
        if index.data():
            editor.setText('False')
            editor.setEnabled(True)
            editor.setFlat(False)
            
        else:
            editor.setText('')
            editor.setEnabled(False)
            editor.setFlat(True)
    
    def setModelData(self, editor, model, index):
        model.setData(index, False, role=Qt.EditRole)
        
    def buttonClicked(self):
        self.commitData.emit(self.sender())    
    
    
    def eventFilter(self, obj, event):
        if event.type() == event.Type.Wheel:
            event.setAccepted(False)
            return True
        return super().eventFilter(obj, event)

class TableModel(QAbstractTableModel):
    def __init__(self, localData=[[]], parent=None):
        super().__init__(parent)
        self.modelData = localData

    def headerData(self, section: int, orientation: Qt.Orientation, role: int):
        if role == Qt.DisplayRole:
            if orientation == Qt.Vertical:
                return "Row " + str(section)

    def columnCount(self, parent=None):
        return 3

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

    def data(self, index: QModelIndex, role: int):
        if role == Qt.DisplayRole:
            row = index.row()
            return self.modelData[row]
    
    def setData(self, index, value = None, role=Qt.DisplayRole):
        row = index.row()
        self.modelData[row] = value

        index = self.index(row, 0)
        self.dataChanged.emit(index, index) 
        index = self.index(row, 1)
        self.dataChanged.emit(index, index) 
        index = self.index(row, 2)
        self.dataChanged.emit(index, index) 

        return True

app = QApplication()

data = [True, True, True, True, True, True, True, True, True, True, True, True, True, True]

model = TableModel(data)

tableView = QTableView()
tableView.setModel(model)
selectionModel = QItemSelectionModel(model)
tableView.setSelectionModel(selectionModel)
tableView.setItemDelegateForColumn(1, FalseButtonDelegate(tableView))
tableView.setItemDelegateForColumn(2, TrueButtonDelegate(tableView))
tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
tableView.setSelectionMode(QAbstractItemView.SingleSelection)


widget = QWidget()
widget.horizontalHeader = tableView.horizontalHeader()
widget.horizontalHeader.setStretchLastSection(True)
widget.mainLayout = QVBoxLayout()
widget.mainLayout.setContentsMargins(1,1,1,1)
widget.mainLayout.addWidget(tableView)
widget.setLayout(widget.mainLayout)

mainWindow = QMainWindow()
mainWindow.setCentralWidget(widget)
mainWindow.setGeometry(0, 0, 380, 300)
mainWindow.show()


exit(app.exec())

Solution

  • The reason for this behavior is that disabling the widget automatically sets the focus to the next available widget in the focus chain.

    The actual behavior is based on the QAbstractItemView's re-implementation of focusNextPrevChild, which creates a "virtual" QKeyPressEvent with a tab (or backtab) key that is sent to the keyPressEvent() handler.

    By default, this results in calling the table view's reimplementation of moveCursor(), which focuses on the next selectable item (the first item in the next row in your case).

    A possible workaround for this would be to use a subclass of QTableView and override focusNextPrevChild(); in this way you can first check if the current widget is a button and a child of the viewport (meaning it's one of your editors), and eventually just return True without doing anything else:

    class TableView(QTableView):
        def focusNextPrevChild(self, isNext):
            if isNext:
                current = QApplication.focusWidget()
                if isinstance(current, QPushButton) and current.parent() == self.viewport():
                    return True
            return super().focusNextPrevChild(isNext)
    

    Unfortunately, this won't resolve a major issue with your implementation.

    Implementing such complex systems like yours, requires some special care and knowledge about how Qt views work, and the main problem is related to the fact that setModelData() can be triggered by various reasons; one of them is whenever the current index of the view changes. This can happen with keyboard navigation (tab/backtab, arrows, etc), but also when the mouse changes the current selection: you can see this in your UI by clicking and keeping the mouse button pressed on an item on the first column, and then begin to drag the mouse on items that have buttons; since that operation changes the selection model, this also triggers the current index change, and consequentially the setModelData of the delegate, since the persistent editor is opened.

    A better implementation (which also doesn't require separate delegates) implies knowing whether the current index corresponds to the "true" or "false" column. As long as you know the column used to show contents when the value is True, then setting the value and showing the buttons is just a matter of comparing those three values:

            value = index.data()
            trueColumn = index.column() == self.TrueColumn
            if value == trueColumn:
                # we are in the column that should show the widget
            else:
                # we are in the other column (whatever it is)
    

    Setting the data when the button is pressed follows the same concept; if the button is in the "true" column (the one used to set the value to False), set it to False, and vice versa:

        model.setData(index, index.column() != self.TrueColumn, Qt.EditRole)
    

    Then, some further adjustments are also required:

    class ButtonDelegate(QStyledItemDelegate):
        TrueColumn = 1
        isClicked = False
        def buttonClicked(self):
            self.isClicked = True
            self.commitData.emit(self.sender())
            self.isClicked = False
    
        def createEditor(self, parent, option, index):
            editor = QPushButton(str(index.column() != self.TrueColumn), parent)
            editor.clicked.connect(self.buttonClicked)
            return editor
    
        def eventFilter(self, editor, event):
            if event.type() == event.MouseMove:
                editor.mouseMoveEvent(event)
                event.setAccepted(True)
                return True
            return super().eventFilter(editor, event)
    
        def displayText(self, *args):
            return ''
    
        def setEditorData(self, editor, index):
            value = index.data()
            trueColumn = index.column() == self.TrueColumn
            if value == trueColumn:
                editor.setAttribute(Qt.WA_TransparentForMouseEvents, False)
                editor.setStyleSheet('')
                editor.setFocusPolicy(Qt.StrongFocus)
                if self.isClicked:
                    editor.setFocus()
                    self.parent().setCurrentIndex(index)
            else:
                editor.setAttribute(Qt.WA_TransparentForMouseEvents, True)
                editor.setStyleSheet(
                    'color:transparent; background: transparent; border: none;')
                editor.setFocusPolicy(Qt.NoFocus)
    
        def setModelData(self, editor, model, index):
            sender = self.sender()
            if sender:
                model.setData(index, index.column() != self.TrueColumn, Qt.EditRole)
    
    
    app = QApplication([])
    
    data = [True] * 16
    
    tableView = QTableView()
    tableView.setModel(model)
    selectionModel = QItemSelectionModel(model)
    tableView.setSelectionModel(selectionModel)
    
    delegate = ButtonDelegate(tableView)
    tableView.setItemDelegateForColumn(1, delegate)
    tableView.setItemDelegateForColumn(2, delegate)
    
    tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
    tableView.setSelectionMode(QAbstractItemView.SingleSelection)
    
    def updateEditors(parent, first, last):
        for row in range(first, last + 1):
            tableView.openPersistentEditor(model.index(row, 1))
            tableView.openPersistentEditor(model.index(row, 2))
    
    updateEditors(None, 0, model.rowCount() - 1)
    model.rowsInserted.connect(updateEditors)
    
    # ...
    
    

    A further improvement would consider tab navigation, and for this you need to tweak the model and the view. With the following modifications, pressing tab only changes between indexes with valid data or active editor:

    class TableModel(QAbstractTableModel):
        tabPressed = False
        def __init__(self, localData=[[]], parent=None):
            super().__init__(parent)
            self.modelData = localData
    
        def flags(self, index):
            flags = super().flags(index)
            if 0 < index.column() < self.columnCount() and self.tabPressed:
                if (index.column() != 1) == self.modelData[index.row()]:
                    flags &= ~(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
            return flags
    
        def headerData(self, section: int, orientation: Qt.Orientation, role: int):
            if role == Qt.DisplayRole and orientation == Qt.Vertical:
                    return "Row " + str(section)
    
        def columnCount(self, parent=None):
            return 3
    
        def rowCount(self, parent=None):
            return len(self.modelData)
    
        def data(self, index: QModelIndex, role: int):
            if role == Qt.DisplayRole:
                return self.modelData[index.row()]
        
        def setData(self, index, value = None, role=Qt.DisplayRole):
            row = index.row()
            self.modelData[row] = value
    
            # do not emit dataChanged for each index, emit it for the whole range
            self.dataChanged.emit(self.index(row, 0), self.index(row, 2)) 
    
            return True
    
    
    class TableView(QTableView):
        def moveCursor(self, action, modifiers):
            self.model().tabPressed = True
            new = super().moveCursor(action, modifiers)
            self.model().tabPressed = False
            return new
    
    
    # ...
    
    tableView = TableView()
    

    Update: further options

    It occured to me that there is another available alternative: while keeping the two-column requirement, it is possible to have a single delegate, as long as the table has properly set spans.

    This requests some ingenuity, and a further class (with a proper user property set) is required, but it might provide a better result; the trick is to create a custom widget that contains both buttons. Some further adjustments are required too (especially to ensure that the size of the inner widgets is respected whenever the columns are resized).

    class Switch(QWidget):
        valueChanged = Signal(bool)
        clicked = Signal()
        _value = False
        def __init__(self, table, column):
            super().__init__(table.viewport())
            self.setFocusPolicy(Qt.TabFocus)
            layout = QHBoxLayout(self)
            layout.setContentsMargins(0, 0, 0, 0)
            self.spacing = self.style().pixelMetric(QStyle.PM_HeaderGripMargin)
            layout.setSpacing(self.spacing)
            self.buttons = []
            for v in range(2):
                button = QPushButton(str(bool(v)))
                self.buttons.append(button)
                layout.addWidget(button)
                button.setMinimumWidth(10)
                button.clicked.connect(self.buttonClicked)
    
            self.header = table.horizontalHeader()
            self.columns = column, column + 1
            self.updateButtons(False)
    
            self.header.sectionResized.connect(self.updateSizes)
            self.resizeTimer = QTimer(self, interval=0, singleShot=True, 
                timeout=self.updateSizes)
    
        @Property(bool, user=True, notify=valueChanged)
        def value(self):
            return self._value
    
        @value.setter
        def value(self, value):
            if self._value != value:
                self._value = value
                self.valueChanged.emit(value)
            self.updateButtons(self._value)
    
        def updateButtons(self, value):
            focused = False
            self.setFocusProxy(None)
            for i, button in enumerate(self.buttons):
                if i != value:
                    button.setAttribute(Qt.WA_TransparentForMouseEvents, False)
                    self.setFocusProxy(button)
                    button.setStyleSheet('')
                else:
                    if button.hasFocus():
                        focused = True
                    button.setAttribute(Qt.WA_TransparentForMouseEvents, True)
                    button.setStyleSheet(
                        'color: transparent; background: transparent; border: none;')
            if focused:
                self.setFocus(Qt.MouseFocusReason)
    
        def buttonClicked(self):
            button = self.sender()
            self.value = bool(self.buttons.index(button))
            self.clicked.emit()
    
        def updateSizes(self):
            for i, column in enumerate(self.columns):
                size = self.header.sectionSize(column)
                if i == 0:
                    size -= self.spacing
                self.layout().setStretch(i, size)
            self.layout().activate()
    
        def focusNextPrevChild(self, isNext):
            return False
    
        def resizeEvent(self, event):
            self.updateSizes()
    
    
    class SwitchButtonDelegate(QStyledItemDelegate):
        def displayText(self, *args):
            return ''
    
        def createEditor(self, parent, option, index):
            editor = Switch(self.parent(), index.column())
            def clicked():
                if persistent.isValid():
                    index = persistent.model().index(
                        persistent.row(), persistent.column(), persistent.parent())
                    view.setCurrentIndex(index)
            view = option.widget
            persistent = QPersistentModelIndex(index)
            editor.clicked.connect(clicked)
            editor.valueChanged.connect(lambda: self.commitData.emit(editor))
            return editor
    
    # ...
    
    tableView.setItemDelegateForColumn(1, SwitchButtonDelegate(tableView))
    
    def updateEditors(parent, first, last):
        for row in range(first, last + 1):
            tableView.setSpan(row, 1, 1, 2)
            tableView.openPersistentEditor(model.index(row, 1))
    

    Of course, the simpler solution is to avoid any editor at all, and delegate the painting to the item delegate.

    class PaintButtonDelegate(QStyledItemDelegate):
        _pressIndex = _mousePos = None
        def __init__(self, trueColumn=0, parent=None):
            super().__init__(parent)
            self.trueColumn = trueColumn
    
        def paint(self, painter, option, index):
            opt = QStyleOptionViewItem(option)
            self.initStyleOption(opt, index)
            style = opt.widget.style()
            opt.text = ''
            opt.state |= style.State_Enabled
            style.drawControl(style.CE_ItemViewItem, opt, painter, opt.widget)
            if index.data() == (index.column() == self.trueColumn):
                btn = QStyleOptionButton()
                btn.initFrom(opt.widget)
                btn.rect = opt.rect
                btn.state = opt.state
                btn.text = str(index.column() != self.trueColumn)
                if self._pressIndex == index and self._mousePos in btn.rect:
                    btn.state |= style.State_On
                if index == option.widget.currentIndex():
                    btn.state |= style.State_HasFocus
                style.drawControl(style.CE_PushButton, btn, painter, opt.widget)
    
        def editorEvent(self, event, model, option, index):
            if event.type() == event.MouseButtonPress:
                if index.data() == (index.column() == self.trueColumn):
                    self._pressIndex = index
                    self._mousePos = event.pos()
                option.widget.viewport().update()
            elif event.type() == event.MouseMove and self._pressIndex is not None:
                self._mousePos = event.pos()
                option.widget.viewport().update()
            elif event.type() == event.MouseButtonRelease:
                if self._pressIndex == index and event.pos() in option.rect:
                    model.setData(index, not index.data(), Qt.EditRole)
                self._pressIndex = self._mousePos = None
                option.widget.viewport().update()
            elif event.type() == event.KeyPress:
                if event.key() == Qt.Key_Space:
                    value = not index.data()
                    model.setData(index, value, Qt.EditRole)
                    newIndex = model.index(index.row(), self.trueColumn + (not value))
                    option.widget.setCurrentIndex(newIndex)
                option.widget.viewport().update()
            return super().editorEvent(event, model, option, index)
    
    # ...
    
    delegate = PaintButtonDelegate(1, tableView)
    tableView.setItemDelegateForColumn(1, delegate)
    tableView.setItemDelegateForColumn(2, delegate)
    

    Note that in this case, if you want to keep a valid keyboard (Tab) navigation, the model also requires adjustments:

    class TableModel(QAbstractTableModel):
        # ...
        def flags(self, index):
            flags = super().flags(index)
            if 0 < index.column() < 3:
                if index.data() == index.column() - 1:
                    flags &= ~Qt.ItemIsEnabled
            return flags
    

    This unfortunately results in unexpected behavior of the horizontal header, as only the enabled columns will be "highlighted" with some specific styles.

    That said, the other important drawback of this approach is that you will completely lose any animation provided by the style: since the style uses actual widgets to create visual animations, and the painting is only based on the current QStylOption value, those animations will not be available.