PyQt5 QTableViewにpandasを表示するためのQAbstractItemModelを作成する

PyQt5でQTableViewにpandasのDataFrameを表示するためのQAbstractItemModelを作ってみました。



表示するデータの取得

QAbstractItemModelのデータを返す関数はdef data(self, index, role=QtCore.Qt.DisplayRole)です。この関数はindexで取得するデータの位置を指定して、その位置のデータを返します。そこでindexの行、列番号をindex.row()とindex.column()で取得して、それらの番号でDataFrameの特定の場所のデータをDataFrame.iat[row, column]で取得して、data関数の返り値とします。

def data(self, index, role=QtCore.Qt.DisplayRole):
    if not index.isValid():
        return QtCore.QVariant()
    if role == QtCore.Qt.EditRole or role == QtCore.Qt.DisplayRole:
        return self.df.iat[index.row(), index.column()]
    return QtCore.QVariant()

データのセット

QAbstractItemModelのデータを返す関数はdef setData(self, index, value, role=QtCore.Qt.EditRole)です。この関数はindexで取得するデータの位置を指定して、その位置にデータvalueをセットします。データのセットにはデータの表示と同じDataFrame.iatを使用します。

def setData(self, index, value, role=QtCore.Qt.EditRole):
    if role == QtCore.Qt.EditRole:
        self.df.iat[index.row(), index.column()] = value
        return True
    return False

ヘッダーの表示

ヘッダーの方向が縦か横のどちらかを示すorientationによってヘッドーの表示内容を変えます。縦の場合はDataFrameのindexを表示して、横の場合はDataFrameのcolumnsを表示します。

def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
    if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole:
        return self.df.columns[section]
    if orientation == QtCore.Qt.Vertical and role == QtCore.Qt.DisplayRole:
        return self.df.index[section]

表のサイズ

表の縦横のサイズはそれぞれ、def columnCount(self, parent=QtCore.QModelIndex())とdef rowCount(self, parent=QtCore.QModelIndex())という関数です。これらはDataFrame.shapeから取得した値をそのまま返す関数としました。

def columnCount(self, parent=QtCore.QModelIndex()):
    return self.df.shape[1]

def rowCount(self, parent=QtCore.QModelIndex()):
    return self.df.shape[0]

列の挿入

列の挿入にはdef insertColumns(self, column, count, parent=QtCore.QModelIndex())を使用します。挿入を行う前にbeginInsertColumnsを実行して、挿入完了後にendInsertColumnsを実行する必要があります。挿入完了前にendInsertColumnsを実行したりするとエラーがが発生します。挿入位置はcolumnからcountの個数挿入します。そこでDataFrame.ilocで列を指定してcolumnの位置でDataFrameを左側と右側にスライスします。その中間に挿入されるDataFrameを、indexを既存のデータフレームから流用して、columnsを挿入する個数分の数として作成します。そして、pandas.concatでスライスした左側、作成した中間、スライスした右側を連結して新しいDataFrameを作成することで列の挿入を実装します。

def insertColumns(self, column, count, parent=QtCore.QModelIndex()):
    self.beginInsertColumns(parent, column, column + count - 1)

    columns = [ str(self.columnCount()+i+1) for i in range(count) ]
    left = self.df.iloc[:, 0:column]
    mid = pd.DataFrame(index=self.df.index, columns=columns)
    right = self.df.iloc[:, column+count-1:self.columnCount()]
    self.df = pd.concat( [left, mid, right], axis=1 )
    
    self.endInsertColumns()

行の挿入

行の挿入も列の挿入と同じ要領です。挿入位置rowでDataFrameをスライスして、新たに作成したDataFrameとそれらを連結して新しいDataFrameを作成することで行の挿入を実装します。

def insertRows(self, row, count, parent=QtCore.QModelIndex()):
    self.beginInsertRows(parent, row, row + count - 1)

    indexes = [ str(self.rowCount()+i) for i in range(count) ]
    left = self.df[0:row]
    mid = pd.DataFrame(index=indexes, columns=self.df.columns)
    right = self.df[row+count-1:self.rowCount()]
    self.df = pd.concat([left, mid, right])

    self.endInsertRows()

列の削除

列の削除は、列の挿入の処理から中間に挿入するDataFrameを作成する処理を除いた処理です。

def removeColumns(self, column, count, parent=QtCore.QModelIndex()):
    self.beginRemoveColumns(parent, column, column + count - 1)

    left = self.df.iloc[:, 0:column]
    right = self.df.iloc[:, column+count:self.columnCount()]
    self.df = pd.concat( [left, right], axis=1 )

    self.endRemoveColumns()

行の削除

行の削除も列の削除と同様の処理です。

def removeRows(self, row, count, parent=QtCore.QModelIndex()):
    self.beginRemoveRows(parent, row, row + count - 1)
    
    left = self.df.iloc[0:row]
    right = self.df.iloc[row+count:self.rowCount()]
    self.df = pd.concat( [left, right], axis=0 )

    self.endRemoveRows()



ソースコード

import pandas as pd
from PyQt5 import QtWidgets, QtCore

class PandasModel(QtCore.QAbstractItemModel):
    def __init__(self, parent=None, data_frame=None):
        super(PandasModel, self).__init__(parent)
        self.df = data_frame

    def columnCount(self, parent=QtCore.QModelIndex()):
        return self.df.shape[1]

    def data(self, index, role=QtCore.Qt.DisplayRole):
        if not index.isValid():
            return QtCore.QVariant()
        if role == QtCore.Qt.EditRole or role == QtCore.Qt.DisplayRole:
            return self.df.iat[index.row(), index.column()]
        return QtCore.QVariant()

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

    def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
        if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole:
            return self.df.columns[section]
        if orientation == QtCore.Qt.Vertical and role == QtCore.Qt.DisplayRole:
            return self.df.index[section]

    def index(self, row, column, parent=QtCore.QModelIndex()):
        if not self.hasIndex(row, column, parent):
            return QtCore.QModelIndex()
        return self.createIndex(row, column, QtCore.QModelIndex())

    def insertColumns(self, column, count, parent=QtCore.QModelIndex()):
        self.beginInsertColumns(parent, column, column + count - 1)

        columns = [ str(self.columnCount()+i+1) for i in range(count) ]
        left = self.df.iloc[:, 0:column]
        mid = pd.DataFrame(index=self.df.index, columns=columns)
        right = self.df.iloc[:, column+count-1:self.columnCount()]
        self.df = pd.concat( [left, mid, right], axis=1 )
        
        self.endInsertColumns()

    def insertRows(self, row, count, parent=QtCore.QModelIndex()):
        self.beginInsertRows(parent, row, row + count - 1)

        indexes = [ str(self.rowCount()+i) for i in range(count) ]
        left = self.df[0:row]
        mid = pd.DataFrame(index=indexes, columns=self.df.columns)
        right = self.df[row+count-1:self.rowCount()]
        self.df = pd.concat([left, mid, right])

        self.endInsertRows()

    def removeColumns(self, column, count, parent=QtCore.QModelIndex()):
        self.beginRemoveColumns(parent, column, column + count - 1)

        left = self.df.iloc[:, 0:column]
        right = self.df.iloc[:, column+count:self.columnCount()]
        self.df = pd.concat( [left, right], axis=1 )

        self.endRemoveColumns()

    def removeRows(self, row, count, parent=QtCore.QModelIndex()):
        self.beginRemoveRows(parent, row, row + count - 1)
        
        left = self.df.iloc[0:row]
        right = self.df.iloc[row+count:self.rowCount()]
        self.df = pd.concat( [left, right], axis=0 )

        self.endRemoveRows()
        
    def rowCount(self, parent=QtCore.QModelIndex()):
        return self.df.shape[0]

    def setData(self, index, value, role=QtCore.Qt.EditRole):
        if role == QtCore.Qt.EditRole:
            self.df.iat[index.row(), index.column()] = value
            return True
        return False

class Delegate(QtWidgets.QStyledItemDelegate):
    def __init__(self, parent=None, setModelDataEvent=None):
        super(Delegate, self).__init__(parent)
        self.setModelDataEvent = setModelDataEvent
 
    def createEditor(self, parent, option, index):
        index.model().data(index, QtCore.Qt.DisplayRole)
        return QtWidgets.QLineEdit(parent)
 
    def setEditorData(self, editor, index):
        value = index.model().data(index, QtCore.Qt.DisplayRole)
        editor.setText(str(value))
        
    def setModelData(self, editor, model, index):
        model.setData(index, editor.text())
        if not self.setModelDataEvent is None:
            self.setModelDataEvent()

def main():
    
    def insert_row():
        if len(tableView.selectedIndexes()) == 0:
            model.insertRows(model.rowCount(), 1)
        else:
            rows = list( set([ index.row() for index in tableView.selectedIndexes() ]) )
            for row in rows[::-1]:
                model.insertRows(row, 1)

    def delete_row():
        rows = list( set([ index.row() for index in tableView.selectedIndexes() ]) )
        for row in rows[::-1]:
            model.removeRows(row, 1)

    def insert_column():
        if len(tableView.selectedIndexes()) == 0:
            model.insertColumns(model.columnCount(), 1)
        else:
            columns = list( set([ index.column() for index in tableView.selectedIndexes() ]) )
            for column in columns[::-1]:
                model.insertColumns(column, 1)

    def delete_column():
        columns = list( set([ index.column() for index in tableView.selectedIndexes() ]) )
        for column in columns[::-1]:
            model.removeColumns(column, 1)

    def exec_context_menu(point):
        menu = QtWidgets.QMenu(tableView)
        menu.addAction('Insert row', insert_row)
        menu.addAction('Delete row', delete_row)
        menu.addAction('Insert column', insert_column)
        menu.addAction('Delete column', delete_column)
        menu.exec( tableView.focusWidget().mapToGlobal(point) )
    
    import sys
    app = QtWidgets.QApplication(sys.argv)

    window = QtWidgets.QMainWindow()
    window.resize(600, 600)

    centralwidget = QtWidgets.QWidget(window)
    verticalLayout = QtWidgets.QVBoxLayout(centralwidget)

    df = pd.DataFrame(
        {
            '1': ['A1', 'A2', 'A3'],
            '2': ['B1', 'B2', 'B3'],
            '3': ['C1', 'C2', 'C3']
        }, index=['0', '1', '2']
    )
    
    tableView = QtWidgets.QTableView(centralwidget)
    model = PandasModel(tableView, df)

    tableView.setModel(model)
    tableView.setItemDelegate(Delegate())
    tableView.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
    tableView.customContextMenuRequested.connect(exec_context_menu)

    verticalLayout.addWidget(tableView)
    window.setCentralWidget(centralwidget)

    window.show()
    app.exec()

if __name__ == '__main__':
    main()

コメント

タイトルとURLをコピーしました