PythonでGUI : PyQt5のご紹介

# なぜPythonでGUIなのか？

---

## なぜ Ruby, JS, C# ではなくPythonなのか？

1. Python言語自体が書きやすい (vs JS)
2. WindowsでもMacでも動作する (vs Ruby, C#）
3. データ分析や画像処理などのライブラリが充実している
4. ctypesやCythonでC/C++のコードを呼び出せる

---

## Pythonの主なGUIライブラリ

 - tkinter
   - 標準添付
 - turtle
   - 標準添付・カメさんがカワイイ
 - Kivy
   - 新しい
 - **PyQt5**
   - **今日のオススメ**

---

# PyQt5のご紹介

---

## QtはOSSのアプリケーションフレームワーク

 - 1992〜 (現在 v5.8)
 - **C++** 製
 - **クロスプラットフォーム**(OSネイティブのスタイルで描画)
 - **豊富なウィジェット** [_](http://doc.qt.io/qt-5/widget-classes.html#basic-widget-classes)
 - **使いやすいAPI**（シグナル、レイアウト、MV）
 - **ツール** (IDE, UIデザイナ)

---

## PyQt5 は Qt5.x のラッパー

SIP というツールでC++ <=> Pythonの部分を生成している。

 - Qtのほぼ全ての機能を使える
 - Qtの使いやすいAPIはそのまま
 - よりPythonicな書き方
   - 文字列や配列はPythonの型に変換
   - メソッドに`lambda`やメソッドを渡せる

---

# PyQt５の例

---
## 標準のウィジェットを配置

スクロールバーの現在値を文字列で表示

```python
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import sys

class Sample1(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent=parent)

        s = QScrollBar(orientation=Qt.Horizontal)
        t = QLabel()

        s.valueChanged.connect(lambda v: t.setText(str(v)))

        l = QVBoxLayout()
        l.addWidget(s)
        l.addWidget(t)
        self.setLayout(l)

app = QApplication(sys.argv)
s = Sample1()
s.show()
sys.exit(app.exec_())
```

---

## 例1

---

## レイアウト

 - .Net などと大体同じ
 - ウィンドウサイズが変わっても、適当に伸縮
 - QHBoxLayout, QVBoxLayout, QGridLayout, etc..

---

## シグナル

- 各ウィジェットは固有のシグナルを持つ
- 値の変更(`valueChanged`) やクリック (`clicked`) などを通知する
- `.connect` で通先の関数やメソッドを登録する
- Observer パターン

→ ウィジェットがモデル層と密結合するのを防ぐ
→ コールバックメソッドより柔軟

---

## シグナルを双方向につなぐ

 - `connect / setValue` をループしてもよい
 - 同じシグナルに複数回 `connect` したり、複数のシグナルに `connect` したりしてもよい

```python
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import sys

class Sample2(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent=parent)

        l = QVBoxLayout()
        
        t = QLabel()
        s1 = QScrollBar(orientation=Qt.Horizontal)
        s1.valueChanged.connect(lambda v: t.setText(str(v)))

        s2 = QScrollBar(orientation=Qt.Horizontal)
        s3 = QSpinBox()
        s4 = QSpinBox()
        s5 = QSpinBox()

        s1.valueChanged.connect(s2.setValue)
        s2.valueChanged.connect(s3.setValue)
        s3.valueChanged.connect(s4.setValue)
        s4.valueChanged.connect(s5.setValue)
        s5.valueChanged.connect(s1.setValue)

        l.addWidget(s1)
        l.addWidget(s2)
        l.addWidget(s3)
        l.addWidget(s4)
        l.addWidget(s5)
        l.addWidget(t)

        self.setLayout(l)

app = QApplication(sys.argv)
s = Sample2()
s.show()
sys.exit(app.exec_())
```

---

## 例2

---

## 桂馬飛び

- データ・モデル・ウィジェットを実装
- モデルにもシグナルを定義できる

```python

# データ（現在の盤面）
class BoardState:

# モデル
class BoardModel(QObject):
    stateChanged = pyqtSignal(BoardState)
    def moveKnightTo(self, x, y):
    def rollback(self):

# ウィジェット（１個１個のセル）
class BoardCellWidget(QLabel):
    knightMoved = pyqtSignal(tuple)
    def setState(self, state):

# モデルを持ち、64個のセルを表示するウィジェット
class BoardWidget(QWidget):
```

---

```python
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import sys

class BoardState:
    def __init__(self, x, y):
        self._x = x
        self._y = y
        
    def knightIn(self, x, y):
        return (self._x, self._y) == (x, y)
    
    def canKnightMoveTo(self, x, y):
        dx = abs(self._x - x)
        dy = abs(self._y - y)
        return (dx, dy) in [(1, 2), (2, 1)]
        

class BoardModel(QObject):
    stateChanged = pyqtSignal(BoardState)

    def __init__(self, parent=None):
        super().__init__(parent=parent)
        self._states = [BoardState(0, 0)]

    def moveKnightTo(self, x, y):
        newState = BoardState(x, y)
        self._states.append(newState)
        self.stateChanged.emit(newState)

    def rollback(self):
        if len(self._states) <= 1:
            return
        self._states = self._states[:-1]
        self.stateChanged.emit(self._states[-1])

    def state(self):
        return self._states[-1]


class BoardCellWidget(QLabel):
    knightMoved = pyqtSignal(tuple)

    def __init__(self, parent, x, y):
        super().__init__(parent=parent)
        self._x = x
        self._y = y

        self.setMinimumSize(QSize(32, 32))
        self.setMouseTracking(True)
        self.setAcceptDrops(True)

    def setState(self, state):
        self._state = state
        self.update()

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            drag = QDrag(self)
            data = QMimeData()
            data.setText("knight")
            drag.setMimeData(data)
            drag.exec()

            event.accept()

    def dropEvent(self, event):
        if self._state.canKnightMoveTo(self._x, self._y):
            self.knightMoved.emit((self._x, self._y))
            event.acceptProposedAction()

    def dragEnterEvent(self, event):
        if self._state.canKnightMoveTo(self._x, self._y):
            event.acceptProposedAction()

    def paintEvent(self, event):
        p = QPainter(self)

        if (self._x + self._y) % 2 == 1:
            p.setBackground(QColor(0xD3, 0x8C, 0x40))
        else:
            p.setBackground(QColor(0xFF, 0xCF, 0x9B))
        p.eraseRect(self.rect())

        rect = QRect(4, 4, self.width() - 8, self.height() - 8)
        if self._state.knightIn(self._x, self._y):
            p.fillRect(rect, Qt.white)
        elif self._state.canKnightMoveTo(self._x, self._y):
            p.fillRect(rect, Qt.green)


class BoardWidget(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent=parent)

        self._model = BoardModel(parent=self)

        l = QGridLayout()
        for x in range(8):
            for y in range(8):
                cell = BoardCellWidget(parent, x, y)
                cell.setState(self._model.state())
                self._model.stateChanged.connect(cell.setState)
                cell.knightMoved.connect(lambda xy: self._model.moveKnightTo(*xy))
                l.addWidget(cell, x, y)

        l1 = QHBoxLayout()
        l1.addLayout(l, 1)
        self._rollback = QPushButton("rollback", parent=self)
        self._rollback.pressed.connect(self._model.rollback)
        l1.addWidget(self._rollback, 0)
        self.setLayout(l1)

app = QApplication(sys.argv)

b = BoardWidget()
b.show()
sys.exit(app.exec_())
```

---

## 例3

---

# 補足

- QML・QtQuickというDSLもある
- `QtWebEngineWidgets` でブラウザを埋め込める（→React.jsも使える）
- AndroidでもNDKを使ってビルドすればアプリを作れる
- 昔はビルドが大変だった。今は `pip install` でOK

---

# 残念なところ

- ライセンスがGPL or 有償
  - 社内向け・個人向けなら問題無い？
- インストール作業が必要
  - PyInstaller を使えばWindows向け実行ファイルを生成できる
  - Mac や Linuxでも同様のツールがあるはず

---

# Q&A

**Q.** どうして、ウィジェットを `a.valueChanged.connect(b.setValue)` でループ状につないでいっても無限ループにならないのですか？
**A.** `valueChanged`は「値が変更された時」にしか発行されないからです。