PythonでGUIを作ろう！ PySide編


## はじめに

本記事はプロもくチャット Adevent Calendar2023の8日目の記事です。

https://qiita.com/advent-calendar/2023/puromoku


普段Pythonでツールを作っているとGUIも作りたくなることがあり、調べてみました。

PythonでGUIを作るための仕組みはいろいろとありますが、今回はPySide6を使用します。

### 動作確認環境

|   | バージョン |
|---|---|
| OS | Windows10 22H2 |
| Python | 3.10.10 |
| PySide6 | 6.6.0 |


## 何を作るか

テキストの入力と出力、ボタンクリックを実装したプログラムを作ります。
今回はサンプルプログラムとして、数値を入力して[加算]ボタンを押すと加算結果を表示するプログラムを作ります。

![002_AdditionUi.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359852/cf48941b-d743-5855-a977-ea85d0ee4315.png)

サンプルコード全体は[github](https://github.com/ker38c/PySide6Sample)に公開しています。

## どう作るか

今回実現したいこと

- GUIの実装はボタンやテキストをドラッグ＆ドロップで配置したい。

- 内部の処理はPythonで書きたい。

- GUIと内部処理は分離したい。

Viewの部分は今回使うPySide6に付属するツール(Qt Designer)から生成します。

このViewの中に内部処理を実装すると、Viewを作り直すたびに処理が消し飛んでしまうため、Viewをツールで生成する部分と、内部処理と連携する部分を分離して実装します。

内部処理の部分もModel、Controllerに分けて、MVCモデル風に実装します。

### クラス図

全体像はこんな感じです。

```plantuml
@startuml
skinparam classAttributeIconSize 0

class Main
class Model{
    + left_value
    + right_value
    + answer
    + add()
}
class Controller{
    - _model
}
class View{
    - _model
    - _controller
    - _ui
}
note left{
    Model, Controllerとの
    連携部分を実装
}

class Ui_MainView{
}
note right{
    デザイナーツールで作成
    ソースを編集しない
}

Main --> Model : 生成
Main --> View : 生成
Main --> Controller : 生成

Model <--o Controller
Model <--o View
Controller <--o View
View -> Ui_MainView

@enduml
```

## PySide6について

PySide6は、GUI開発フレームワークのQtを、Pythonで使用できるようにしたパッケージです。
Qt自体はC++で開発されています。

QtをPython向けに使えるようにしたパッケージにはPySide6のほかに、PyQt6というものもあります。

### ライセンス

- オープンソースライセンス（LGPLv3 / GPLv2）
- 商用ライセンス

法的な判断はできないため、ライセンスについては各々ご判断ください。

https://pypi.org/project/PySide6/


### シグナルとスロット

Qt特有の仕組みに、シグナルとスロットというものがあります。
これらを使うことで、ボタンのクリックのような動的なイベントをトリガーに、何かしらの処理を紐づけて実行できます。

参考：

https://www.qt.io/ja-jp/blog/2010/06/17/signals-and-slots

#### シグナル

シグナルは、テキストボックスの入力やボタンのクリックのように、何かが発生したことを他のオブジェクトに通知するための仕組みです。

通知とともに型を指定して値を渡すこともできます。
例えば、QLineEdit（テキストボックス）の持つtextChagnedシグナルは、QLineEditの文字が更新されたときに発行され、変更後の文字も渡されます。

:::note info
```Ctrl＋C```を実行したとき等に使われるシグナルとは別者です。
:::

#### スロット

スロットは、シグナルに紐づけて何かしらの処理を実行させることができます。
スロットで定義した関数の引数に、シグナルから渡された値を渡すことができます。

### シグナルとスロットの連携

実際に見た方が分かりやすいと思うので実装例です。
Python上でPySide6を使ってシグナルとスロットを連携させる場合、以下のようにします。

```python
# シグナルを定義
value_changed = Signal(float)

# スロットを定義
@Slot
def print_value(value):
    # 何かしら処理
    print(value)

# シグナルをスロットに接続
value_changed.connect(print_value)
```

例えば、プロパティのセッターにシグナルの発行を紐づけできます。

```python
@property
def value(self):
    return self._value

@value.setter
def value(self, value):
    self._value = value
    # シグナルを発行
    value_changed.emit(value)
```


### モデル／ビューアーキテクチャ

QtはMVC系アーキテクチャとして、モデル／ビューアーキテクチャが実装されています。
本記事では詳細は省略します。

こちらが参考になりました。

https://qiita.com/argama147/items/79db67c058560909ec1c


今回は以下のような構成で組んでみました。
ModelからViewへの流れは一方向で、Viewの操作をModelへ反映する場合は、Delegateを仲介します。
Deligateの部分をControllerとみなしています。

![002_MVC.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359852/8dd8284f-d431-fc94-e05e-c6b1e3560b2d.png)


## 手順1：PySide6のインストール

ここからは実際に実装するための手順です。

以下を実行してPySide6をインストールします。
```console
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が立ち上がります。

![002_DesignerOutside.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359852/42ae9679-641e-6f92-b109-b1f9e17f1737.png)


新しいフォームウィンドウが表示されるため、Main Windowを選択して作成します。

![002_NewForm.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359852/cc49040b-8163-665f-cf3d-2d64fc1e1303.png)


MainWindowが生成されます。

画面の左右のウィンドウからウィジェットを配置したり、プロパティを編集したりして、画面を作っていきます。

![002_DesignerNewWindow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359852/78c58c5d-91a6-bebd-23f1-7542c523833f.png)

- ウィジェットボックス

画面に配置するボタンやテキストボックスなどの要素をウィジェットといいます。
このウィジェットボックスから配置したいウィジェットをドラッグ＆ドロップで配置することができます。

- オブジェクトインスペクタ

配置したオブジェクトの階層構造を確認することができます。
オブジェクトを選択したり、オブジェクト名を編集したりするときに使います。

- プロパティエディタ

ウィジェットのプロパティを編集できます。
ウィジェットごとにプロパティは異なります。


今回のサンプルプログラムでは、このような画面にしました。

![002_DesignerAddition.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359852/f6d70ae3-c6b4-99f7-faad-dfb3a2fff5ad.png)

編集したウィジェット

- MainWindow
サイズを300x200に設定

- Vertical Layout、Horizontal Layout
ウィジェットを整列

- Label
加算結果や+ =の表示

- TextEdit
足し合わせる数値の入力フォーム

- PushButton
加算を実行するボタン

配置が完了したら、Addition.uiとして保存します。

### Pythonファイルへの変換

pyside6-uic.exeを使って.uiファイルを.pyファイルへと変換します。

Addition.uiを保存したフォルダに移動し、

```console
C:\Users\ユーザ名\AppData\Local\Programs\Python\Python310\Scripts\pyside6-uic.exe Addition.ui -o Ui_Addition.py
```
を実行して変換します。
変換したファイルの中身は後ほど。


## 手順3：Main実装

ここからはPythonでプログラムを書いていきます。

以下のような構造で配置します。
Ui_Addition.pyは実行時には不要ですが、置いたままにしています。

```console
PySide6Sample
├─controllers
│  └─controller.py
├─models
│  └─model.py
├─views
│  ├─Addition.ui
│  ├─Ui_Addition.py
│  └─view.py
└─app.py

```

まずはapp.pyのMainから。
MainではModelとViewとControllerを生成し、描画処理を起動します。


```python:app.py
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へと通知する必要があるため、シグナルを定義しています。

```python:model.py
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に変換できない場合、シグナルを発行します。

```python:controller.py
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シグナルを使用しています。

数字でないものが入力されたら、入力をクリアします。

```python:view.py
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を作り直したときに上書きされて消し飛びます。

```python:Ui_Addition.py
# -*- 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の記事がとても参考になりました。

https://stackoverflow.com/questions/26698628/mvc-design-with-qt-designer-and-pyqt-pyside

https://www.qt.io/ja-jp/blog/2010/06/17/signals-and-slots

https://qiita.com/argama147/items/79db67c058560909ec1c

https://qiita.com/karakuri-t910/items/9d418a4edab081990243