добавление флажка-столбца в пользовательский QSortFilterProxyModel - PullRequest
1 голос
/ 27 февраля 2020

У меня есть код для представления pandas кадра данных в таблице, и он обеспечивает функции фильтрации, как показано ниже:

enter image description here

I хотел бы вставить столбец флажка перед первым столбцом, чтобы пользователь мог выбирать строки. В коде используется модель PandasModel(QtCore.QAbstractTableModel) для чтения данных pandas и CustomProxyModel(QtCore.QSortFilterProxyModel) для добавления возможности фильтрации для QTableView.

Мой вопрос: нужно ли мне добавить дополнительный столбец флажка, если добавить это в PandasModel или в CustomProxyModel или где-то еще? В качестве альтернативного решения я нашел решение SO , предлагающее использовать пользовательский 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_())

1 Ответ

3 голосов
/ 27 февраля 2020

CustomProxyModel не является опцией, потому что она не позволяет правильно сопоставлять флажки, так как отображаемое число и строка различаются. Одним из возможных решений является реализация прокси (возможно, основанного на QAbstractProxyModel или QIdentityProxyModel), реализующего эту функциональность, но в своем ответе я не буду предлагать это решение, поскольку оно может усложнить логи c, вместо этого я буду реализовывать логи c в исходная модель, поэтому я создал класс, унаследованный от PandasModel, в который добавлен столбец для флажков.

В logi c переопределены методы data, setData, flags и headarData, так что информация в столбце больше, чем столбец флажка взят из базового класса, но уменьшен на 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_())

enter image description here

...