Thor's Columns
PyQtでお手軽GUI開発♪―――は可能だったか? 第9回 その他の小ネタ編

PyQtでお手軽GUI開発♪―――は可能だったか? 第9回 その他の小ネタ編

 さて今回はQtDesignerなどでは設定できない細々した要素としてコンテキストメニューステータスバーダイアログについて、またその他にもリソースの使い方やあとuiの自動コンパイルモジュールとかなどを、わりととりとめなくお送りしていきます。

コンテキストメニュー

 GUIを作る上で以下のようなウィジェットのコンテキストメニュー―――右クリックなど出てくるポップアップメニューというのは、あれば非常に重宝する要素です。



 これの出し方を管理するQWidgetのプロパティが、contextMenuPolicyで、その設定値は以下のような意味を持っています。

NoContextMenuコンテキストメニューを出さない。
DefaultContextMenuデフォルトのコンテキストメニューを出す(デフォルト)
ActionContextMenuウィジェットに設定されたコンテキストメニューを出す。
CustomContextMenu独自のコンテキストメニューを出す。
PreventContextMenuコンテキストメニューを絶対出さない。

 通常はDefaultContextMenuが設定されていますが、デフォルトコンテキストメニューとは、例えばLineEditなどで勝手に出てくる“切り取り”、“コピー”、“貼りつけ”みたいなものです。そういったメニューのないウィジェットでは何も出ません。


 NoContextMenuはその名の通りそのウィジェットにはコンテキストメニューがないことを示しますが、その場合でも親ウィジェットにコンテキストメニューがあればそれが出てきます。

 もしそういったメニューを一切出したくないという場合に最後のPreventContextMenuを設定します。すなわちこれを選べばマウスの右ボタンイベントはmousePressEvent()およびmouseReleaseEvent()に渡されることが保証されます。


 ユーザーが独自のコンテキストメニューを出したい場合は以下のActionContextMenuCustomContextMenuのいずれかを使います。


◆ActionContextMenu

 QWidgetにはaddAction, addActions, insertAction, insertActions, removeActionといった関数が用意されていて、これを使うことでWidgetにアクションを登録することができます。ActionContextMenuのモードではここで登録されたアクションのメニューが表示されます。

 これは自前のコンテキストメニューを出す一番シンプルな方法です。

◆CustomContextMenu

 こちらはもうちょっと手間のかかる方法です。

 QWidgetにはcustomContextMenuRequested(QPoint)というシグナルが定義されています。このシグナルはコンテキストメニューが呼び出されるタイミングで発行されます。

 そこで例えば以下のようにこのシグナルに対するスロットを作ってやって、その中でQMenuを作って表示させてやればよいわけです。

def execContextmenu(self, event):
    """コンテキストメニューを出す"""

    menu = Qw.QMenu()                                   #メニューを作る
    menu.addAction(self.ui.action_Draw)                 #アクションの登録
    menu.addAction(self.ui.actionColorchange)
    menu.addSeparator()                                 #セパレータ
    menu.addAction(self.ui.actionViewback)
    menu.addAction(self.ui.actionViewfoward)
    menu.exec_(self.ui.PaintBox.mapToGlobal(event))     #表示(eventはローカル座標なので変換する)

 この場合ActionContextMenuより二手間ほどかかりますが、例えばセパレータが出せるなどメニューデザインの自由度が上がります。また呼ばれたときの状況に応じて項目を変えたいような場合も、こちらでないとできません。


 また細かい話になりますが、Qtの場合コンテキストメニューは右クリックのmouseReleaseのタイミングで表示されるので、そこから項目を選んでもう一度マウスクリックという2ステップの操作になります。

 しかしWindowsアプリの場合通常は右クリックのmousePressのタイミングで表示されるので、ボタンを押したままマウスカーソルを項目まで移動してリリースすれば一動作でその項目が選択できます(2017/05/19追記:確かめて見たら必ずしもそうではないようです。エクスプローラでもそうなってないし、昔はそうだった気がするんですが……)

 この手の細かい動作は結構体に染みついているもので、そういう部分が違うと結構ストレスになったりしますが、そう修正しようとすればmousePressEvent()にひっかけてどのボタンが押されたかなどをチェックしないとだめでしょう。そんなときに自前でメニューを作る方法を知っていると役に立ちます。


ステータスバー

 QtDesignerでMainWindowを作ると、メニューとこのステータスバーがもれなくおまけでついてきます。

 Windowsの場合、ステータスバーというのは適当な幅を持った表示領域が並んでいるようなものですが、Qtの場合はそれとはちょっと違っていて、一時的なメッセージの表示エリアという概念です。

 従って基本の使い方は、ウィジェットのstatusTipに設定されたテキストを表示させるか、showMessage関数でメッセージを表示することです。


 またWindowsの場合はそのエリアにテキストを設定したらずっと表示され続けますが、Qtのステータスバーの場合は放っておいたら適当なタイミングで消えてしまいます(showMessageのパラメータに消えるまでの時間が設定できるくらいです)

 そのためそこにずっとメッセージを表示させたい場合は、addWidget関数addPermanentWidget関数でステータスバー上にラベルなどの適当なウィジェットを配置します。


 二種類の関数の違いはメッセージの位置と、一時的なメッセージで覆い隠されるかどうかです。

 addWidget を使うとメッセージはステータスバーの左に表示され、一時的メッセージがあればそれで覆い隠されてしまいます。しかしaddPermanentWidgetを使うとステータスバーの右側に表示されて、一時的メッセージで覆い隠されることがありません。




 ステータスバーに配置するウィジェットは、QtDesignerであらかじめ作っておくこともできます。

 ただしデザイナーでステータスバー上に設置することはできないので、例えば以下のように適当な場所に置いてフォントなどを設定しておいて、



__init__のなかで以下のように書いてやれば、実行時には前述のようになります。

self.ui.statusbar.addPermanentWidget(self.ui.label_2)
self.ui.statusbar.addWidget(self.ui.label_3)

ダイアログボックス

 GUIを作る上では各種ダイアログボックスというのは必須の要素です。

 Qtの場合そういったダイアログボックスはQDialogのサブクラスとして様々な種類が定義されています。ここではその中で使う頻度の高そうなQMessageBoxQInputDialogQFileDialogに関して簡単に説明します。


 このようないわゆるコモンダイアログを使う場合、ダイアログクラスのインスタンスを作ってexec()することもできますが、Qtでは基本的に一発で呼び出せる関数が揃っているのでそれを使うのが楽でしょう。


◆MessageBox

 一般的な各種のメッセージダイアログです。各関数は以下のように基本的にparent(これは普通selfでOK)タイトルテキスト、メッセージテキストをパラメータに取って呼び出すだけです。パラメータの詳細に関してはQMessageBoxのStatic Public Membersを参照してください。

結果の型メッセージボックス関数
voidabout(parent, title, text)
voidaboutQt(parent)
StandardButtoncritical(parent, title, text, buttons=Ok, defaultButton=NoButton)
StandardButtoninformation(parent, title, text, buttons=Ok, defaultButton=NoButton)
StandardButtonquestion(parent, title, text, buttons=StandardButtons(Yes|No), defaultButton=NoButton)
StandardButtonwarning(parent, title, text, buttons=Ok, defaultButton=NoButton)

 呼び出したら例えば以下のようになります。

Qw.QMessageBox.warning(self,'もしかすると','これは大変な状況だと思われます')


◆InputDialog

 以下は各種入力用のダイアログです。パラメータがこれもたくさんありますが、基本的にはparentにタイトルとラベル、デフォルト値などを設定して呼び出すだけです。パラメータの詳細に関しては同じくQInputDialogのStatic Public Membersを参照してください。

結果の型インプットダイアログ関数
(double,bool)getDouble(parent, title, label, value=0, min=-2147483647, max=2147483647, decimals=1, flags=Qt.WindowFlags())
(int,bool)getInt(parent, title, label, value=0, min=-2147483647, max=2147483647, step=1, flags=Qt.WindowFlags())
(QString,bool)getItem(parent, title, label, items, current=0, editable=true, flags=Qt.WindowFlags(), inputMethodHints=Qt.ImhNone)
(QString,bool)getMultiLineText(parent, title, label, text=QString(), flags=Qt.WindowFlags(), inputMethodHints=Qt.ImhNone)
(QString,bool)getText(parent, title, label, mode=QLineEdit.Normal, text=QString(), flags=Qt.WindowFlags(), inputMethodHints=Qt.ImhNone)

 なお本来のQtの場合上記のパラメータには“bool *ok = Q_NULLPTR”というOK/Cancelボタンの選択結果を返す(らしい)参照引数があるのですが、PyQtの場合はその引数はなく、ボタン選択結果が入力結果のデータと共にタプルで戻ってくるという、よりエレガントな仕組みになっています。従ってPyQtではこれらの関数は以下のように利用することになります。

value, isOK = Qw.QInputDialog.getItem(
    self, 
    '何かを',
    '選択すべき状況です', 
    ['Type-A','Type-B','Type-C']
)
if isOK:
    #valueをあれこれ処理する


◆FileDialog

 ファイル入出力用のダイアログには以下のような簡易呼び出し関数が用意されています。以下の関数はWindowsとMacOSではQtのQFileDialogインスタンスではなく、OSネイティブなダイアログが呼ばれます。

結果の型ファイルダイアログ関数
QStringgetExistingDirectory(parent=Q_NULLPTR, caption=QString(), dir=QString(), options=ShowDirsOnly)
QUrlgetExistingDirectoryUrl(parent=Q_NULLPTR, caption=QString(), dir=QUrl(), options=ShowDirsOnly, supportedSchemes=QStringList())
(QString,str?)getOpenFileName(parent=Q_NULLPTR, caption=QString(), dir=QString(), filter=QString(), initialFilter=Q_NULLPTR, options=Options())
(QStringList,str?)getOpenFileNames(parent=Q_NULLPTR, caption=QString(), dir=QString(), filter=QString(), initialFilter=Q_NULLPTR, options=Options())
(QUrl,str?)getOpenFileUrl(parent=Q_NULLPTR, caption=QString(), dir=QUrl(), filter=QString(), initialFilter=Q_NULLPTR, options=Options(), supportedSchemes=QStringList())
(QList<QUrl>,str?)getOpenFileUrls(parent=Q_NULLPTR, caption=QString(), dir=QUrl(), filter=QString(), initialFilter=Q_NULLPTR, options=Options(), supportedSchemes=QStringList())
(QString,str?)getSaveFileName(parent=Q_NULLPTR, caption=QString(), dir=QString(), filter=QString(), initialFilter=Q_NULLPTR, options=Options())
(QUrl,str?)getSaveFileUrl(parent=Q_NULLPTR, caption=QString(), dir=QUrl(), filter=QString(), initialFilter=Q_NULLPTR, options=Options(), supportedSchemes=QStringList())

 パラメータは見れば大体分かると思いますが、filterは例えば以下のように、複数の項目があった場合は;;で区切って記述します。

"Images (*.png *.xpm *.jpg);;Text files (*.txt);;XML files (*.xml)"

 あとinitialFilterとはデフォルトで選択されているフィルターですが、Qtのドキュメントの方ではselectedFilterという名になっています。これは例えば上記の例なら

initialFilter = "Text files (*.txt)"

 と書いておけばデフォルトでtxtでフィルタリングされた画面になります。


 またgetOpenFileName以降の関数は結果として、得られたファイルやディレクトリ名と何かもう一つの文字列らしき物のタプルが戻って来ます。こちらはボタンを押した結果に関わらず必ず''になっていて詳細不明なのですが、ファイルが選択された結果は例えば最初のパラメータがヌル文字列ではないかどうかといったチェックをすればいいようです。

fn, dmy = Qw.QFileDialog.getOpenFileName(
    filter = 'Png Files (*.png)',
    directory = str(self.savedir)
)
if fn != '':
	...

リソース

 GUIを作っているとやはりアイコンなどのリソースが使いたくなります。

 そういった場合はQtDesignerのリソースエディタで作成できますが、その基本的な手順は以下のようになります。

  1. あらかじめリソースにしたいデータを、プロジェクトフォルダかそのサブフォルダにコピーしておきます(リソースを読み込むとき、プロジェクトの外への参照を作ると色々うるさく言われるので、こうしておくのがいいです)
  2. リソースブラウザを開いて、リソース編集を選択します。


  3. 新しいリソースファイルを選択して、リソースファイルを作ります(ここでは“hello.qrc”という名にします)


  4. プレフィックスを追加を選択します。デフォルトではnewPrefixとなっているので、適当に命名してください(ここでは“icon”という名ににします)


  5. できたプレフィックスにファイルを追加します(ここではrating.icoを追加します)


  6. ファイルが追加されたらOKを押します。


  7. するとこのようにリソースができあがります。リソースファイルの中身はリソースへの参照が書かれているxmlファイルです。


  8. しかしこれをPyQtで使うためにはさらにコマンドラインから下記のコマンドでコンパイルする必要があります。Pythonのリソースファイル名は以下のように元のリソースファイル名に_rcをつけて拡張子をpyにしたものにしておきます。
pyrcc5 -o hello_rc.py hello.qrc
  1. するとバイナリーデータを抱え持ったpyファイルができて初期化もされるので、PyQtからそのリソースが使えるようになります。

 PyQtのプログラムからリソースを使うには :// から始まるurlみたいな型式でデータを指定すればOKです。

 上記サンプルのアイコンの場合、例えば以下のように単なるファイルを扱うのと同じ手順で画面に表示できます。

def paintEvent(self, event):

    img = Qg.QImage('://icon/rating.ico')
    canvas = Qg.QPainter(self)
    canvas.drawImage(10, 30, img)



UI自動コンバータ

 ところでこれまではQtDesignerで作成したuiファイルは、コマンドプロンプト上でpyuic5を使ってコンバートしてから今度はSpyderで実行するという建前になっていました。

 しかしやっていると分かりますが、画面の修正をちょこちょこやっているとこの流れは結構鬱陶しかったりします。Delphiなどの統合環境ではフォームを修正して実行すれば変更は自動的に反映されるのですが、QtDesigner-PyQt環境でそこまでは望めません。

 しかしQtDesignerでセーブして実行すれば自動的に変更が反映するところまでなら可能です。


 uiをコンバートするpyuic5というツールと同じ機能がPyQt5のuicというモジュールにあって、それのcompileUiという関数でスクリプト上からコンパイルできます。そこでカレントのフォルダ内のuiファイルと対応するpyファイルのタイムスタンプを比較してpyが古いか存在していなければコンパイルするモジュールを作ってみました。それをそのまま実行してもいいし、if __name__ == '__main__':がないのでインポートしておけば自動実行されます。


 またリソースのコンパイルも同様に自動的にできるようにしてみましたが、こちらの方はPythonのモジュールが用意されていないため結構強引な方法になっています。

 ただしこちらの場合、新しいリソースが追加されたような場合はリソースファイルが更新されるために自動コンパイルできますが、既存のリソースのデータだけが更新されたな場合は下記のロジックでは検出できません。多分そんなにリソースをちまちまアップデートするようなことはないと思いますので、そのような場合は*_rc.pyファイルを消すなりしてください。

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

QtDesignerで生成されたuiファイルやリソース(qrc)ファイルをpyにコンバートする

※使い方
- スクリプトをカレントに置いてそのまま実行する。
- またはモジュールとしてuiやqrcなどが読まれる前にこれをインポートしておくと自動実行される
"""
import sys
import os
from pathlib import Path
from PyQt5 import uic

def ui2py():
    """uiファイルをpyにコンバートする

    カレントフォルダ内のuiのTimeが新しいか、対応するpyが存在しない場合のみ実行
    コンバートしたモジュール名のlistが戻る
    ※だがそれを利用してモジュールをリロードするのはあまりお勧めでないようだ。
    """
    Result = []
    p = Path(sys.argv[0]).parent
    for fnui in p.glob('*.ui'):
        fnpy = fnui.with_suffix('.py')
        if not fnpy.exists() or (fnui.stat().st_mtime > fnpy.stat().st_mtime):
            #明示的にutf-8でファイルを開かないとコンパイルエラーになる
            with fnui.open(encoding='utf-8') as fui:
                with fnpy.open(mode='w', encoding='utf-8') as fpy:
                    uic.compileUi(fui, fpy)
                    print(fnpy.name+'を生成しました。')
                    Result.append(fnpy.stem)
    return Result

def qrc2py():
    """リソースファイルをコンバートする

    カレントフォルダ内のqrcのTimeが新しいか、対応するpyが存在しない場合のみ実行
    ※qrcにはuicのようなモジュールがないみたいなのでコマンドラインで実行する
    """
    #環境によってpyrcc5.exeの場所が変わるので、Pathを見て検索する
    def rccexe():
        for _path in os.environ['Path'].split(';'):
            r = Path(_path) / 'pyrcc5.exe'
            if r.exists():
                return str(r)
        print('pyrcc5.exeが見つかりませんでした')
        raise Exception

    p = Path(sys.argv[0]).parent
    for fnqrc in p.glob('*.qrc'):
        fnpy = p / (fnqrc.stem + '_rc.py') #カッコがないとエラーになる
        if not fnpy.exists() or (fnqrc.stat().st_mtime > fnpy.stat().st_mtime):
            os.system('{} -o {} {}'.format(rccexe(), fnpy, fnqrc))
            print(fnpy.name+'を生成しました。')

#インポートした場合も初期化時に実行される
ui2py()
qrc2py()

というわけで……

 大体こんな感じでUIに関しては終わろうかと思っていますが、いくら立派なUIができても、そもそもの基本性能がダメでは使い物になりません。Pythonの場合は便利な関数やモジュールはたくさん用意されていますが、如何せんこれはインタープリタ言語です。特にその速度が遅いことに関してはわりと悪名も轟いているようです。

 ということで次回以降、そのへんも含めてマンデルブロ集合描画がどの程度まで高速化できるかというわりと無謀なテーマに挑戦してみる予定です。

2017-04-18