【问题标题】:adding a checkbox-column to a custom QSortFilterProxyModel将复选框列添加到自定义 QSortFilterProxyModel
【发布时间】:2020-02-27 01:45:14
【问题描述】:

我有一个代码来表示一个表中的熊猫数据框,并提供如下所示的过滤功能:

我想在第一列之前插入一个复选框列,以便用户可以选择行。 该代码使用PandasModel(QtCore.QAbstractTableModel) 模型读取pandas 数据并使用CustomProxyModel(QtCore.QSortFilterProxyModel)QTableView 添加过滤功能。

我的问题是如果我需要添加额外的复选框列,我应该将它添加到PandasModel 还是CustomProxyModel 或其他地方?作为替代解决方案,我发现SO solution 建议使用自定义CheckBoxDelegate(我在下面的代码中添加)但它似乎占据了第一列而不是插入新列。也不允许点击。

#!/usr/bin/env python
#-*- coding:utf-8 -*-

from PyQt5 import QtCore, QtGui, QtWidgets
import pandas as pd

class PandasModel(QtCore.QAbstractTableModel):
    def __init__(self, df=pd.DataFrame(), parent=None):
        QtCore.QAbstractTableModel.__init__(self, parent=parent)
        self._df = df.copy()
        self.bolds = dict()

    def toDataFrame(self):
        return self._df.copy()

    def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
        if orientation == QtCore.Qt.Horizontal:
            if role == QtCore.Qt.DisplayRole:
                try:
                    return self._df.columns.tolist()[section]
                except (IndexError,):
                    return QtCore.QVariant()
            elif role == QtCore.Qt.FontRole:
                return self.bolds.get(section, QtCore.QVariant())
        elif orientation == QtCore.Qt.Vertical:
            if role == QtCore.Qt.DisplayRole:
                try:
                    # return self.df.index.tolist()
                    return self._df.index.tolist()[section]
                except (IndexError,):
                    return QtCore.QVariant()
        return QtCore.QVariant()

    def setFont(self, section, font):
        self.bolds[section] = font
        self.headerDataChanged.emit(QtCore.Qt.Horizontal, 0, self.columnCount())

    def data(self, index, role=QtCore.Qt.DisplayRole):
        if role != QtCore.Qt.DisplayRole:
            return QtCore.QVariant()

        if not index.isValid():
            return QtCore.QVariant()

        return QtCore.QVariant(str(self._df.iloc[index.row(), index.column()]))

    def setData(self, index, value, role):
        row = self._df.index[index.row()]
        col = self._df.columns[index.column()]
        if hasattr(value, 'toPyObject'):
            # PyQt4 gets a QVariant
            value = value.toPyObject()
        else:
            # PySide gets an unicode
            dtype = self._df[col].dtype
            if dtype != object:
                value = None if value == '' else dtype.type(value)
        self._df.set_value(row, col, value)
        return True

    def rowCount(self, parent=QtCore.QModelIndex()):
        return len(self._df.index)

    def columnCount(self, parent=QtCore.QModelIndex()):
        return len(self._df.columns)

    def sort(self, column, order):
        colname = self._df.columns.tolist()[column]
        self.layoutAboutToBeChanged.emit()
        self._df.sort_values(colname, ascending= order == QtCore.Qt.AscendingOrder, inplace=True)
        self._df.reset_index(inplace=True, drop=True)
        self.layoutChanged.emit()


class CustomProxyModel(QtCore.QSortFilterProxyModel):
    def __init__(self, parent=None):
        super().__init__(parent)
        self._filters = dict()

    @property
    def filters(self):
        return self._filters

    def setFilter(self, expresion, column):
        if expresion:
            self.filters[column] = expresion
        elif column in self.filters:
            del self.filters[column]
        self.invalidateFilter()

    def filterAcceptsRow(self, source_row, source_parent):
        for column, expresion in self.filters.items():
            text = self.sourceModel().index(source_row, column, source_parent).data()
            regex = QtCore.QRegExp(
                expresion, QtCore.Qt.CaseInsensitive, QtCore.QRegExp.RegExp
            )
            if regex.indexIn(text) == -1:
                return False
        return True

class CheckBoxDelegate(QtWidgets.QItemDelegate):
    """
    A delegate that places a fully functioning QCheckBox cell of the column to which it's applied.
    """
    def __init__(self, parent):
        QtWidgets.QItemDelegate.__init__(self, parent)

    def createEditor(self, parent, option, index):
        """
        Important, otherwise an editor is created if the user clicks in this cell.
        """
        return None

    def paint(self, painter, option, index):
        """
        Paint a checkbox without the label.
        """
        self.drawCheck(painter, option, option.rect, QtCore.Qt.Unchecked if int(index.data()) == 0 else QtCore.Qt.Checked)

    def editorEvent(self, event, model, option, index):
        '''
        Change the data in the model and the state of the checkbox
        if the user presses the left mousebutton and this cell is editable. Otherwise do nothing.
        '''
        if not int(index.flags() & QtCore.Qt.ItemIsEditable) > 0:
            return False

        if event.type() == QtCore.QEvent.MouseButtonRelease and event.button() == QtCore.Qt.LeftButton:
            # Change the checkbox-state
            self.setModelData(None, model, index)
            return True

        return False

    def setModelData (self, editor, model, index):
        '''
        The user wanted to change the old state in the opposite.
        '''
        model.setData(index, 1 if int(index.data()) == 0 else 0, QtCore.Qt.EditRole)

class TableView(QtWidgets.QTableView):
    """
    A simple table to demonstrate the QComboBox delegate.
    """
    def __init__(self, *args, **kwargs):
        QtWidgets.QTableView.__init__(self, *args, **kwargs)
        self.setItemDelegateForColumn(0, CheckBoxDelegate(self))

class myWindow(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        super(myWindow, self).__init__(parent)
        self.centralwidget  = QtWidgets.QWidget(self)
        self.lineEdit       = QtWidgets.QLineEdit(self.centralwidget)
        self.view           = TableView(self)
        self.comboBox       = QtWidgets.QComboBox(self.centralwidget)
        self.label          = QtWidgets.QLabel(self.centralwidget)

        self.gridLayout = QtWidgets.QGridLayout(self.centralwidget)
        self.gridLayout.addWidget(self.lineEdit, 0, 1, 1, 1)
        self.gridLayout.addWidget(self.view, 1, 0, 1, 3)
        self.gridLayout.addWidget(self.comboBox, 0, 2, 1, 1)
        self.gridLayout.addWidget(self.label, 0, 0, 1, 1)

        self.setCentralWidget(self.centralwidget)
        self.label.setText("Regex Filter")

        self.load_sites()
        self.comboBox.addItems(["{0}".format(col) for col in self.model._df.columns])

        self.lineEdit.textChanged.connect(self.on_lineEdit_textChanged)
        self.comboBox.currentIndexChanged.connect(self.on_comboBox_currentIndexChanged)

        self.horizontalHeader = self.view.horizontalHeader()
        self.horizontalHeader.sectionClicked.connect(self.on_view_horizontalHeader_sectionClicked)


    def load_sites(self):
        df = pd.DataFrame({'site_codes': ['01', '02', '03', '04'],
                           'status': ['open', 'open', 'open', 'closed'],
                           'Location': ['east', 'north', 'south', 'east'],
                           'data_quality': ['poor', 'moderate', 'high', 'high']})

        self.model = PandasModel(df)
        self.proxy = CustomProxyModel(self)
        self.proxy.setSourceModel(self.model)
        self.view.setModel(self.proxy)
        self.view.resizeColumnsToContents()

        # delegate = CheckBoxDelegate(None)
        # self.view.setItemDelegateForColumn(0, delegate)



    @QtCore.pyqtSlot(int)
    def on_view_horizontalHeader_sectionClicked(self, logicalIndex):

        self.logicalIndex   = logicalIndex
        self.menuValues     = QtWidgets.QMenu(self)
        self.signalMapper   = QtCore.QSignalMapper(self)
        self.comboBox.blockSignals(True)
        self.comboBox.setCurrentIndex(self.logicalIndex)
        self.comboBox.blockSignals(True)

        valuesUnique = self.model._df.iloc[:, self.logicalIndex].unique()

        actionAll = QtWidgets.QAction("All", self)
        actionAll.triggered.connect(self.on_actionAll_triggered)
        self.menuValues.addAction(actionAll)
        self.menuValues.addSeparator()
        for actionNumber, actionName in enumerate(sorted(list(set(valuesUnique)))):
            action = QtWidgets.QAction(actionName, self)
            self.signalMapper.setMapping(action, actionNumber)
            action.triggered.connect(self.signalMapper.map)
            self.menuValues.addAction(action)
        self.signalMapper.mapped.connect(self.on_signalMapper_mapped)
        headerPos = self.view.mapToGlobal(self.horizontalHeader.pos())
        posY = headerPos.y() + self.horizontalHeader.height()
        posX = headerPos.x() + self.horizontalHeader.sectionPosition(self.logicalIndex)

        self.menuValues.exec_(QtCore.QPoint(posX, posY))

    @QtCore.pyqtSlot()
    def on_actionAll_triggered(self):
        filterColumn = self.logicalIndex
        self.proxy.setFilter("", filterColumn)
        font = QtGui.QFont()
        self.model.setFont(filterColumn, font)

    @QtCore.pyqtSlot(int)
    def on_signalMapper_mapped(self, i):
        stringAction = self.signalMapper.mapping(i).text()
        filterColumn = self.logicalIndex
        self.proxy.setFilter(stringAction, filterColumn)
        font = QtGui.QFont()
        font.setBold(True)
        self.model.setFont(filterColumn, font)

    @QtCore.pyqtSlot(str)
    def on_lineEdit_textChanged(self, text):
        self.proxy.setFilter(text, self.proxy.filterKeyColumn())

    @QtCore.pyqtSlot(int)
    def on_comboBox_currentIndexChanged(self, index):
        self.proxy.setFilterKeyColumn(index)



if __name__ == "__main__":
    import sys
    app  = QtWidgets.QApplication(sys.argv)
    main = myWindow()
    main.show()
    main.resize(2000, 800)
    sys.exit(app.exec_())

【问题讨论】:

    标签: python pyqt pyqt5 qsortfilterproxymodel


    【解决方案1】:

    CustomProxyModel 不是一个选项,因为它不允许您正确映射复选框,因为显示的数量和行会有所不同。一种可能的解决方案是实现一个代理(可能基于 QAbstractProxyModel 或 QIdentityProxyModel)来实现该功能,但在我的回答中,我不会提出该解决方案,因为它会使逻辑复杂化,而是我将在源模型中实现逻辑,所以我创建了继承自 PandasModel 的类,其中添加了复选框列。

    逻辑是重写data、setData、flags和headarData方法,使得大于checkbox列的列中的信息取自基类但减1。

    综合以上情况,解决方法如下:

    #!/usr/bin/env python
    # -*- coding:utf-8 -*-
    
    from PyQt5 import QtCore, QtGui, QtWidgets
    import pandas as pd
    
    
    class PandasModel(QtCore.QAbstractTableModel):
        def __init__(self, df=pd.DataFrame(), parent=None):
            QtCore.QAbstractTableModel.__init__(self, parent=parent)
            self._df = df.copy()
            self.bolds = dict()
    
        def toDataFrame(self):
            return self._df.copy()
    
        def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
            if orientation == QtCore.Qt.Horizontal:
                if role == QtCore.Qt.DisplayRole:
                    try:
                        return self._df.columns.tolist()[section]
                    except (IndexError,):
                        return QtCore.QVariant()
                elif role == QtCore.Qt.FontRole:
                    return self.bolds.get(section, QtCore.QVariant())
            elif orientation == QtCore.Qt.Vertical:
                if role == QtCore.Qt.DisplayRole:
                    try:
                        # return self.df.index.tolist()
                        return self._df.index.tolist()[section]
                    except (IndexError,):
                        return QtCore.QVariant()
            return QtCore.QVariant()
    
        def setFont(self, section, font):
            self.bolds[section] = font
            self.headerDataChanged.emit(QtCore.Qt.Horizontal, 0, self.columnCount())
    
        def data(self, index, role=QtCore.Qt.DisplayRole):
            if role != QtCore.Qt.DisplayRole:
                return QtCore.QVariant()
    
            if not index.isValid():
                return QtCore.QVariant()
    
            return QtCore.QVariant(str(self._df.iloc[index.row(), index.column()]))
    
        def setData(self, index, value, role):
            row = self._df.index[index.row()]
            col = self._df.columns[index.column()]
            if hasattr(value, "toPyObject"):
                # PyQt4 gets a QVariant
                value = value.toPyObject()
            else:
                # PySide gets an unicode
                dtype = self._df[col].dtype
                if dtype != object:
                    value = None if value == "" else dtype.type(value)
            self._df.set_value(row, col, value)
            return True
    
        def rowCount(self, parent=QtCore.QModelIndex()):
            return len(self._df.index)
    
        def columnCount(self, parent=QtCore.QModelIndex()):
            return len(self._df.columns)
    
        def sort(self, column, order):
            colname = self._df.columns.tolist()[column]
            self.layoutAboutToBeChanged.emit()
            self._df.sort_values(
                colname, ascending=order == QtCore.Qt.AscendingOrder, inplace=True
            )
            self._df.reset_index(inplace=True, drop=True)
            self.layoutChanged.emit()
    
    
    class CheckablePandasModel(PandasModel):
        def __init__(self, df=pd.DataFrame(), parent=None):
            super().__init__(df, parent)
            self.checkable_values = set()
            self._checkable_column = -1
    
        @property
        def checkable_column(self):
            return self._checkable_column
    
        @checkable_column.setter
        def checkable_column(self, column):
            if self.checkable_column == column:
                return
            last_column = self.checkable_column
            self._checkable_column = column
    
            if last_column == -1:
                self.beginInsertColumns(
                    QtCore.QModelIndex(), self.checkable_column, self.checkable_column
                )
                self.endInsertColumns()
    
            elif self.checkable_column == -1:
                self.beginRemoveColumns(QtCore.QModelIndex(), last_column, last_column)
                self.endRemoveColumns()
            for c in (last_column, column):
                if c > 0:
                    self.dataChanged.emit(
                        self.index(0, c), self.index(self.columnCount() - 1, c)
                    )
    
        def columnCount(self, parent=QtCore.QModelIndex()):
            return super().columnCount(parent) + (1 if self.checkable_column != -1 else 0)
    
        def data(self, index, role=QtCore.Qt.DisplayRole):
            if self.checkable_column != -1:
                row, col = index.row(), index.column()
                if col == self.checkable_column:
                    if role == QtCore.Qt.CheckStateRole:
                        return (
                            QtCore.Qt.Checked
                            if row in self.checkable_values
                            else QtCore.Qt.Unchecked
                        )
                    return QtCore.QVariant()
                if col > self.checkable_column:
                    index = index.sibling(index.row(), col - 1)
            return super().data(index, role)
    
        def setData(self, index, value, role):
            if self.checkable_column != -1:
                row, col = index.row(), index.column()
                if col == self.checkable_column:
                    if role == QtCore.Qt.CheckStateRole:
                        if row in self.checkable_values:
                            self.checkable_values.discard(row)
                        else:
                            self.checkable_values.add(row)
                        self.dataChanged.emit(index, index, (role,))
                        return True
                    return False
                if col > self.checkable_column:
                    index = index.sibling(index.row(), col - 1)
            return super().setData(index, value, role)
    
        def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
            if self.checkable_column != -1:
                if section == self.checkable_column and orientation == QtCore.Qt.Horizontal:
                    return QtCore.QVariant()
                if section > self.checkable_column and orientation == QtCore.Qt.Horizontal:
                    section -= 1
            return super().headerData(section, orientation, role)
    
        def flags(self, index):
            if self.checkable_column != -1:
                col = index.column()
                if col == self.checkable_column:
                    return QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled
                if col > self.checkable_column:
                    index = index.sibling(index.row(), col - 1)
            return super().flags(index)
    
    
    class CustomProxyModel(QtCore.QSortFilterProxyModel):
        def __init__(self, parent=None):
            super().__init__(parent)
            self._filters = dict()
    
        @property
        def filters(self):
            return self._filters
    
        def setFilter(self, expresion, column):
            if expresion:
                self.filters[column] = expresion
            elif column in self.filters:
                del self.filters[column]
            self.invalidateFilter()
    
        def filterAcceptsRow(self, source_row, source_parent):
            for column, expresion in self.filters.items():
                text = self.sourceModel().index(source_row, column, source_parent).data()
                regex = QtCore.QRegExp(
                    expresion, QtCore.Qt.CaseInsensitive, QtCore.QRegExp.RegExp
                )
                if regex.indexIn(text) == -1:
                    return False
            return True
    
    
    class myWindow(QtWidgets.QMainWindow):
        def __init__(self, parent=None):
            super(myWindow, self).__init__(parent)
            self.centralwidget = QtWidgets.QWidget()
            self.lineEdit = QtWidgets.QLineEdit()
            self.view = QtWidgets.QTableView()
            self.comboBox = QtWidgets.QComboBox()
            self.label = QtWidgets.QLabel()
    
            self.gridLayout = QtWidgets.QGridLayout(self.centralwidget)
            self.gridLayout.addWidget(self.lineEdit, 0, 1, 1, 1)
            self.gridLayout.addWidget(self.view, 1, 0, 1, 3)
            self.gridLayout.addWidget(self.comboBox, 0, 2, 1, 1)
            self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
    
            self.setCentralWidget(self.centralwidget)
            self.label.setText("Regex Filter")
    
            self.load_sites()
            self.comboBox.addItems(["{0}".format(col) for col in self.model._df.columns])
    
            self.lineEdit.textChanged.connect(self.on_lineEdit_textChanged)
    
            self.horizontalHeader = self.view.horizontalHeader()
            self.horizontalHeader.sectionClicked.connect(
                self.on_view_horizontalHeader_sectionClicked
            )
    
        def load_sites(self):
            df = pd.DataFrame(
                {
                    "site_codes": ["01", "02", "03", "04"],
                    "status": ["open", "open", "open", "closed"],
                    "Location": ["east", "north", "south", "east"],
                    "data_quality": ["poor", "moderate", "high", "high"],
                }
            )
    
            self.model = CheckablePandasModel(df)
            self.model.checkable_column = 0
            self.proxy = CustomProxyModel(self)
            self.proxy.setSourceModel(self.model)
            self.view.setModel(self.proxy)
            self.view.resizeColumnsToContents()
    
        @QtCore.pyqtSlot(int)
        def on_view_horizontalHeader_sectionClicked(self, logicalIndex):
            if logicalIndex == self.model.checkable_column:
                return
    
            self.menuValues = QtWidgets.QMenu(self)
            self.comboBox.blockSignals(True)
            self.comboBox.setCurrentIndex(
                logicalIndex - 1
                if logicalIndex > self.model.checkable_column
                else logicalIndex
            )
            self.comboBox.blockSignals(True)
    
            valuesUnique = set(
                self.proxy.index(i, logicalIndex).data()
                for i in range(self.proxy.rowCount())
            )
    
            actionAll = QtWidgets.QAction("All", self)
            self.menuValues.addAction(actionAll)
            self.menuValues.addSeparator()
            for i, name in enumerate(valuesUnique):
                action = QtWidgets.QAction(name, self)
                action.setData(i)
                self.menuValues.addAction(action)
    
            headerPos = self.view.mapToGlobal(self.horizontalHeader.pos())
            pos = headerPos + QtCore.QPoint(
                self.horizontalHeader.sectionPosition(logicalIndex),
                self.horizontalHeader.height(),
            )
            action = self.menuValues.exec_(pos)
            if action is not None:
                font = QtGui.QFont()
                if action.data() is None:  # all
                    self.proxy.setFilter("", logicalIndex)
                else:
                    font.setBold(True)
                    self.proxy.setFilter(action.text(), logicalIndex)
                self.model.setFont(logicalIndex - 1, font)
    
        @QtCore.pyqtSlot(str)
        def on_lineEdit_textChanged(self, text):
            self.proxy.setFilter(text, self.comboBox.currentIndex() + 1)
    
    
    if __name__ == "__main__":
        import sys
    
        app = QtWidgets.QApplication(sys.argv)
        main = myWindow()
        main.show()
        main.resize(2000, 800)
        sys.exit(app.exec_())
    

    【讨论】:

    • 感谢您的大力帮助。有三个问题需要解决:(1)当你过滤一列时,下一列得到粗体(我猜你需要将self.bolds[section]更改为self.bolds[section-1]); (2) 正则表达式过滤器不起作用,似乎只连接到复选框列; (3) 列之间的AND 操作不适用于提供的唯一列表,例如在您的图中data_quality 被过滤但如果您单击Location 列,您将看到所有唯一值。我希望看到所有具有High 数据质量的位置。
    • @BehzadJamali 刚刚看到你的评论,我会分析你说的内容
    • 第三个问题依然存在。重现问题:单击最后一列并将data_quality 过滤到high 值。现在,当您单击 location 列时,您应该只会看到 southeast,但您会看到所有现有的唯一值。
    • @BehzadJamali 嗯,你不觉得奇怪的是你说我的代码有一个从未工作过的问题吗?仅在这一次,我将添加您未在问题中任何地方指出的功能,并且这不是您原始代码行为的一部分,在其他情况下您将不得不创建一个新问题。
    • @BehzadJamali 我已经添加了这个功能,请不要这样做,要求你从未指出的东西会很不舒服。
    猜你喜欢
    • 2017-10-15
    • 1970-01-01
    • 2019-07-04
    • 1970-01-01
    • 2021-11-13
    • 1970-01-01
    • 1970-01-01
    • 2021-02-18
    • 2020-10-17
    相关资源
    最近更新 更多