pythonpyqtpyqt5qtableviewqitemdelegate

Sorting QTableView with combo delegates removes delgetes except after double clicking


I have QTableView which views a pandas table. The first row has QComboBox delegates. When I sort the table by a column the delegates disappear.

A working example of my code is below.

import sys
import pandas as pd
import numpy as np

from PyQt5.QtCore import (QAbstractTableModel, Qt, pyqtProperty, pyqtSlot,
                          QVariant, QModelIndex)
from PyQt5.QtWidgets import (QItemDelegate, QComboBox, QMainWindow, QTableView,
                             QApplication)


class DataFrameModel(QAbstractTableModel):
    DtypeRole = Qt.UserRole + 1000
    ValueRole = Qt.UserRole + 1001
    ActiveRole = Qt.UserRole + 1

    def __init__(self, df=pd.DataFrame(), parent=None):
        super(DataFrameModel, self).__init__(parent)
        self._dataframe = df

    def setDataFrame(self, dataframe):
        self.beginResetModel()
        self._dataframe = dataframe.copy()
        self.endResetModel()

    def dataFrame(self):
        return self._dataframe

    dataFrame = pyqtProperty(pd.DataFrame, fget=dataFrame, fset=setDataFrame)

    @pyqtSlot(int, Qt.Orientation, result=str)
    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if role != Qt.DisplayRole:
            return QVariant()

        if orientation == Qt.Horizontal:
            try:
                return self._dataframe.columns.tolist()[section]
            except (IndexError, ):
                return QVariant()
        elif orientation == Qt.Vertical:
            try:
                if section in [0]:
                    pass
                else:
                    return self._dataframe.index.tolist()[section - 1]
            except (IndexError, ):
                return QVariant()

    def rowCount(self, parent=QModelIndex()):
        if parent.isValid():
            return 0
        return len(self._dataframe.index)

    def columnCount(self, parent=QModelIndex()):

        if parent.isValid():
            return 0
        return self._dataframe.columns.size

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid() or not (0 <= index.row() < self.rowCount()
                                       and 0 <= index.column() <
                                       self.columnCount()):
            return QVariant()
        row = self._dataframe.index[index.row()]
        col = self._dataframe.columns[index.column()]
        dt = self._dataframe[col].dtype

        val = self._dataframe.iloc[row][col]

        if role == Qt.DisplayRole:
            return str(val)
        elif role == DataFrameModel.ValueRole:
            return val
        if role == DataFrameModel.DtypeRole:
            return dt
        return QVariant()

    def roleNames(self):
        roles = {
            Qt.DisplayRole: b'display',
            DataFrameModel.DtypeRole: b'dtype',
            DataFrameModel.ValueRole: b'value'
        }
        return roles

    def setData(self, index, value, role):
        col = index.column()
        row = index.row()
        if index.row() == 0:
            if isinstance(value, QVariant):
                value = value.value()
            if hasattr(value, 'toPyObject'):
                value = value.toPyObject()
            self._dataframe.iloc[row, col] = value
            self.dataChanged.emit(index, index, (Qt.DisplayRole,))
        else:
            try:
                value = eval(value)
                if not isinstance(
                        value,
                        self._dataframe.applymap(type).iloc[row, col]):
                    value = self._dataframe.iloc[row, col]
            except Exception as e:
                value = self._dataframe.iloc[row, col]
            self._dataframe.iloc[row, col] = value
            self.dataChanged.emit(index, index, (Qt.DisplayRole,))
        return True

    def flags(self, index):
        return Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsSelectable

    def sort(self, column, order):
        self.layoutAboutToBeChanged.emit()
        col_name = self._dataframe.columns.tolist()[column]
        sheet1 = self._dataframe.iloc[:1, :]
        sheet2 = self._dataframe.iloc[1:, :].sort_values(
            col_name, ascending=order == Qt.AscendingOrder, inplace=False)

        sheet2.reset_index(drop=True, inplace=True)
        sheet3 = pd.concat([sheet1, sheet2], ignore_index=True)
        self.setDataFrame(sheet3)
        self.layoutChanged.emit()


class ComboBoxDelegate(QItemDelegate):
    def __init__(self, owner, choices):
        super().__init__(owner)
        self.items = choices

    def createEditor(self, parent, option, index):
        editor = QComboBox(parent)
        editor.addItems(self.items)
        editor.currentIndexChanged.connect(self.currentIndexChanged)
        return editor

    def paint(self, painter, option, index):
        if isinstance(self.parent(), QItemDelegate):
            self.parent().openPersistentEditor(0, index)
        QItemDelegate.paint(self, painter, option, index)

    def setModelData(self, editor, model, index):
        value = editor.currentText()
        model.setData(index, value, Qt.EditRole)

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)

    @pyqtSlot()
    def currentIndexChanged(self):
        self.commitData.emit(self.sender())


class MainWindow(QMainWindow):
    def __init__(self, pandas_sheet):
        super().__init__()
        self.pandas_sheet = pandas_sheet

        for i in range(1):
            self.pandas_sheet.loc[-1] = [''] * len(self.pandas_sheet.columns.values)
            self.pandas_sheet.index = self.pandas_sheet.index + 1
            self.pandas_sheet = self.pandas_sheet.sort_index()
        self.table = QTableView()
        self.setCentralWidget(self.table)
        delegate = ComboBoxDelegate(self.table,
                                    ['m1', 'm2', 'm3'])
        model = DataFrameModel(self.pandas_sheet, self)
        self.table.setModel(model)
        self.table.setSortingEnabled(True)
        self.table.setItemDelegateForRow(0, delegate)
        for i in range(model.columnCount()):
            ix = model.index(0, i)
            self.table.openPersistentEditor(ix)

        self.table.resizeColumnsToContents()
        self.table.resizeRowsToContents()


if __name__ == '__main__':
    df = pd.DataFrame({'a': ['col0'] * 5,
                       'b': np.arange(5),
                       'c': np.random.rand(5)})
    app = QApplication(sys.argv)
    window = MainWindow(df)
    window.show()
    sys.exit(app.exec_())

The image below shows the table before sorting.

enter image description here

The image below after sorting the table by one of the columns.

enter image description here

I would like to have the style for the first row the same before and after sorting by any column. Is this possible?


Solution

  • By default the editors are not displayed unless the user interacts with the items using the events indicated in the flags assigned to editTriggers or if you force them to open them using openPersistentEditor().

    Considering the last option you can automate the task shown but for this the view must be accessible from the delegate's paint method since it is always called by what a solution is to pass it as a parent (it seems that you are trying to implement) and use the openPersistentEditor() if it is the view, in your case there is an error since the parent is not a QItemDelegate but inherits from QAbstractItemView, in addition you must pass the QModelIndex.

    Considering the above, the solution is:

    def paint(self, painter, option, index):
        if isinstance(self.parent(), QAbstractItemView):
            self.parent().openPersistentEditor(index)

    So with the above every time the delegates are repainted (for example after a sorting) openPersistentEditor() will be called making the editors visible.

    Update:

    The editor must save the information in the QModelIndex roles through setModelData and retrieve them using setEditorData, in your case you do not implement the second one so the editor will not obtain the information when the editor is created again. In addition setModelData saves the information in Qt::EditRole but in your model it does not handle that role so you must use Qt::DisplayRole.

    Considering the above, the solution is:

    class ComboBoxDelegate(QItemDelegate):
        def __init__(self, parent, choices):
            super().__init__(parent)
            self.items = choices
    
        def createEditor(self, parent, option, index):
            editor = QComboBox(parent)
            editor.addItems(self.items)
            editor.currentIndexChanged.connect(self.currentIndexChanged)
            return editor
    
        def paint(self, painter, option, index):
            if isinstance(self.parent(), QAbstractItemView):
                self.parent().openPersistentEditor(index)
    
        def setModelData(self, editor, model, index):
            value = editor.currentText()
            model.setData(index, value, Qt.DisplayRole)
    
        def setEditorData(self, editor, index):
            text = index.data(Qt.DisplayRole) or ""
            editor.setCurrentText(text)
    
        def updateEditorGeometry(self, editor, option, index):
            editor.setGeometry(option.rect)
    
        @pyqtSlot()
        def currentIndexChanged(self):
            self.commitData.emit(self.sender())