PyQt

【PyQt】QTableViewとQAbstractTableModelの使用方法 ~簡易CSVエディタの作成~

【PyQt】QTableViewとQAbstractTableModelの使用方法 _簡易CSVエディタの作成_

PythonのGUIツールキットであるPyQtで提供されているQTableViewQAbstructTableModelの使用方法について紹介します。

QTableViewとQAbstructTableModel

PyQtでは、GUIアプリケーションを開発するときの開発パターンとしてModel-Viewパターンでの開発の枠組みが提供されています。Model-View開発の概要については「Model-Viewを使ったアプリケーション開発」でまとめていますので興味があれば参考にしてください。

PyQtでは、テーブル用にQTableViewというビュークラスとQAbstructTableModelというモデルの抽象クラスが用意されています。QAbstructTableModelはその名の通り、抽象クラスのため使用するためには自分で各種メソッドの実装が必要になります。

本記事では、QTableViewとQAbstructTableModelの使用方法を簡易的なCSVエディタを作成することで整理をしてみたいと思います。

※QTableViewとQAbstructTableModelの概要を整理する目的で作成した簡易的なものであるため細部処理の考慮が行き届いていない部分がある点はご容赦ください。

QTableView, QAbstructTableModelを用いた簡易CSVエディタ開発

簡易CSVエディタの概要

以降で説明する簡易CSVエディタのイメージは以下のような形になります。

QTableView QAbstructTableModel 簡易CSVエディタ

CSVファイルを開いて保存ができるようになっており、編集機能として行の追加・削除、列の追加・削除の機能を持っています。また、セルは編集できるようになっていて、セルに対して上下左右に行や列の追加できます(セルが選択されていない時は上下左右の端に行や列が追加されます)

以降で、上記画面をQTableViewとQAbstructTableModelを使って実装してみます。

簡易CSVエディタの実装

以降で紹介するプログラムの概要を書いてみると以下のような構成になっています。

QTableView QAbstructTableModel 簡易CSVエディタ

MainWindowの中にビュー用のViewクラスとCSVファイルを扱うためのCsvTabModelクラスのインスタンスを持っています。

Viewクラスの中にはQTableViewを含む画面構成となっていて、CsvTableModelはQAbstructTableModelを継承して実装します。CsvTableModel内の点線で囲っている部分は、QAbstructTableModelのメソッドでCsvTableModelでオーバーライドして実装します。

MainWindowからは[ファイル]メニューや[編集]メニューのアクションに対するメソッドを介してビューやモデルを使用します。

実装例

では、上記概要図で説明したプログラムを実際に以下に示します。詳細は以降で部分ごとに説明していきます。

以降のプログラムを実行する際には色々なところにブレークポイントを置いてデバッグで動作を確認してみることをおすすめします。画面でどの操作をすると、どのメソッドに入るかが少しずつ分かってくるかと思います。

import csv
import sys

from PyQt6 import QtCore as qtc
from PyQt6 import QtGui as qtg
from PyQt6 import QtWidgets as qtw


class CsvTableModel(qtc.QAbstractTableModel):
    """CSVデータ用テーブルモデル"""

    # エラーシグナル
    error = qtc.pyqtSignal(str)

    def __init__(self):
        super().__init__()
        self.filepath = None
        self._header = None
        self._data = None

    def csv_open(self, filepath):
        """CSVファイルを読み込みデータをセットする

        Args:
            filepath: CSVファイルパス
        """
        if filepath:
            self.filepath = filepath
            error = ""
            try:
                with open(self.filepath, "r", encoding="utf-8") as f:
                    csv_reader = csv.reader(f)
                    self._header = next(csv_reader)
                    self._data = list(csv_reader)
            except Exception as ex:
                error = f"ファイルを開けませんでした。: {ex}"

            if error:
                self.error.emit(error)
                return False

            return True

    def csv_save(self, filename=None):
        """CSVファイルを保存する"""
        if filename:
            target_file = filename
        else:
            target_file = self.filepath

        if target_file:
            error = ""
            try:
                with open(target_file, "w", newline="", encoding="utf-8") as f:
                    writer = csv.writer(f)
                    writer.writerow(self._header)
                    writer.writerows(self._data)
            except Exception as ex:
                error = f"ファイルを開けませんでした。: {ex}"

            if error:
                self.error.emit(error)
                return False

            # 保存に成功したら現在のファイルパスを更新
            self.filepath = target_file
            return True

    # ===== QAbstractTableModelを使う際に最低限必要なメソッド
    # rowCount, column, data
    def rowCount(self, parent):
        """テーブルの行数を返却する"""
        return len(self._data)

    def columnCount(self, parent):
        """テーブルの列数を返却する"""
        return len(self._header)

    def data(self, index, role):
        """index, roleで指定されるセルのデータを返却する"""
        if role in (
            qtc.Qt.ItemDataRole.DisplayRole,
            qtc.Qt.ItemDataRole.EditRole,
        ):
            return self._data[index.row()][index.column()]

    # ===== データのソート関連メソッド
    # headerData, sort
    def headerData(self, section, orientation, role):
        """ヘッダーデータを返却する"""
        if (
            orientation == qtc.Qt.Orientation.Horizontal
            and role == qtc.Qt.ItemDataRole.DisplayRole
        ):
            return self._header[section]
        else:
            return super().headerData(section, orientation, role)

    def sort(self, column, order):
        # ソートを実行する前に必要
        self.layoutAboutToBeChanged.emit()
        # データをソートする
        self._data.sort(key=lambda x: x[column])
        if order == qtc.Qt.SortOrder.DescendingOrder:
            self._data.reverse()
        # ソート後に必要
        self.layoutChanged.emit()

    # ===== 書き込み対応のためのメソッド
    # flags, setData
    def flags(self, index):
        """フラグの設定"""
        return super().flags(index) | qtc.Qt.ItemFlag.ItemIsEditable

    def setData(self, index, value, role):
        """データを設定する"""
        if index.isValid() and role == qtc.Qt.ItemDataRole.EditRole:
            # valueをindexに該当するセルに設定する
            self._data[index.row()][index.column()] = value
            self.dataChanged.emit(index, index, [role])
            return True
        else:
            return False

    # ===== 行追加/行削除用メソッド
    # insertRows, removeRows
    def insertRows(self, row, count, parent):
        """行追加"""
        self.beginInsertRows(parent or qtc.QModelIndex(), row, row + count - 1)
        new_row = [""] * len(self._header)
        for _ in range(count):
            self._data.insert(row, new_row)
        self.endInsertRows()

    def removeRows(self, row, count, parent):
        """行削除"""
        self.beginRemoveRows(parent or qtc.QModelIndex(), row, row + count - 1)
        for _ in range(count):
            del self._data[row]
        self.endRemoveRows()

    # ===== 列追加/列削除用メソッド
    # insertColumns, removeColumns
    def insertColumns(self, column, count, parent, col_name=""):
        """列追加"""

        if col_name:
            self.beginInsertColumns(
                parent or qtc.QModelIndex(), column, column + count - 1
            )
            new_column = [""] * count
            new_header = [col_name] * count
            self._header[column : column + count - 1] = new_header
            for i, _ in enumerate(self._data):
                self._data[i][column : column + count - 1] = new_column
            self.endInsertColumns()

    def removeColumns(self, column, count, parent):
        """列削除"""
        self.beginRemoveColumns(parent or qtc.QModelIndex(), column, column + count - 1)
        del self._header[column : column + count]
        for i, _ in enumerate(self._data):
            del self._data[i][column : column + count]
        self.endRemoveColumns()


class View(qtw.QWidget):
    """MainView"""

    def __init__(self):
        """Viewコンストラクタ"""
        super().__init__()

        # ===== 画面要素の定義
        # テーブルビューの生成
        self.table_view = qtw.QTableView()
        # ソート可否の設定
        self.table_view.setSortingEnabled(True)

        layout = qtw.QVBoxLayout()
        self.label = qtw.QLabel("", self)
        layout.addWidget(self.label)
        layout.addWidget(self.table_view)
        self.setLayout(layout)

    def show_error_message(self, error):
        """エラーダイアログ表示"""
        qtw.QMessageBox.critical(None, "エラー", error)

    def show_col_input_dialog(self):
        """列名指定ダイアログ"""
        col_name, input_ok = qtw.QInputDialog.getText(self, "列名指定", "新規列名を入力してください。")

        if input_ok:
            return col_name
        else:
            return ""


class MainWindow(qtw.QMainWindow):
    """メインウィンドウ"""

    def __init__(self):
        super().__init__()
        # 画面タイトルの設定
        self.setWindowTitle("CSVエディタ")
        # 画面サイズの設定
        self.resize(640, 360)

        # モデルを生成
        self.model = None
        # Viewを生成
        self.view = View()
        # CentralWidgetへ設定
        self.setCentralWidget(self.view)

        # [ファイル]メニュー
        self.file_menu = self.menuBar().addMenu("ファイル(&F)")
        # [編集]メニュー
        self.edit_menu = self.menuBar().addMenu("編集(&E)")

        # ===== [ファイル]メニューのアクションの追加
        # [ファイルを開く...] アクション
        self.open_action = qtg.QAction(
            "ファイルを開く...",
            self,
            triggered=self.select_file,
            shortcut=qtg.QKeySequence("Ctrl+O"),
        )
        self.file_menu.addAction(self.open_action)

        # [保存] アクション
        self.save_action = qtg.QAction(
            "保存",
            self,
            triggered=self.save_file,
            shortcut=qtg.QKeySequence("Ctrl+S"),
            enabled=False,
        )
        self.file_menu.addAction(self.save_action)

        # [名前を付けて保存...] アクション
        self.save_as_action = qtg.QAction(
            "名前を付けて保存",
            self,
            triggered=self.save_file_as,
            shortcut=qtg.QKeySequence("Ctrl+Shift+S"),
            enabled=False,
        )
        self.file_menu.addAction(self.save_as_action)

        # [終了] アクション
        self.quit_action = self.file_menu.addAction("終了", self.close)

        # ===== [編集]メニューのアクション追加
        # [上に行を追加する] アクション
        self.insert_above_action = qtg.QAction(
            "上に行を追加する",
            self,
            triggered=self.insert_above,
            enabled=False,
        )
        self.edit_menu.addAction(self.insert_above_action)

        # [下に行を追加する] アクション
        self.insert_below_action = qtg.QAction(
            "下に行を追加する",
            self,
            triggered=self.insert_below,
            enabled=False,
        )
        self.edit_menu.addAction(self.insert_below_action)

        # [行を削除する] アクション
        self.remove_rows_action = qtg.QAction(
            "行を削除する",
            self,
            triggered=self.remove_rows,
            enabled=False,
        )
        self.edit_menu.addAction(self.remove_rows_action)

        # [左に列を追加する] アクション
        self.insert_left_action = qtg.QAction(
            "左に列を追加する",
            self,
            triggered=self.insert_left,
            enabled=False,
        )
        self.edit_menu.addAction(self.insert_left_action)

        # [右に列を追加する] アクション
        self.insert_right_action = qtg.QAction(
            "右に列を追加する",
            self,
            triggered=self.insert_right,
            enabled=False,
        )
        self.edit_menu.addAction(self.insert_right_action)

        # [列を削除する] アクション
        self.remove_columns_action = qtg.QAction(
            "列を削除する",
            self,
            triggered=self.remove_columns,
            enabled=False,
        )
        self.edit_menu.addAction(self.remove_columns_action)

        # 画面表示
        self.show()

    def select_file(self):
        """対象ファイルを選択する"""
        filename, _ = qtw.QFileDialog.getOpenFileName(
            self,
            "ファイルを開く",
            qtc.QDir.currentPath(),
            "CSVファイル (*.csv);;すべてのファイル (*)",
            "CSVファイル (*.csv)",
            options=qtw.QFileDialog.Option.DontUseNativeDialog
            | qtw.QFileDialog.Option.DontResolveSymlinks,
        )
        # ファイル読み込み
        if filename:
            if not self.model:
                # モデルが生成されていない場合に生成
                self.model = CsvTableModel()
                self.model.error.connect(self.view.show_error_message)

            # ファイルをオープンして、モデルをセットする
            if self.model.csv_open(filename):
                self.view.table_view.setModel(self.model)
                # アクションの有効化
                self.save_action.setEnabled(True)
                self.save_as_action.setEnabled(True)
                self.insert_above_action.setEnabled(True)
                self.insert_below_action.setEnabled(True)
                self.remove_rows_action.setEnabled(True)
                self.insert_left_action.setEnabled(True)
                self.insert_right_action.setEnabled(True)
                self.remove_columns_action.setEnabled(True)
                # ファイルパス更新
                self.view.label.setText(f"{filename}")

    def save_file_as(self):
        """名前を付けて保存する"""
        # ファイルダイアログを表示
        filename, _ = qtw.QFileDialog.getSaveFileName(
            self,
            "ファイルを保存する",
            qtc.QDir.currentPath(),
            "CSVファイル (*.csv);;すべてのファイル (*)",
            "CSVファイル (*.csv)",
            qtw.QFileDialog.Option.DontUseNativeDialog
            | qtw.QFileDialog.Option.DontResolveSymlinks,
        )
        # ファイル書き込み
        if filename and self.model:
            if self.model.csv_save(filename):
                # ファイルパス更新
                self.view.label.setText(f"{filename}")

    def save_file(self):
        """上書き保存する"""
        if self.model:
            self.model.csv_save()

    def insert_above(self):
        """選択行の上に行を追加する"""
        selected = self.view.table_view.selectedIndexes()
        row = selected[0].row() if selected else 0
        self.model.insertRows(row, 1, None)

    def insert_below(self):
        """選択行の下に行を追加する"""
        selected = self.view.table_view.selectedIndexes()
        row = selected[-1].row() if selected else self.model.rowCount(None)
        self.model.insertRows(row + 1, 1, None)

    def remove_rows(self):
        """選択行を削除する"""
        selected = self.view.table_view.selectedIndexes()
        num_rows = len(set(i.row() for i in selected))
        if selected:
            self.model.removeRows(selected[0].row(), num_rows, None)

    def insert_left(self):
        """選択列の左に列を追加する"""
        col_name = self.view.show_col_input_dialog()
        if col_name:
            selected = self.view.table_view.selectedIndexes()
            column = selected[0].column() if selected else 0
            self.model.insertColumns(column, 1, None, col_name)

    def insert_right(self):
        """選択列の右に列を追加する"""
        col_name = self.view.show_col_input_dialog()
        if col_name:
            selected = self.view.table_view.selectedIndexes()
            column = selected[-1].column() if selected else self.model.columnCount(None)
            self.model.insertColumns(column + 1, 1, None, col_name)

    def remove_columns(self):
        """選択列を削除する"""
        selected = self.view.table_view.selectedIndexes()
        num_columns = len(set(i.column() for i in selected))
        if selected:
            self.model.removeColumns(selected[0].column(), num_columns, None)


def main():
    """メイン関数"""
    app = qtw.QApplication(sys.argv)
    mv = MainWindow()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()

以降で、上記プログラムについて部分ごとに内容を説明していきます。なお、プログラムの全体構成は「QMainWindowを継承した画面開発のテンプレート」をベースにしています。

CsvTableModelクラスの実装

class CsvTableModel(qtc.QAbstractTableModel):
    """CSVデータ用テーブルモデル"""

    # エラーシグナル
    error = qtc.pyqtSignal(str)

今回のプログラムの中心となるモデルは上記のようにQAbstructTableModelを継承して実装します。ファイル操作に失敗した場合用にエラー用シグナルを用意しています。

コンストラクタ __init__()
    def __init__(self):
        super().__init__()
        self.filepath = None
        self._header = None
        self._data = None

コンストラクタでは、csvファイルのファイルパス(filepath)、ヘッダー(_header)、データ(_data)を用意しています。

CSV操作メソッド:csv_open, csv_save
    def csv_open(self, filepath):
        """CSVファイルを読み込みデータをセットする

        Args:
            filepath: CSVファイルパス
        """
        if filepath:
            self.filepath = filepath
            error = ""
            try:
                with open(self.filepath, "r", encoding="utf-8") as f:
                    csv_reader = csv.reader(f)
                    self._header = next(csv_reader)
                    self._data = list(csv_reader)
            except Exception as ex:
                error = f"ファイルを開けませんでした。: {ex}"

            if error:
                self.error.emit(error)
                return False

            return True

    def csv_save(self, filename=None):
        """CSVファイルを保存する"""
        if filename:
            target_file = filename
        else:
            target_file = self.filepath

        if target_file:
            error = ""
            try:
                with open(target_file, "w", newline="", encoding="utf-8") as f:
                    writer = csv.writer(f)
                    writer.writerow(self._header)
                    writer.writerows(self._data)
            except Exception as ex:
                error = f"ファイルを開けませんでした。: {ex}"

            if error:
                self.error.emit(error)
                return False

            # 保存に成功したら現在のファイルパスを更新
            self.filepath = target_file
            return True

上記のメソッドはcsvモジュールを使ったcsvファイルを操作するための簡単なメソッドなので細部説明は省略します。csvモジュールの基本は「CSVファイルの入出力」でもまとめていますので興味があれば参考にしてください。

エラー時にはerror文字列を生成して、エラーシグナルのerror.emit()でシグナルを発生させます。これは後ほどのViewクラスで定義するエラーダイアログのスロットメソッドに接続します。

最低限実装が必要なメソッド:rowCount, columnCount, data
    # ===== QAbstractTableModelを使う際に最低限必要なメソッド
    # rowCount, column, data
    def rowCount(self, parent):
        """テーブルの行数を返却する"""
        return len(self._data)

    def columnCount(self, parent):
        """テーブルの列数を返却する"""
        return len(self._header)

    def data(self, index, role):
        """index, roleで指定されるセルのデータを返却する"""
        if role in (
            qtc.Qt.ItemDataRole.DisplayRole,
            qtc.Qt.ItemDataRole.EditRole,
        ):
            return self._data[index.row()][index.column()]

QAbstructTableModelを使う上では最低限実装が必要なメソッドとして、rowCountcolumnCountdataといったメソッドがあります。その名の通りrowCountやcolumnCountは行数や列数を返却するメソッドで、_dataや_headerを使って長さを返却しています。

また、dataメソッドは、indexやroleで指定されるセルのデータを返却するメソッドです。indexは、QModelIndexクラスのインスタンスでテーブル選択個所の位置を扱うことができます。index.row()で行番号、index.column()で列番号が取得できます。

また、roleはQtCoreで定義されているQt.ItemDataRoleのいずれに該当するかを記載しています。DisplayRoleは表示、EditRoleは編集を意味するものです。これらのRoleに一致する場合はindexで指定される位置のデータの値を返却します。

ソートに必要なメソッド:headerData, sort
    # ===== データのソート関連メソッド
    # headerData, sort
    def headerData(self, section, orientation, role):
        """ヘッダーデータを返却する"""
        if (
            orientation == qtc.Qt.Orientation.Horizontal
            and role == qtc.Qt.ItemDataRole.DisplayRole
        ):
            return self._header[section]
        else:
            return super().headerData(section, orientation, role)

    def sort(self, column, order):
        # ソートを実行する前に必要
        self.layoutAboutToBeChanged.emit()
        # データをソートする
        self._data.sort(key=lambda x: x[column])
        if order == qtc.Qt.SortOrder.DescendingOrder:
            self._data.reverse()
        # ソート後に必要
        self.layoutChanged.emit()

テーブルを操作する際に列名でソートしたくなることがありますが、その場合にはheaderDatasortメソッドの実装が必要です。

headerDataメソッドでは、sectionで指定される列番号のヘッダーを返却します。

sortメソッドでは、_dataをソートするのですがポイントとしてソートを実行する前にlayoutAboutToBeChanged.emitを、ソートを完了した後にlayoutChanged.emitのシグナルを出すことが必要です。

セルの値の変更に必要なメソッド:flags, setData
    # ===== 書き込み対応のためのメソッド
    # flags, setData
    def flags(self, index):
        """フラグの設定"""
        return super().flags(index) | qtc.Qt.ItemFlag.ItemIsEditable

    def setData(self, index, value, role):
        """データを設定する"""
        if index.isValid() and role == qtc.Qt.ItemDataRole.EditRole:
            # valueをindexに該当するセルに設定する
            self._data[index.row()][index.column()] = value
            self.dataChanged.emit(index, index, [role])
            return True
        else:
            return False

各セルの値を変更するためには上記のflagssetDataといったメソッドの実装が必要です。各セルの値を読み取り専用で編集できないようにしたい場合は、コメントアウトしてもらえば読み取り専用のテーブルにできます。

flagsメソッドでは、qtc.Qt.ItemFlag.ItemEditableをフラグに|で追加することで編集可能であることを指定しているような形になっています。

また、setDataメソッドの方は指定された位置(index)の位置に値(value)を設定するメソッドだと思ってください。「index.isValid()」の結果でインデックスが妥当であり、かつ「EditRole」の場合にデータを指定インデックスの位置に設定しています。

また、データ変更後には、データが変更されたことが分かるようにdataChanged.emitでデータが変更されたというシグナルを出すことが必要です。

行追加・削除メソッド:insertRows, removeRows
    # ===== 行追加/行削除用メソッド
    # insertRows, removeRows
    def insertRows(self, row, count, parent):
        """行追加"""
        self.beginInsertRows(parent or qtc.QModelIndex(), row, row + count - 1)
        new_row = [""] * len(self._header)
        for _ in range(count):
            self._data.insert(row, new_row)
        self.endInsertRows()

    def removeRows(self, row, count, parent):
        """行削除"""
        self.beginRemoveRows(parent or qtc.QModelIndex(), row, row + count - 1)
        for _ in range(count):
            del self._data[row]
        self.endRemoveRows()

上記は行を追加・削除しているメソッドで、選択されている行(row)、行数(count)を引数として受け取り_dataを操作して空行を追加したり、指定行をdelで削除しています。

ここでポイントとなるのは行追加の場合は、beginInsertRowsendInsertRowsで、行削除の場合は、beginRemoveRowsendRemoveRowsで処理を囲うということです。これはPyQt側が必要な変更のために処理をするために必要なものということでお決まりとして理解してもらえばいいのかなと思います。

列追加・削除メソッド:insertColumns, removeColumns
    # ===== 列追加/列削除用メソッド
    # insertColumns, removeColumns
    def insertColumns(self, column, count, parent, col_name=""):
        """列追加"""

        if col_name:
            self.beginInsertColumns(
                parent or qtc.QModelIndex(), column, column + count - 1
            )
            new_column = [""] * count
            new_header = [col_name] * count
            self._header[column : column + count - 1] = new_header
            for i, _ in enumerate(self._data):
                self._data[i][column : column + count - 1] = new_column
            self.endInsertColumns()

    def removeColumns(self, column, count, parent):
        """列削除"""
        self.beginRemoveColumns(parent or qtc.QModelIndex(), column, column + count - 1)
        del self._header[column : column + count]
        for i, _ in enumerate(self._data):
            del self._data[i][column : column + count]
        self.endRemoveColumns()

上記は列を追加・削除しているメソッドで、選択されている列(column)、行数(count)を引数として受け取り_headerや_dataに対して追加や削除を行います。また、追加の際には列名に指定された文字列を列名として列を作成しています。

ここでポイントとなるのは行の追加・削除と同じで、追加の際はbeginInsertColumnsendInsertColumnsで、列削除の場合にはbeginRemoveColumnsendRemoveColumnsで囲うことになります。

Viewクラスの実装

Viewクラスは、今回の簡易CSVエディタの画面構成を定義しているものです。

コンストラクタ:__init__()
class View(qtw.QWidget):
    """MainView"""

    def __init__(self):
        """Viewコンストラクタ"""
        super().__init__()

        # ===== 画面要素の定義
        # テーブルビューの生成
        self.table_view = qtw.QTableView()
        # ソート可否の設定
        self.table_view.setSortingEnabled(True)

        layout = qtw.QVBoxLayout()
        self.label = qtw.QLabel("", self)
        layout.addWidget(self.label)
        layout.addWidget(self.table_view)
        self.setLayout(layout)

コンストラクタでテーブルビューであるQTableViewをtable_viewとして生成しています。また、テーブルがソート可能なようにsetSortingEnableでTrueを指定しています。

あとは、QVBoxLayoutでファイルパスを表示するための「label」とテーブルビューである「table_view」を垂直にレイアウトしています。

エラーダイアログ
    def show_error_message(self, error):
        """エラーダイアログ表示"""
        qtw.QMessageBox.critical(None, "エラー", error)

上記は、error文字列を受け取ってエラーダイアログを出す簡単なメソッドです。モデル側でCSVファイルが開けなかった際のシグナルと接続してエラーを表示します。

列名指定ダイアログ
    def show_col_input_dialog(self):
        """列名指定ダイアログ"""
        col_name, input_ok = qtw.QInputDialog.getText(self, "列名指定", "新規列名を入力してください。")

        if input_ok:
            return col_name
        else:
            return ""

上記は、列追加が呼び出されたときに列名を指定するようなダイアログです。以下のような画面で列名を入力できるようにします。

QTableView QAbstructTableModel 簡易CSVエディタ 列名指定ダイアログ

MainWindowクラスの実装

最後にMainWindowクラスの説明をします。MainWindowクラスは、ビューとモデルを含んだようなものになっています。

コンストラクタ:__init__()
class MainWindow(qtw.QMainWindow):
    """メインウィンドウ"""

    def __init__(self):
        super().__init__()
        # 画面タイトルの設定
        self.setWindowTitle("CSVエディタ")
        # 画面サイズの設定
        self.resize(640, 360)

        # モデルを生成
        self.model = None
        # Viewを生成
        self.view = View()
        # CentralWidgetへ設定
        self.setCentralWidget(self.view)

        # ================ メニュー追加等についての説明は省略

        # 画面表示
        self.show()

コンストラクタでは、モデル(model)とビュー(view)を生成し、ビューの方は上記で作ったViewクラスをインスタンス化し、setCentralWidgetで中心ウィジェットに設定しています。なお、モデルの方はファイルを開く操作の時に生成することとして初期の生成時にはNoneとしています。

メニューの追加等のコードについては「QMainWindowを継承した画面開発のテンプレート」のページでも説明しているため今回詳細は省略します。なお、アプリケーション初期表示時点でファイルを選択されていない時には保存や編集等はできる必要はないため、enabled=Falseとして以下のように選択できないようにしています。以降でファイルを選択(select_file)する際にメニューのアクションを有効化します。

QTableView QAbstructTableModel 簡易CSVエディタ メニューの無効化
QTableView QAbstructTableModel 簡易CSVエディタ メニューの無効化
ファイル選択メソッド:select_file
    def select_file(self):
        """対象ファイルを選択する"""
        filename, _ = qtw.QFileDialog.getOpenFileName(
            self,
            "ファイルを開く",
            qtc.QDir.currentPath(),
            "CSVファイル (*.csv);;すべてのファイル (*)",
            "CSVファイル (*.csv)",
            options=qtw.QFileDialog.Option.DontUseNativeDialog
            | qtw.QFileDialog.Option.DontResolveSymlinks,
        )
        # ファイル読み込み
        if filename:
            if not self.model:
                # モデルが生成されていない場合に生成
                self.model = CsvTableModel()
                self.model.error.connect(self.view.show_error_message)

            # ファイルをオープンして、モデルをセットする
            if self.model.csv_open(filename):
                self.view.table_view.setModel(self.model)
                # アクションの有効化
                self.save_action.setEnabled(True)
                self.save_as_action.setEnabled(True)
                self.insert_above_action.setEnabled(True)
                self.insert_below_action.setEnabled(True)
                self.remove_rows_action.setEnabled(True)
                self.insert_left_action.setEnabled(True)
                self.insert_right_action.setEnabled(True)
                self.remove_columns_action.setEnabled(True)
                # ファイルパス更新
                self.view.label.setText(f"{filename}")

select_fileメソッドはファイルダイアログを開いて指定したファイル名を読み込みます。

ファイル名が読み込まれた場合でモデルが生成されていない場合にはモデルを生成し、エラーシグナルとエラーメッセージのスロットを接続します。その後、ファイルをオープンしてQTableViewのsetModelでモデルをセットすることで使えるようにします。

また、保存や編集アクションなどファイルを開いた際に有効化するべきアクションをsetEnabledにTrueを設定することで有効化しています。

名前を付けて保存:save_file_as 上書き保存:save_file
    def save_file_as(self):
        """名前を付けて保存する"""
        # ファイルダイアログを表示
        filename, _ = qtw.QFileDialog.getSaveFileName(
            self,
            "ファイルを保存する",
            qtc.QDir.currentPath(),
            "CSVファイル (*.csv);;すべてのファイル (*)",
            "CSVファイル (*.csv)",
            qtw.QFileDialog.Option.DontUseNativeDialog
            | qtw.QFileDialog.Option.DontResolveSymlinks,
        )
        # ファイル書き込み
        if filename and self.model:
            if self.model.csv_save(filename):
                # ファイルパス更新
                self.view.label.setText(f"{filename}")

    def save_file(self):
        """上書き保存する"""
        if self.model:
            self.model.csv_save()

上記メソッドは、ファイルを保存するためのメソッドです。名前を付けて保存の際にはファイルダイアログを開いて選択してから、選択ファイルパスを引数に指定してモデルのcsv_saveを呼び出します。名前を付けて保存の場合は、別ファイルになるので画面のファイルパス表示を更新しています。

また、上書き保存の際には既にモデルに設定されているファイルパスに対して保存を実行するため、引数なしでモデルのcsv_saveを呼び出します。

編集関連メソッド:insert_above, insert_below, remove_rows, insert_left, insert_right, remove_columns
   def insert_above(self):
        """選択行の上に行を追加する"""
        selected = self.view.table_view.selectedIndexes()
        row = selected[0].row() if selected else 0
        self.model.insertRows(row, 1, None)

    def insert_below(self):
        """選択行の下に行を追加する"""
        selected = self.view.table_view.selectedIndexes()
        row = selected[-1].row() if selected else self.model.rowCount(None)
        self.model.insertRows(row + 1, 1, None)

    def remove_rows(self):
        """選択行を削除する"""
        selected = self.view.table_view.selectedIndexes()
        num_rows = len(set(i.row() for i in selected))
        if selected:
            self.model.removeRows(selected[0].row(), num_rows, None)

    def insert_left(self):
        """選択列の左に列を追加する"""
        col_name = self.view.show_col_input_dialog()
        if col_name:
            selected = self.view.table_view.selectedIndexes()
            column = selected[0].column() if selected else 0
            self.model.insertColumns(column, 1, None, col_name)

    def insert_right(self):
        """選択列の右に列を追加する"""
        col_name = self.view.show_col_input_dialog()
        if col_name:
            selected = self.view.table_view.selectedIndexes()
            column = selected[-1].column() if selected else self.model.columnCount(None)
            self.model.insertColumns(column + 1, 1, None, col_name)

    def remove_columns(self):
        """選択列を削除する"""
        selected = self.view.table_view.selectedIndexes()
        num_columns = len(set(i.column() for i in selected))
        if selected:
            self.model.removeColumns(selected[0].column(), num_columns, None)

上記メソッド群は、編集メソッドから呼び出される行や列の追加削除メソッドです。

基本的にはtable_viewのselectedIndexesで指定されたインデックス情報を取得してきて、モデルクラスに実装した各種の行、列の追加・削除メソッドを呼び出しています。列追加の際には、新規列名が分からないのでViewクラスの列名指定ダイアログで列名を取得してから列追加メソッドを呼び出します。

以上が、QTableViewとQAbstructTableModelを使った簡易CSVエディタの概要説明でした。

まとめ

PythonのGUIツールキットであるPyQtで提供されているQTableViewQAbstructTableModelの使用方法について紹介しました。QTableViewとQAbstructTableModelの使用方法を簡易的なCSVエディタを作成することで整理、紹介しています。

QAbstructTableModelの使用では、いくつかオーバーライドして実装するべきメソッドがありますので実装時の参考にしていただければと思います。

なお、プログラムを実装する際には色々なところにブレークポイントを置いてデバッグで動作を確認してみることをおすすめします。画面でどの操作をすると、どのメソッドに入るかが少しずつ分かってくるかと思います。

※QTableViewとQAbstructTableModelの概要を整理する目的で作成した簡易的なものであるため細部処理の考慮が行き届いていない部分がある点はご容赦ください。