Thor's Columns
PyQtでお手軽GUI開発♪―――は可能だったか? 第6回 状態保存編

PyQtでお手軽GUI開発♪―――は可能だったか? 第6回 状態保存編

 前回やったようにQtでは細々としたレイアウト調整をすることが可能です。もちろんウインドウのサイズを変更できるのは当たり前で、スプリッタなどでユーザーに最適なUIサイズを選ばせることもできます。

 しかしそういったツールの場合Qtに限らず、終了時にウインドウのサイズや位置・スプリッタの場所などの設定を保存して、次回の起動時にリストアできないと、むしろストレスが溜まることになります。


そいつは誰だ⁉

 ところが実際にそういうツールを作ってみると結局、起動時と終了時にウインドウのサイズや位置、その他を個々にちまちまとIniFileなどに保存したりリストアするコードを書かざるを得なくなって、す・ご・く面倒です。

 そこで昔、Delphiでウインドウや子コントロールの状態を自動的に保存・リストアするクラスとかができないかなあといろいろ考えてみたんですが―――結局無理でした。


 こういったGUIの場合、親ウィジェットはその中に貼られている子ウィジェットのリストを持っているので、再帰的に全てをリストアップすることは可能です。しかし問題は得られたオブジェクトのクラスまでは判定できるのですが、最終的にそれが何という変数で定義されていたかが分からなかったためです。

 PascalやCといった言語の場合、プログラムで使う変数名はプログラムのときにだけ必要で、コンパイル後にはもう不要です。デバッグを楽にするためにシンボル情報を抱え込むことはあっても、それがなければ動かないわけではありません。要するにコンパイルされて実行されたら、そいつが誰だかもう分からないのです。


 ところが、Qtの場合便利なことに、QObjectを継承するクラス―――すなわち状態保存などに関係する物にはすべてにObjectNameというプロパティがあって、そこに個々の識別名を設定できるようになっています。そしてQtDesignerで作った各ウィジェットには、みんな律儀にユニークなObjectNameを設定してくれているのです!

 というわけでQtの場合には原理的にそういうことができそうです!!


状態保存関数がたくさん⁉

 ところがそこでまたちょっとした問題がありました。

 ウインドウの位置やサイズならgeometoryプロパティの値を見て、Inifilenに書いてしまえば問題ありません。

 しかしたとえば先ほどのスプリッタの位置情報とか、またウィジェットの中にQDockWidgetというのがあってこいつがなかなか便利なんですが、ところがこれがメインウインドウのどこにどうくっついているか、それともフロート状態なのか、といったことを表すプロパティがないのです。


 そこでいろいろ調べてみるとそういう場合にはQMainWindowの方にあるsaveStaterestoreStateという関数を使って、バイナリーファイルとして保存・読み込みをすればいいことが分かりました。

 ところがこの関数はDockWidgetなどの状態用で、ウインドウの位置はQWidgetにあるsaveGeometryrestoreGeometryを使わなければならないし、スプリッタに関してはなんとQSplitterが独自のsaveStaterestoreStateを持っているとか、いったいいくつあるんだよって話なわけです。


 すなわち状態保存しようと思うと、IniFileなどの他にそんなバイナリーファイルがごろごろできるということですが―――ひとつにまとめようにもそんなことのために可変長サイズのいくつあるかも分からないバイナリーファイルを管理するコードを書くとか、考えるだけでやる気が削がれてきます。


 ―――というところで思い出したのが、いま使ってる言語がPythonだったということです。この言語の超便利なところに、どんな変数でもとりあえずstrで文字列化できるということがあります。文字列化できればIniFileの項目として普通に書き込めます。

 そしてバイナリーシーケンスとは要するにbyteallayです。これをstrで表現したら例えば以下のような文字列になりますが……

b'\x01\xd9\xd0\xcb\x00\x02\x00\x00\xff\xff\xfb(\x00\x00\x00&\xff\xff\xfeC\x00\x00\x02\xc6\xff\xff\xfb0\x00\x00\x00D\xff\xff\xfe;\x00\x00\x02\xbe\x00\x00\x00\x01\x00\x00\x00\x00\x05\x00'

 これってよく見るとbyteallayを生成するリテラルの書式そのままなわけで―――すなわちこいつをevalに食わせてやれば簡単に元に戻るということで……


configsaver

 そんなわけで作ってみたのが以下にあるconfigsaverクラスです。


 以下長ったらしいですが、やってることはトップから子ウィジェットを再帰的に検索して、情報のセーブが必要そうなウィジェットに関してはIniFileに保存してるだけです。

 コピペしたら多分動くんではないかと思います。

# -*- coding: utf-8 -*-
"""configsaver

ウインドウの情報を自動的にセーブ・リストアするクラス
"""
import sys
from pathlib import Path
import configparser

from PyQt5 import QtCore as Qc, QtGui as Qg, QtWidgets as Qw
from PyQt5.QtCore import Qt

class TConfigSaver(object):

    def __init__(self, topwidget, filename=None, ignorelist=[]):
        """初期化を行う

        topwidget: トップレベルウインドウ
        filename: 保存するinifile(文字列かpathlib.Path)
        ignorelist: 状態の保存・リストアを行いたくないオブジェクト名文字列のリスト

        ※filenameがNoneなら、メインスクリプトフォルダ内のスクリプト名拡張子をiniにしたファイル
        """
        self._ignore = ignorelist
        if filename is None:
            filename = Path(sys.argv[0]).with_suffix('.ini')
        self.set_inifilename(filename)
        self.load_config()
        self.set_topwidget(topwidget)
        self.restore = self.restore_config  #エイリアス

    def inifilename(self):
        """inifilenameを取得する

        ※値の型はpathlibのPathオブジェクト
        """
        return self._inifilename

    def set_inifilename(self, newname):
        """inifileの名前を設定する

        ※newnameが文字列だった場合はpathlibのPathオブジェクトに変換する
        """
        if isinstance(newname, str):
            newname = Path(newname)
        self._inifilename = newname

    def load_config(self):
        """iniファイルからconfigparserを作る

        ※iniの内容は初期化される
        """
        self.ini = configparser.ConfigParser()
        if self._inifilename.exists():
            self.ini.read(str(self._inifilename))

    def save_config(self):
        """データをiniファイルに保存する"""

        with self._inifilename.open('w') as f:
            self.ini.write(f)

    def topwidget(self):
        """現在のtopwidgetを返す"""

        return self._wig

    def set_topwidget(self, topwidget, ignorelist=None):
        """topwidgetを設定して初期化する

        topwidget: 新たなtopwidget
        ignorelist: 新たな無視リスト

        ※QMainWindowクラスかQDialogクラスでないとTypeError
        ※無視リストがNoneの場合は、現在のリストが使われる
        """
        if ignorelist is not None:
            self._ignore = ignorelist
        if isinstance(topwidget, (Qw.QMainWindow, Qw.QDialog)):
            self._wig = topwidget
            self._wigname = self._wig.__class__.__name__
        else:
            raise TypeError

    def _iter_qtwidgets(self, parent=None):
        """Qtの子ウィジェットを再帰的に探索して返すジェネレータ

        戻り値は見つかったウィジェットのインスタンスとその名前のタプル

        ※objectNameがあるのでインスタンスを特定できる
        """
        if parent is None:
            parent = self._wig
            yield parent, parent.objectName()

        if isinstance(parent, Qw.QAbstractSpinBox):    #SpinBoxを構成するLineEditを見ないようにする
            return

        for child in parent.children():
            cname = child.objectName()
            #名前が無視リストにあると孫子に渡って無視される
            if cname in self._ignore:
                continue
            yield child, cname
            if isinstance(child, Qw.QWidget):
                yield from self._iter_qtwidgets(child)

    def restore_config(self, topwidget=None):
        """configparserの設定をウィジェットにセットする"""

        if topwidget is not None:
            self.set_topwidget(topwidget)

        if not self._wigname in self.ini.keys():
            return
        i = self.ini[self._wigname]

        for wig, wn in self._iter_qtwidgets():
            try:
                #メインウインドウ
                if isinstance(wig, (Qw.QMainWindow)):
                    wig.restoreGeometry(eval(i[wn+'.geometory']))
                    wig.restoreState(eval(i[wn+'.state']))

                #ダイアログ
                elif isinstance(wig, (Qw.QDialog)):
                    wig.restoreGeometry(eval(i[wn+'.geometory']))

                #チェックボックス
                elif isinstance(wig, Qw.QCheckBox):
                    wig.setCheckState(eval(i[wn+'.checked']))

                #一般のチェック可能なオブジェクト
                elif isinstance(wig, (Qw.QAbstractButton, Qw.QAction)):
                    if wig.isCheckable():
                        wig.setChecked(eval(i[wn+'.checked']))

                #QSpinBox
                elif isinstance(wig, (Qw.QSpinBox, Qw.QDoubleSpinBox)):
                    wig.setValue(eval(i[wn+'.value']))

                #QComboBox
                elif isinstance(wig, Qw.QComboBox):
                    wig.setCurrentText(i[wn+'.currenttext'])

                #QListWidget
                elif isinstance(wig, Qw.QListWidget):
                    items = eval(i[wn+'.items'])
                    for itext, istate, iselected, flags in items:
                        finded = wig.findItems(itext, Qt.MatchFixedString)
                        if len(finded)>0:
                            if flags & Qt.ItemIsUserCheckable:
                                finded[0].setCheckState(istate)
                            if flags & Qt.ItemIsSelectable:
                                finded[0].setSelected(iselected)
                    finded = wig.findItems(
                            i[wn+'.currentitem'], Qt.MatchFixedString)
                    if len(finded)>0:
                        r = wig.row(finded[0])
                        wig.setCurrentRow(r)

                #QLineEdit
                elif isinstance(wig, Qw.QLineEdit):
                    wig.setText(i[wn+'.text'])

                #QSlider,Dial
                elif isinstance(wig, (Qw.QDial, Qw.QSlider)):
                    wig.setValue(eval(i[wn+'.value']))

                #QSplitter
                elif isinstance(wig, (Qw.QSplitter)):
                    wig.restoreState(eval(i[wn+'.state']))

                #QTreeView, QTableView
                elif isinstance(wig, (Qw.QTreeView, Qw.QTableView)):
                    cw = eval(i[wn+'.colmnwidth'])
                    for n in range(len(cw)):
                        wig.setColumnWidth(n, cw[n])

            except KeyError:
                pass
            except:
                raise

    def store_config(self, topwidget=None):
        """ウィジェットの情報をconfigparserに取得する

        ※ここではまだセーブはしない
        """
        if topwidget is not None:
            self.set_topwidget(topwidget)

        if not self._wigname in self.ini.keys():
            self.ini[self._wigname] = {}
        i = self.ini[self._wigname]

        for wig, wn in self._iter_qtwidgets():
            #メインウインドウ
            if isinstance(wig, (Qw.QMainWindow)):
                #ウインドウの位置や最大最小などの状態をまとめて保存
                i[wn+'.geometory'] = str(wig.saveGeometry())
                #DockWindowの状態をまとめて保存(DockWindowの方は何もしなくていい)
                i[wn+'.state'] = str(wig.saveState())

            #ダイアログ等
            elif isinstance(wig, (Qw.QDialog)):
                i[wn+'.geometory'] = str(wig.saveGeometry())
                i[wn+'.visible'] = wig.isVisible()

            #チェックボックス
            elif isinstance(wig, Qw.QCheckBox ):
                i[wn+'.checked'] = str(wig.checkState())    #状態が複数ある

            #一般のチェック可能なオブジェクト
            elif isinstance(wig, (Qw.QAbstractButton, Qw.QAction)):
                if wig.isCheckable():
                    i[wn+'.checked'] = str(wig.isChecked()) #状態はbool

            #QSpinBox
            elif isinstance(wig, (Qw.QSpinBox, Qw.QDoubleSpinBox)):
                i[wn+'.value'] = str(wig.value())

            #QComboBox
            elif isinstance(wig, Qw.QComboBox):
                i[wn+'.currenttext'] = wig.currentText()

            #QListWidget
            elif isinstance(wig, Qw.QListWidget):
                ci = wig.currentItem()
                if ci is not None:
                    i[wn+'.currentitem'] = ci.text()
                else:
                    i[wn+'.currentitem'] = ''
                #QListWidgetItemの状態を取得する
                v = []
                for n in range(wig.count()):
                    w = wig.item(n)
                    v.append((
                        w.text(),
                        w.checkState(),
                        w.isSelected(),
                        int(w.flags())
                    ))
                i[wn+'.items'] = str(v)

            #QLineEdit
            elif isinstance(wig, Qw.QLineEdit):
                i[wn+'.text'] = wig.text()

            #QSlider, Dial
            elif isinstance(wig, (Qw.QDial, Qw.QSlider)):
                i[wn+'.value'] = str(wig.value())

            #QSplitter
            elif isinstance(wig, (Qw.QSplitter)):
                i[wn+'.state'] = str(wig.saveState())

            #QTreeView, QTableView
            elif isinstance(wig, (Qw.QTreeView, Qw.QTableView)):
                columncount = wig.model().columnCount()
                cw = []
                for cn in range(columncount):
                    cw.append(wig.columnWidth(cn))
                i[wn+'.colmnwidth'] = str(cw)

    def save(self):
        """データ取得とセーブをまとめて行う

        ※メインウインドウ一個だけの場合など
        """
        self.store_config()
        self.save_config()

configsaverの使い方

 これの使い方ですが、基本は―――


#開始時
def __init__(self, parent=None):
    ...
    self.wsetting = TConfigSaver(self)
    self.wsetting.restore()

#終了時
def closeEvent(self, event):
    ...
        self.wsetting.save()

―――このように書くだけです。


上記の例ならself.wsetting.iniという変数がPythonのconfigparserなので、その他の自前の初期化情報を入れたければそこにSectionを作って好きに入れてください。


 またアプリが複数のウインドウで構成される場合なら以下のような感じになるでしょうか。

#開始時
def __init__(self, parent=None):
    wsetting = TConfigSaver(self,...)
    wsetting.restore_config(self)
    wsetting.restore_config(dialog1)
    ...
    #その他のセクションの処理

#終了時
def closeEvent(self, event):
    #その他のセクションの処理
    ...
    wsetting.store_config(dialog1)
    wsetting.store_config(self)
    wsetting.save_config()

 多分PyQtじゃなければ間違いなくやる気にならなかったと思いますね。


ということで……

 こんな感じでレイアウト編を終わりたいと思ったんですが、やっているうちにもう一つ結構大変なことがでてきました。それはいわゆるモデルとビューに関することで、次回はそれについて書こうと思います。

2017-04-09