Thor's Columns
PyQtでお手軽GUI開発♪―――は可能だったか? 第7回 モデル&ビュー編

PyQtでお手軽GUI開発♪―――は可能だったか? 第7回 モデル&ビュー編

 画面の描画やレイアウトなどができれば、あとは各ウィジェットの細かい使い方だけです。そういうことならドキュメントを見ればもう分かるでしょう―――という感じで当初はこのへんでレイアウト関連を終了するつもりだったのですが、そうは問屋が卸してくれませんでした。

 QtDesignerにあるウィジェットはおおむね直観的に使えるのですが、中にとても分かりづらい物があったからです。それが今回話題にするモデル&ビューという仕組みでした。


ビューとウィジェットって?

 QtDesignerのウィジェットボックスを見ると、Item Views(Model-Based)というカテゴリと、Item Widget(Item-Base)というカテゴリがあることに気づくと思います。前者の中にはListView、TreeView、TableView、ColumnViewという物があるのですが、後者にもListWidget、TreeWidget、TableWidgetと、何だか同じような名前のウィジェットが入っています。アイコンも同じだし、いったいどう違うのでしょうか?


 そこで軽く調べてみたら、Qtのウィジェットにはモデル&ビューという思想の元に作られたものがあることがわかりました。これは実データとそれを表示するオブジェクトを別々にすることによって、設計の柔軟性を高めようということのようです。

 そして前述のViewsの方がそんな思想の元にできていて、Widgetsの方は旧来のウィジェットとデータが一体型になっているタイプでした。

 そしてなぜ二種類用意されているかというと、やはりちょろっと利用したいような場合に一々モデルを作っていたら面倒なので簡単に使えるウィジェットの方も用意してある、といったそんな理由のようでした。


 ということなら、こちらとしてはコラムのタイトル通りいかにお手軽にGUIするかというのが目的です。実際に軽くWidgetsを使ってみたら、おおむね直観的に使えます。またWidgetsの方はQtDesignerでダブルクリックすれば初期値を設定できますが、Viewsの方は必ずコードで設定しなければなりません。

 そこでまあ、初心者ならとりあえずWidgetの方を使っておいて何かそんな必要性が出てきたら改めてViewの使い方を覚えればいいんじゃないかな? というスタンスでここは終了しようと考えました。


 ただそこで最後にちょっとオーナードローのやり方だけは調べておこうと思ったのです。

 DelphiなどではListBoxなどの表示をユーザーが自由にカスタマイズする手段が提供されていて、多分これはかなりないと困る機能なのです。

 ところが―――またWidgetsの説明のどこを見てもそんな項目はありません! そして結局そういうことをしたければデレゲートという仕組みを使わなければならないのですが、それを使えるのはViewの方だけだという結果になってしまったわけで、すなわち……

オーナードローがやりたければモデルベースの使い方を知っておかなければならない

 ―――のでした。

 それで仕方がないのでちょっとこっちもやってみようと手を出してみたのですが―――それが運の尽きでした。


モデルとビューとデレゲート

 まず最初にここでモデル、ビュー、そしてデレゲートという物に関して筆者が理解した範囲で簡単に説明しておきます。


 例えばDelphiではListBoxというコントロールはItemsというプロパティを持っていて、それに値を代入していけばListBoxに表示されます。すなわちListBox自身が表示するアイテムを抱え持っているわけです。

 それに対してデータ本体と表示部の独立性をもっと高めようというコンセプトで作られたのがQtのViewで、これはその内部にデータを持ってはおらず、接続された外部のモデルが管理するデータを表示する機能だけを持っています。

 このため例えば同じデータでも使用するビューを切り替えることで、元データは何も変えることなく違った表示に変えたり、同時に別々の切り口でデータを表示したりできるようになります。


 さらにデータの表示・修正が行われる際にはデレゲートというオブジェクトを経由します。このデレゲートが実際にデータを表示する際の表示の仕方や、データを修正する際のエディタ機能も管理しています。

 すなわちこのデレゲートクラスを差し替えてやれば、画面表示やデータの入力方法をカスタマイズすることが可能になるわけです。

 こんな風に機能をいろいろと分散することで見栄えや操作の変更を、元データに全く影響を与えずに行うことができるようになって色々便利じゃないかというのがモデルビューのコンセプトです。


 以下はモデルとビューとデレゲートの関係のイメージです。



 さてこのコンセプトの是非はともかく、QtのViewというのがこういう考え方で設計されていることが分かれば、あとはそのモデルを作ってやればいいわけです。


 そこでわりと軽い気持ちで、ツリー構造のサンプルとして入れ子になったPythonのリストでも表示させてみようかと思ったわけでした。

 すなわちこんなリストがあったら……

[1, [2, [3, 4], 5, 6], 7, [8, 9], 10]

 ―――こんな感じで表示させてみたいなと思ったわけです。

1

[2, [3, 4], 5, 6]

  2

  [3, 4]

    3

    4

  5

  6

7

[8, 9]

  8

  9

10

モデル制作で泥沼にはまる

 さてこのモデルですが、もちろんどんなデータでもいいわけではなくQAbstractItemModelというクラスを継承したクラスでなければなりません。Viewで表示されるためのインターフェースくらいは必要なのは当然です。

 そこで上記のようなリストを与えたらTreeViewで表示してくれるQAbstractItemModelを継承したクラスを作ろうと考えたわけです。


 ―――実はこの時点でいろいろ既に見落としをしておりました。

 まずQAbstractItemModelのところを見に行くと、そいつを継承しているサブクラスは QAbstractListModel, QAbstractProxyModel, QAbstractTableModel, QDirModel, QFileSystemModel, QHelpContentModel, and QStandardItemModelなどと、かなり特殊な奴ばっかりです。そのため原則としてモデルを使うにはQAbstractItemModelを継承して自前でモデルを作る必要があると思ってしまったわけです。


 その場合に何をしなければならないかというと、少なくとも以下の関数をオーバーライドする必要があるようなのですが……

def index(self, row: int, column: int, parent: Qc.QModelIndex) -> Qc.QModelIndex:
#行と列と親のインデックスからアイテムのインデックス(QModelIndex)を作成する。

def data(self, index: Qc.QModelIndex, role=Qt.DisplayRole) -> Any:
#指定されたインデックスの値を返す。

def rowCount(self, index: Qc.QModelIndex) -> int:
#データの行数を返す。

def columnCount(self, index: Qc.QModelIndex) -> int:
#データの列数を返す。

def parent(self, index: Qc.QModelIndex) -> Qc.QModelIndex:
#指定されたインデックスの親インデックスを返す

def headerData(self, section, orientation, role) -> str:
#指定されたセクションのヘッダー文字列を返す。

 見た感じそれほどたいしたこともなさそうです。読んでる人も多分そう思ったんじゃないでしょうか?

 それより、むしろこの程度ならもっと自動化できそうなんじゃないかと疑問に思った人もいるかもしれません。こちらもそこはかとなく不審に思ってたわけですが…

―――案の定、全く動きません!

 ちょっと見かけが変とかいうレベルではなく、何も表示されない上にカーネルが死にまくります!(この頃はまだSpyderの設定について気づいていなかったというのもありますが―――それを直してもやっぱり死にます)


 そんな場合はまず一つ一つの関数を確実に作っていくのが鉄則です。

 しかしこの場合、全部の関数が全て正しく完成して初めて表示が出てくるわけで―――これではこの関数は大丈夫と言い切ることができません。

 で、あーだこーだやった挙げ句なんとか完成はしたのですが―――その結果判明したことは、Pythonのリストを単に表示するというのはわりと一般的には無理だということでした……


 しかも今回やったのは単に表示するところだけで、後まだソートしたりデータ修正などやることはまだいろいろあります。いい加減疲れて、もうモデルを使うのは諦めるしかないのかとかなり暗い気分になったのですが―――そこでふとQAbstractItemModelのサブクラスの中にしれっとQStandardItemModelというのがあることに気づいたわけです。

 そしてそいつをよく見てみたら……

これさえあれば何でもできるじゃないですか!

 なんかもう、疲れたですよ。ほんとに……


QStandardItemModel と QStandardItem

 とは言ってもこれもいきなりだとそれなりにややこしいと思います。


 StandardItemModelというのは、二次元の行列のテーブルがあって、そのテーブルの各項目が再帰的に子アイテムとして二次元テーブルを持てる、といった構造をしています。

 なのでその特殊例と考えればこれで1次元のリストでも2次元のテーブルでも、ツリー構造でも自由に表現できるわけです(まあ一次元のリストならQStringListModelの方がいいんじゃないかという気がしますが)


 これを使う際にはまた、QStandardItemModelの他に、QStandardItemQModelIndexというクラスの使い方が分からないとダメで、また例のごとくに情報が分散していますが、概要は以下のようなものです。

  1. まずベースとなるQStandardItemModelを作ります。
  2. そこにappendRowという関数で、例えば以下のようにQStandardItemのリストを追加していきます。すなわちQStandardItemがテーブルの1項目に相当します。
model.appendRow([Qg.QStandardItem('カラム1'), Qg.QStandardItem('カラム2'), ...]) 
  1. できあがったStandardItemModelを適当なViewのsetModel関数で設定すれば、データが表示されます。

 QStandardItemに子アイテムを持たせたい場合は、QStandardItemクラスにもappendRowといったデータを追加するメソッドがあるので同じようにそれを使います。すなわちトップレベルではアイテムの親はQStandardItemModelクラスですが、それ以下では親がQStandardItemクラスになるわけです。


QModelIndex

 ところがこのQStandardItemですが、データ作成のときにはこうやって使うんですが、その後は意外に使いません。

 例えばViewをクリックした場所の項目の値を知りたいような場合、クリックイベントのシグナルと共にQModelIndexというクラスがやってきます。これからもちろんアイテムの行列番号を取得することもできますが、実はQModelIndex自身のdata関数を使えばクリックされたアイテムのデータが直接取得できてしまいます。

 またsiblingという関数で周辺アイテムのモデルインデックスも取得できるので、テーブルやリストのようなデータなら値を取得するためにQStandardItemを参照する必要は全くありません。


 ただQModelIndexにはparentはあるのですがchildrenがありません。そこで子アイテムを取得するためにはそのModelIndexに相当するStandardItemを取得しなければなりませんが、そのためにはQStandardItemModelの方にあるitemFromIndex関数を使う必要があります(StandardItemの方からならindex関数で対応するModelIndexを取得できます)


 このあたりの関係がなにか分かりづらく、普通ならQStandardItemがデータの本体でQModelIndexはその単なる参照だと思うのですが、実はQtのモデルビューにおいてはこのQModelIndexというクラスの方が本質的な役割を果たしています。何しろ実際のデータを表すinternalPointerという関数がModelIndexの方にあって―――確かに生データを参照しているわけですが―――最初はそのへんが相当混乱するのではないかという気がします。


 ともかくそういうわけでいくつかサンプルを作ってみました。


CSVビューア

 これはCSVファイルを読み込んで表示させるというものですが、実は最後に作った奴です。

  1. まずツリービューとテーブルビューを貼っただけのフォームを作成します。
  2. 続いて__init__の中で以下のように読んだデータをモデルに設定するだけです。
self.modelcsv = Qg.QStandardItemModel()                 #モデルを作成
with open('sample.csv') as f:                           #CSVを読む
    reader = csv.reader(f)                              #Pythonのcsvモジュールのreaderで一発

    header = next(reader)                               #最初の一行はヘッダー
    self.modelcsv.setHorizontalHeaderLabels(header)

    for row in reader:                                  #続きからが実データ
        items = []
        for col in row:
            items.append(Qg.QStandardItem(col))         #StandardItemのリストを作成
        self.modelcsv.appendRow(items)                  #できたリストをモデルに登録

self.ui.treeView.setModel(self.modelcsv)                #できたモデルをビューに登録
self.ui.tableView.setModel(self.modelcsv)


 慣れればもうこの程度はこんな感じで一撃です。StandardItemModelを使う前提なら、Widgetsを使うのと手間はそう変わりません。なので初めての場合ならとりあえずこれの使い方を覚えておけばいいんじゃないか? という気分になってたりしますw


◆TreeViewとColumnView

 ちなみにQtDesignerにはListView、TableView、TreeView、ColumnViewという4種類が登録されていますが、名前を見ただけではListViewが1次元の通常のリスト、TableViewが表計算っぽいテーブル、TreeViewがディレクトリのツリー表示みたいなものになって、ColumnViewがエクスプローラみたいな感じのテーブルだろうと思ってしまうかもしれません。

 しかしListとTableはその通りなのですが、エクスプローラみたいな表示に関してはTreeViewだけがあればOKです。このビューはアイテムのツリー表示も項目のテーブル表示もどっちもできます。


 ではColumnViewとは何かというと、これは実は以下のようにツリー型データを表示することに特化した特殊なビューのようです。




ツリーとデレゲートのサンプル

 続いてツリーのサンプルとして、ここではQtのクラスの継承図を作ってみようかと思います。


 一見えらく大変な作業のように思えますが、Pytyonの場合__dict__でクラス名の文字列やクラスそのものを取得することができます。クラスが分かれば親クラスも分かります。

 ただどんな子がいるかは分からないので、それは全部のクラスをリストアップして、自分の親に教えてやる必要はありますが、それも機械的な作業です。


 その画面は左右に二つのツリービューがあり、左ペインの第1列目にクラスのツリー表示、2列目にはモジュール、3列目に親クラス名を表示することにします。

 右側のペインには、選択されているクラスが持っている識別子を表示することにしましょう。


 ―――あとついでに右側のビューの表示をカスタマイズもしてみます。元はといえばこいつのためにこんな騒ぎになっているわけで……


 表示のカスタマイズ、すなわちオーナードローするためにはデレゲート、QItemDelegateQStyledItemDelegateクラスを継承して、その中のpaint関数をオーバーライドします。以下がそのpaint関数の定義ですが、

def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex):

 painterは描画に使うQPainterで、indexはデータの入っているQModelIndexです。そしてoptionのQStyleOptionViewItemに描画に必要な様々な情報がセットされています―――が、特に重要な項目はその親クラスのQStyleOptionの方にあるので見落とさないようにしましょう。

 例えば……

 こういった情報を元にpaint関数を作り、setItemDelegate関数で自作のデレゲートをビューにセットすれば表示をカスタマイズできます。

 そんな感じで作ったのが以下のサンプルです。少々長いですがやってることはたいしたことありません。

# -*- coding: utf-8 -*-
"""ツリービューとスタンダードモデルのデモ"""
import sys
import PyQt5
from PyQt5 import QtCore as Qc, QtGui as Qg, QtWidgets as Qw
from PyQt5.QtCore import Qt
import qtviewui

#==============================================================================
# デレゲートクラス
#==============================================================================
class MyDelegate(Qw.QStyledItemDelegate):

    def paint(self, painter, option, index):
        """関数名表示のオーナードロー"""

        #選択された項目の背景をまっ黄色にする
        if option.state & Qw.QStyle.State_Selected:
            painter.fillRect(option.rect, Qt.yellow)

        #マウスオーバーの場所に四角を描く
        if option.state & Qw.QStyle.State_MouseOver:
            r = option.rect
            painter.drawRect(r.left(), r.top(), r.width()-1, r.height()-1)

        #表示するテキストをセンタリングして「夜の」と付け加える
        painter.drawText(option.rect, Qt.AlignCenter, '夜の'+index.data())

#==============================================================================
# Qtのクラスを取得しデータ化する
#==============================================================================
def setclass(module, clist):
    """Qtのクラスを取得してリスト化する

    モジュール名がキーのDictだが、その値がまたDictで以下のmodule_,base_,child_を持つ
    """
    #モジュールの識別子を列挙する
    for k in module.__dict__.keys():
        if k not in clist.keys():
            clist[k] = {}
        clist[k]['module_'] = module.__name__   #モジュール名
        clist[k]['base_'] = []                  #ベースクラス名のリスト
        clist[k]['child_'] = []                 #サブクラス名のリスト

        cls = module.__dict__[k]                #文字列からインスタンス化する

        #出てくる識別子はクラスに限らないので__bases__がないこともある。文字列は
        try:
            for base in cls.__bases__:
                #得られたクラスのモジュールがPyQt5の時のみ、リストに追加(sipとかが混じっている)
                if base.__module__[:5] == 'PyQt5':
                    clist[k]['base_'].append(base.__name__)
        except:
            pass

def setparent(clist):
    """リストに親子情報をセットする

    ※クラスは親は知っているが子は知らない。そこで総なめして子をセットする。
    """
    for k in clist.keys():
        for bs in clist[k]['base_']:            #継承元があったなら
            try:
                clist[bs]['child_'].append(k)   #継承元の子供に自分の名をセット
            except:
                pass

#==============================================================================
# メインの処理
#==============================================================================
class MyForm(Qw.QMainWindow):

    def __init__(self, parent=None):
        """MyFormの初期化を行う"""

        super().__init__(parent)
        self.ui = qtviewui.Ui_MainWindow()
        self.ui.setupUi(self)

        #クラスツリー表示画面の作成
        self.get_qtclass()
        self.setmodel()
        self.ui.treeView_class.setModel(self.treemodel)
        self.ui.treeView_class.sortByColumn(0, Qt.AscendingOrder)

        #関数名表示画面の作成
        self.funcsmodel = Qg.QStandardItemModel()
        self.funcsmodel.setSortRole(Qt.UserRole+1)
        self.ui.treeView_func.setModel(self.funcsmodel)

        #関数名表示のデレゲート
        self.funcdelegate = MyDelegate()
        self.ui.treeView_func.setItemDelegate(self.funcdelegate)

    def get_qtclass(self):
        """self.qtcにQtクラス階層情報を取得する"""

        self.qtc = {}
        setclass(Qc, self.qtc)
        setclass(Qg, self.qtc)
        setclass(Qw, self.qtc)
        setparent(self.qtc)

    def getstditems(self, cname, parent):
        """階層構造付きでitemのStandardItemを作成する"""

        #まずitemのQStandardItemを作る。カラムが複数ある場合は以下のようにリストにする
        row = [Qg.QStandardItem(cname),
               Qg.QStandardItem(self.qtc[cname]['module_']),
               Qg.QStandardItem(str(self.qtc[cname]['base_']))
              ]
        #ソートキーを持たせておく(setDataのデフォルトロールはQt.userole+1)
        row[0].setData(row[0].data(Qt.DisplayRole).lower())
        row[1].setData(row[1].data(Qt.DisplayRole))
        #row[2]を書いてないのでカラム2ではソートできない

        #行を追加する
        parent.appendRow(row)

        #アイテムに子供がいればそれをappendするが
        for child in self.qtc[cname]['child_']:
            #孫がいる可能性があるので再帰的に呼び出す
            self.getstditems(child, row[0])

    def setmodel(self):
        """TreeModelにアイテムをセットする"""

        self.treemodel = Qg.QStandardItemModel()
        self.treemodel.setHorizontalHeaderLabels(
                ['Class', 'module', 'baseclass'])

        #ソートキーを変更する
        self.treemodel.setSortRole(Qt.UserRole+1)

        #以下ルートのアイテムに子供を追加していく
        for cn in self.qtc.keys():
            #baseのないクラスはトップレベルになるので
            if self.qtc[cn]['base_'] == []:
                #QStandardItemModelを親にすればトップレベルに追加できる
                self.getstditems(cn, self.treemodel)

    def clickedClasslist(self, index=Qc.QModelIndex):
        """クラス名をクリックしたらそのクラスの関数名を表示する"""

        #クリックした「場所」のQModelIndexがやってくるので
        #自分の階層の周囲の値を参照したい場合はsiblingを使う
        clsname  = index.sibling(index.row(), 0).data()
        mdlename = index.sibling(index.row() ,1).data()

        #funcsmodelに関数名を入れていく
        self.funcsmodel.clear()
        self.funcsmodel.setHorizontalHeaderLabels(['Function'])
        try:
            #モジュールとクラス名文字列から該当クラスを取得する
            self.selectedclass = sys.modules[mdlename].__dict__[clsname]
            #そのクラスの識別子を列挙する
            for fname in self.selectedclass.__dict__.keys():
                item = Qg.QStandardItem(fname)
                item.setData(fname.lower())
                self.funcsmodel.appendRow([item])
        except:
            print('AttributeError')
        self.ui.treeView_func.sortByColumn(0, Qt.AscendingOrder)

if __name__ == '__main__':
    app = Qw.QApplication(sys.argv)
    wmain = MyForm()
    wmain.show()
    sys.exit(app.exec())

 実行結果は以下のようになります。



◆View項目のソート

 例えばTreeViewで各項目のソートを行いたいときは、QtDesignerでQTreeViewのsortingEnabledプロパティをチェックします。そうすれば勝手に各項目がソートできるようになります。

 しかしその場合ソート順は表示文字列のAsciiコード順になるので、例えば大文字と小文字が完全に別々になってしまうなど、あまり見栄えがよくありません。これを修正するにはStandardItemのsetData関数で項目に適当なソートキーを持たせておいて、QStandardItemModelのsetSortRole関数でソート時に使用するroleを変更してやります。

 上記の例では、

row[0].setData(row[0].data(Qt.DisplayRole).lower())

 このように第0列ではsetDataで小文字に変換した表示文字列を入れています。

 setData関数は引数を省略すれば role=Qt.UserRole+1 となるので、

self.treemodel.setSortRole(Qt.UserRole+1)

 こう書けばQt.UserRole+1のデータがキーとして使用されるようになるので、結果として大文字小文字を区別しないソートになります。

ということで……

 最後にQAbstractItemModel継承のサンプルも載せようかと思ってましたが、長くなったので分割して次回に回します。

2017-04-14