最后一列中的 QStyledItemDelegate 禁用按钮将选择移动到下一行

QStyledItemDelegate disabling button in last column moves selection to next row

设置说明

功能的详细说明

问题描述

最小功能示例:

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())

此行为的原因是禁用小部件会自动将焦点设置到焦点链中的下一个可用小部件。

实际行为基于 focusNextPrevChild 的 QAbstractItemView 的 re-implementation,它创建了一个“虚拟”QKeyPressEvent,带有发送到 keyPressEvent() 的制表符(或后制表符)键处理程序。

默认情况下,这会导致调用 table 视图对 moveCursor() 的重新实现,它着重于下一个选择table 项目(在您的情况下下一行中的第一个项目) ).

一个可能的解决方法是使用 QTableView 的子 class 并覆盖 focusNextPrevChild();通过这种方式,您可以 first 检查 current 小部件是否是按钮和视口的 child(这意味着它是您的其中一个编辑器),最终 return True 没有做任何其他事情:

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)

不幸的是,这不会解决您的实施的主要问题。

实现像您这样复杂的系统,需要特别注意和了解 Qt 视图的工作原理,主要问题与 setModelData() 可能由各种原因触发这一事实有关;其中之一是每当视图的当前索引发生变化时。这可能发生在键盘导航(tab/backtab、箭头等)时,也可能发生在鼠标更改当前选择时:您可以在 UI 中通过单击并按住鼠标按钮在项目上看到这一点在第一列上,然后开始将鼠标拖到有按钮的项目上;由于该操作更改了选择模型,因此这也会触发当前索引更改,因此会触发委托的 setModelData,因为持久性编辑器已打开。

更好的实现(也不需要单独的委托)意味着知道当前索引对应于“真”列还是“假”列。只要您知道当值为 True 时用于显示内容的列,那么设置值和显示按钮只是比较这三个值的问题:

        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)

按下按钮时设置数据遵循相同的概念;如果按钮在“true”列(用于将值设置为 False 的那个),则将其设置为 False,反之亦然:

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

然后,还需要做一些进一步的调整:

  • 为避免焦点问题,您可以通过设置属性 Qt.WA_TransparentForMouseEvents 让编辑器忽略鼠标事件,并通过将焦点策略设置为 No.Focus 来忽略键盘焦点;然后在“恢复”编辑器时恢复默认行为;
  • 要使按钮透明,请使用使每个组件不可见的样式表:color: transparent; background: transparent; border: none;
  • 不在paint方法中打开编辑器,而是在设置模型时和添加新行时正确调用openPersistentIndex()
  • 如果你想隐藏索引的文本,只需覆盖displayText()和return一个空字符串即可;通过这种方式,您可以保留显示所选项目的默认绘画行为;
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)

# ...

进一步的改进将考虑选项卡导航,为此您需要调整模型 视图。通过以下修改,按 Tab 键仅在具有有效数据或活动编辑器的索引之间切换:

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()

更新:更多选项

我想到还有另一个可用的替代方案:在保持 two-column 要求的同时,只要 table 正确设置 [=68] 就可以有一个委托=]跨度.

这需要一些独创性,并且需要进一步的 class(具有适当的 user 属性 设置),但它可能会提供更好的结果;诀窍是创建一个包含两个按钮的自定义小部件。还需要进行一些进一步的调整(特别是确保在调整列大小时考虑内部小部件的大小)。

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))

当然,更简单的解决方案是完全避免任何编辑器,并将绘画委托给项目委托。

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)

注意,在这种情况下,如果要保留有效的键盘(Tab)导航,模型也需要调整:

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

不幸的是,这会导致水平 header 出现意外行为,因为只有启用的列会以某些特定样式“突出显示”。

也就是说,这种方法的另一个重要缺点是您将完全失去样式提供的任何动画:因为样式使用 actual 小部件来创建视觉动画,并且绘画仅基于当前的 QStylOption 值,那些动画将不可用。