はじめに
本記事はプロもくチャット Adevent Calendar2023の8日目の記事です。
普段Pythonでツールを作っているとGUIも作りたくなることがあり、調べてみました。
PythonでGUIを作るための仕組みはいろいろとありますが、今回はPySide6を使用します。
動作確認環境
バージョン | |
---|---|
OS | Windows10 22H2 |
Python | 3.10.10 |
PySide6 | 6.6.0 |
何を作るか
テキストの入力と出力、ボタンクリックを実装したプログラムを作ります。
今回はサンプルプログラムとして、数値を入力して[加算]ボタンを押すと加算結果を表示するプログラムを作ります。
サンプルコード全体はgithubに公開しています。
どう作るか
今回実現したいこと
-
GUIの実装はボタンやテキストをドラッグ&ドロップで配置したい。
-
内部の処理はPythonで書きたい。
-
GUIと内部処理は分離したい。
Viewの部分は今回使うPySide6に付属するツール(Qt Designer)から生成します。
このViewの中に内部処理を実装すると、Viewを作り直すたびに処理が消し飛んでしまうため、Viewをツールで生成する部分と、内部処理と連携する部分を分離して実装します。
内部処理の部分もModel、Controllerに分けて、MVCモデル風に実装します。
クラス図
全体像はこんな感じです。
PySide6について
PySide6は、GUI開発フレームワークのQtを、Pythonで使用できるようにしたパッケージです。
Qt自体はC++で開発されています。
QtをPython向けに使えるようにしたパッケージにはPySide6のほかに、PyQt6というものもあります。
ライセンス
- オープンソースライセンス(LGPLv3 / GPLv2)
- 商用ライセンス
法的な判断はできないため、ライセンスについては各々ご判断ください。
シグナルとスロット
Qt特有の仕組みに、シグナルとスロットというものがあります。
これらを使うことで、ボタンのクリックのような動的なイベントをトリガーに、何かしらの処理を紐づけて実行できます。
参考:
シグナル
シグナルは、テキストボックスの入力やボタンのクリックのように、何かが発生したことを他のオブジェクトに通知するための仕組みです。
通知とともに型を指定して値を渡すこともできます。
例えば、QLineEdit(テキストボックス)の持つtextChagnedシグナルは、QLineEditの文字が更新されたときに発行され、変更後の文字も渡されます。
Ctrl+C
を実行したとき等に使われるシグナルとは別者です。
スロット
スロットは、シグナルに紐づけて何かしらの処理を実行させることができます。
スロットで定義した関数の引数に、シグナルから渡された値を渡すことができます。
シグナルとスロットの連携
実際に見た方が分かりやすいと思うので実装例です。
Python上でPySide6を使ってシグナルとスロットを連携させる場合、以下のようにします。
# シグナルを定義
value_changed = Signal(float)
# スロットを定義
@Slot
def print_value(value):
# 何かしら処理
print(value)
# シグナルをスロットに接続
value_changed.connect(print_value)
例えば、プロパティのセッターにシグナルの発行を紐づけできます。
@property
def value(self):
return self._value
@value.setter
def value(self, value):
self._value = value
# シグナルを発行
value_changed.emit(value)
モデル/ビューアーキテクチャ
QtはMVC系アーキテクチャとして、モデル/ビューアーキテクチャが実装されています。
本記事では詳細は省略します。
こちらが参考になりました。
今回は以下のような構成で組んでみました。
ModelからViewへの流れは一方向で、Viewの操作をModelへ反映する場合は、Delegateを仲介します。
Deligateの部分をControllerとみなしています。
手順1:PySide6のインストール
ここからは実際に実装するための手順です。
以下を実行してPySide6をインストールします。
python -m pip install PySide6
PySide6をインストールすると、以下のフォルダ(Python 3.10の場合)にツール一式もインストールされます。
C:\Users\ユーザ名\AppData\Local\Programs\Python\Python310\Scripts
今回はこの中の以下のツールを使います。
-
pyside6-designer.exe
Qt Designer。ドラッグ&ドロップでGUIを作成できるツール。
xml形式で.uiファイルに保存されます。 -
pyside6-uic.exe
Qt Designerで作った.uiファイルを、.pyファイルへと変換するためのツール。
手順2:GUIの作成
ウィジェットの配置
pyside6-designer.exeを実行します。
起動すると、こんな感じの見た目のQt Designerが立ち上がります。
新しいフォームウィンドウが表示されるため、Main Windowを選択して作成します。
MainWindowが生成されます。
画面の左右のウィンドウからウィジェットを配置したり、プロパティを編集したりして、画面を作っていきます。
- ウィジェットボックス
画面に配置するボタンやテキストボックスなどの要素をウィジェットといいます。
このウィジェットボックスから配置したいウィジェットをドラッグ&ドロップで配置することができます。
- オブジェクトインスペクタ
配置したオブジェクトの階層構造を確認することができます。
オブジェクトを選択したり、オブジェクト名を編集したりするときに使います。
- プロパティエディタ
ウィジェットのプロパティを編集できます。
ウィジェットごとにプロパティは異なります。
今回のサンプルプログラムでは、このような画面にしました。
編集したウィジェット
-
MainWindow
サイズを300x200に設定 -
Vertical Layout、Horizontal Layout
ウィジェットを整列 -
Label
加算結果や+ =の表示 -
TextEdit
足し合わせる数値の入力フォーム -
PushButton
加算を実行するボタン
配置が完了したら、Addition.uiとして保存します。
Pythonファイルへの変換
pyside6-uic.exeを使って.uiファイルを.pyファイルへと変換します。
Addition.uiを保存したフォルダに移動し、
C:\Users\ユーザ名\AppData\Local\Programs\Python\Python310\Scripts\pyside6-uic.exe Addition.ui -o Ui_Addition.py
を実行して変換します。
変換したファイルの中身は後ほど。
手順3:Main実装
ここからはPythonでプログラムを書いていきます。
以下のような構造で配置します。
Ui_Addition.pyは実行時には不要ですが、置いたままにしています。
PySide6Sample
├─controllers
│ └─controller.py
├─models
│ └─model.py
├─views
│ ├─Addition.ui
│ ├─Ui_Addition.py
│ └─view.py
└─app.py
まずはapp.pyのMainから。
MainではModelとViewとControllerを生成し、描画処理を起動します。
import sys
from PySide6.QtWidgets import QApplication
from models.model import Model
from controllers.controller import Controller
from views.view import View
class Main(QApplication):
def __init__(self, argv):
super(Main, self).__init__(argv)
self._model = Model()
self._controller = Controller(self._model)
self._view = View(self._model, self._controller)
self._view.show()
if __name__ == "__main__":
app = Main(sys.argv)
sys.exit(app.exec())
手順4:Model実装
Modelでは、プログラム内で使用するデータを保持し、データを使った処理を実装します。
足し算の答えを更新した後に、Viewへと通知する必要があるため、シグナルを定義しています。
from PySide6.QtCore import QObject, Signal
class Model(QObject):
def __init__(self):
super().__init__()
self.left_value = 0
self.right_value = 0
self.answer = 0
# Viewへ加算結果を通知するSignal
answer_changed = Signal(float)
@property
def left_value(self):
return self._left_value
@left_value.setter
def left_value(self, value: float):
self._left_value = value
@property
def right_value(self):
return self._right_value
@right_value.setter
def right_value(self, value: float):
self._right_value = value
@property
def answer(self):
return self._answer
@answer.setter
def answer(self, value: float):
self._answer = value
self.answer_changed.emit(value)
def add(self):
self.answer = self.left_value + self.right_value
手順5:Controller実装
Controllerでは、Viewで入力された値のチェックと、Modelへの値やイベントの受け渡しをしています。
入力値がfloatに変換できない場合、シグナルを発行します。
from PySide6.QtCore import QObject, Signal, Slot
from models.model import Model
class Controller(QObject):
# Viewへ入力値が不正であることを通知するSignal
invalid_left_value = Signal()
invalid_right_value = Signal()
def __init__(self, model :Model):
super().__init__()
self._model = model
def change_left_value(self, value: str):
try:
self._model.left_value = float(value)
except:
self._model.left_value = float(0)
self.invalid_left_value.emit()
def change_right_value(self, value: str):
try:
self._model.right_value = float(value)
except:
self._model.right_value = float(0)
self.invalid_right_value.emit()
# 加算ボタンクリック時に実行
@Slot()
def add(self):
self._model.add()
手順6:View実装
ViewではGUI部分が発行するシグナルや、Model, Controllerが発行するシグナルを紐づけします。
QlineEditのtextChangedシグナルは数値を1桁入力するたびに発行されてしまうため、editingFinishingシグナルを使用しています。
数字でないものが入力されたら、入力をクリアします。
from PySide6.QtWidgets import QMainWindow
from PySide6.QtCore import Slot
from models.model import Model
from controllers.controller import Controller
from views.Ui_Addition import Ui_MainWindow
class View(QMainWindow):
def __init__(self, model :Model, controller :Controller):
super().__init__()
self._model = model
self._controller = controller
self._ui = Ui_MainWindow()
self._ui.setupUi(self)
# UiMainViewのtextEdit編集終了通知Signalを、ViewのSlotに接続
self._ui.lineEdit_left_value.editingFinished.connect(self.change_left_value)
self._ui.lineEdit_right_value.editingFinished.connect(self.change_right_value)
# UiMainViewの加算ボタンクリック通知Signalを、ControllerのSlotに接続
self._ui.button_add.clicked.connect(self._controller.add)
# Modelの加算結果の更新通知Signalを、ViewのSlotに接続
self._model.answer_changed.connect(self.answer_changed)
# Controllerの不正な入力値通知Signalを、ViewのSlotに接続
self._controller.invalid_left_value.connect(self.invalid_left_value)
self._controller.invalid_right_value.connect(self.invalid_left_value)
# Modelの加算結果の更新通知を受信
@Slot(float)
def answer_changed(self, value: float):
self._ui.label_answer.setText(str(value))
# UiのtextEditの編集終了通知を受信
@Slot()
def change_left_value(self):
self._controller.change_left_value(self._ui.lineEdit_left_value.text())
@Slot()
def change_right_value(self):
self._controller.change_right_value(self._ui.lineEdit_right_value.text())
# Controllerの不正な入力値通知を受信
@Slot()
def invalid_left_value(self):
self._ui.lineEdit_left_value.clear()
@Slot()
def invalid_left_value(self):
self._ui.lineEdit_right_value.clear()
参考:Qt Designerから変換したView
最後に、GUIから自動生成したソースです。
自動生成されたヘッダコメントにあるように、このファイルを直接編集すると、GUIを作り直したときに上書きされて消し飛びます。
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'Addition.ui'
##
## Created by: Qt User Interface Compiler version 6.6.0
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QApplication, QHBoxLayout, QLabel, QLineEdit,
QMainWindow, QMenuBar, QPushButton, QSizePolicy,
QStatusBar, QVBoxLayout, QWidget)
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
if not MainWindow.objectName():
MainWindow.setObjectName(u"MainWindow")
MainWindow.resize(300, 200)
self.centralwidget = QWidget(MainWindow)
self.centralwidget.setObjectName(u"centralwidget")
self.verticalLayoutWidget = QWidget(self.centralwidget)
self.verticalLayoutWidget.setObjectName(u"verticalLayoutWidget")
self.verticalLayoutWidget.setGeometry(QRect(40, 30, 160, 80))
self.verticalLayout = QVBoxLayout(self.verticalLayoutWidget)
self.verticalLayout.setObjectName(u"verticalLayout")
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName(u"horizontalLayout")
self.lineEdit_left_value = QLineEdit(self.verticalLayoutWidget)
self.lineEdit_left_value.setObjectName(u"lineEdit_left_value")
self.horizontalLayout.addWidget(self.lineEdit_left_value)
self.label_add = QLabel(self.verticalLayoutWidget)
self.label_add.setObjectName(u"label_add")
self.horizontalLayout.addWidget(self.label_add)
self.lineEdit_right_value = QLineEdit(self.verticalLayoutWidget)
self.lineEdit_right_value.setObjectName(u"lineEdit_right_value")
self.horizontalLayout.addWidget(self.lineEdit_right_value)
self.label_equal = QLabel(self.verticalLayoutWidget)
self.label_equal.setObjectName(u"label_equal")
self.horizontalLayout.addWidget(self.label_equal)
self.label_answer = QLabel(self.verticalLayoutWidget)
self.label_answer.setObjectName(u"label_answer")
self.horizontalLayout.addWidget(self.label_answer)
self.verticalLayout.addLayout(self.horizontalLayout)
self.button_add = QPushButton(self.verticalLayoutWidget)
self.button_add.setObjectName(u"button_add")
self.verticalLayout.addWidget(self.button_add)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(MainWindow)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 300, 22))
MainWindow.setMenuBar(self.menubar)
self.statusbar = QStatusBar(MainWindow)
self.statusbar.setObjectName(u"statusbar")
MainWindow.setStatusBar(self.statusbar)
self.retranslateUi(MainWindow)
QMetaObject.connectSlotsByName(MainWindow)
# setupUi
def retranslateUi(self, MainWindow):
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"MainWindow", None))
self.label_add.setText(QCoreApplication.translate("MainWindow", u"+", None))
self.label_equal.setText(QCoreApplication.translate("MainWindow", u"=", None))
self.label_answer.setText("")
self.button_add.setText(QCoreApplication.translate("MainWindow", u"\u52a0\u7b97", None))
# retranslateUi
おわりに
PySide6を使ったGUIプログラムのひな型でした。
Qtのフレームワーク部分が結構むずかしく、PySideの情報も少なくてなかなか苦労したのでまとめてみました。
GUIを作る仕組みは他にもいろいろあるので、Tkinterだとか、pyscriptを使ってブラウザを画面にするようなものもかじってみたいですね。
参考資料
このstackoverflowの記事がとても参考になりました。