【Python】magic-classで効率的にGUIを作成する

# はじめに

Pythonの豊富なライブラリを活かせば、非常に強力なGUIツールを作ることができます。特にデータの可視化においては、1次元データは`pyqtgraph`、多次元の画像データは`napari`がとても優秀で、これらのライブラリを使えるだけでできることが格段に増えます。

- [pyqtgraphのリポジトリ](https://github.com/pyqtgraph/pyqtgraph)
- [napariのリポジトリ](https://github.com/napari/napari)

しかし、GUIを書いたことのある方であれば必ず感じることがあります。

**「「さすがにもうちょっと楽にできないのか！」」**

これをどうにかできないかと思い、Qtをベースにした[magic-class](https://github.com/hanjinliu/magic-class)というパッケージを開発しています。今回は宣伝もかねて`magic-class`の簡単な紹介をします！

※英語ですが今頑張って[ドキュメント](https://hanjinliu.github.io/magic-class/)も書いています。興味があったらこちらも参考にしていただけると嬉しいです。

# magic-classの動機

### これまでのパッケージの問題点

`magic-class`で解決したかったのは次の問題点です。

- GUIのコードはPythonの読みやすさを全く活かせない。たいてい非常に読みづらくなりメンテナンスが難しい。
- 「ボタンを押す&rarr;引数を指定する&rarr;関数を呼ぶ」という超基本な操作ですら、関数ごとにウィジェットを用意する必要がある。
- ボタンもメニューの各項目もツールバーの各項目も、やってることほぼ同じなのになぜかメソッド名や設計が異なる。
- GUIで手動操作しつつJupyterなどでスクリプトベースで操作する「ハイブリッド」なGUIがPythonの主流になっているにもかかわらず、それを作ろうとするとさらに書くことが増える。

もっと簡潔に悩みを書くとすれば...

**「高度なことはできなくてもよいから、シンプルで使いやすいGUIを直観的に作りたい」**

ということになります。

### `magic-class`ではどうか？

`magic-class`の実装は、次のような設計になっています。

1. 変数の**型**や**型アノテーション**を活用してウィジェットを用意していく。スクリプトからの操作と干渉しないように、変数そのものの書き換えは最小限に抑える。
2. デザインやショートカットなど、付加情報はデコレータにまとめる。
3. クラス定義のコードの見た目がほぼそのままGUIのレイアウトになるようにする。
4. 特殊文法を導入しない。
5. APIは可能な限り統一する。

1.～3.は、ウィジェットの細かなデザインでコードが汚れるのを防ぎます。
4.もかなり重要で、これを意識しないと、Web開発で知られる`Django`のように、固有なルールを導入しすぎてPythonを書き慣れた人でも再学習しなければいけないというような状況が生じます。
5.は、前述したように通常のウィジェットとメニューでわずかにメソッドが異なるせいで無駄に検索したりデバッグしたりという問題を防ぎます。

# 実際にはこんな感じ

詳細は後述するとして、`magic-class`をつかったコードはこんな感じになります。これは画像をロードし、フィルタをかけ、保存することができるGUIです（関数の内容は省略）。

```python
from magicclass import magicclass, magicmenu, bind_key
from magicclass.widgets import Image
from pathlib import Path

@magicclass # デコレートしたクラスを通常のウィジェットに変換する
class A:
    @magicmenu # デコレートしたクラスをメニューに変換する
    class Menu:
        @bind_key("Ctrl-O") # ショートカットを登録する
        def Open(self, path: Path):
            """画像を開く"""

        @bind_key("Ctrl-S")
        def Save(self, path: Path):
            """画像を保存する"""

    image = Image() # 画像表示ウィジェット

    def apply_filter(self, sigma: float):
        """画像にフィルタをかけてimageを更新"""
```

これだけでクラス`A`はGUI用に拡張され、

```python
ui = A()
ui.show()
```

により次のGIF画像のようにGUIが起動します。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/b9118593-89f2-2add-4ba8-23c7f2f13bc5.gif)

`Path`型でアノテーションされた`Open`と`Save`は、GUIで呼び出されるとファイルダイアログを開きます。`apply_filter`は浮動小数`sigma`の入力が必要なので、それ用のウィンドウが開きます。関数の中身を定義すれば実際にフィルタをかけたりとかできます。

関数自体は書き換えられていないので、

```python
ui.apply_filter(3.0)
ui.Menu.Open("/path/to/image.png")
```

のようにスクリプトからの実行もできます。

### マクロ記録機能

手動操作から実行可能なスクリプト（マクロ）を生成する機能というのは、Excelをはじめ様々なソフトウェアで用意されています。`magic-class`ではPythonスクリプトを生成する**マクロ記録機能が標準搭載されています**。次のGIF画像をご覧ください。

![output2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/ff57d41b-86ba-7f6a-3934-848886b24034.gif)

<details><summary>コードはこちら</summary><div>

```python
from magicclass import magicclass, vfield
from magicclass.widgets import QtPlotCanvas

@magicclass
class A:
    @magicclass(widget_type="groupbox", layout="horizontal") # ウィジェットのデザインを指定
    class Parameters:
        a = vfield(float, widget_type="FloatSlider", options={"min": 1.0, "max": 10.0, "step": 0.1})
        b = vfield(float, widget_type="FloatSlider", options={"min": 1.0, "max": 10.0, "step": 0.1})
        
    plt = QtPlotCanvas() # プロット用のウィジェット
    
    def set_title(self, title: str):
        self.plt.title = title
    
    @Parameters.a.connect
    @Parameters.b.connect
    def _plot(self):
        a = self.Parameters.a
        b = self.Parameters.b
        x = np.linspace(0, 1, 100)
        # グラフの更新
        self.plt.layers.clear()
        self.plt.add_curve(np.cos(x*a), np.sin(x*b))

ui = A()
ui.show()
ui.macro.widget.show() # マクロウィンドウを開く
```

---
</div></details>

左のメインウィンドウでの操作が、右のマクロウィンドウにリアルタイムで記録されているのが分かります。マクロ記録機能は汎用性が高いにもかかわらず、実装が非常に大変なので、とても有用な機能だと思います。

# 使い方

ここからは、`magic-class`の使い方をもう少し説明していきます。

### インストール

pipでインストールできます。

```
pip install magic-class
```

### 型とウィジェットの対応

`magic-class`の型とウィジェットの対応やその実際の実装は、`magicgui`に従っています。

- [magicguiリポジトリ](https://github.com/napari/magicgui)

`magicgui`は、`magicgui.widgets`に`Widget`クラスを継承した様々なウィジェットを用意しており、これらはAPIがとてもよく統一されている素晴らしい設計になっています。例えば、文字列を入力する`LineEdit`も、整数を入力する`SpinBox`も、テーブルデータを入力/表示する`Table`も、値の参照/代入はすべて`widget.value = ...`で行われます。
さらに、デコレータ`@magicgui`を用いて関数をGUI化でき、その際、次の対応表に従って引数の型アノテーションやデフォルト引数の型に対応する`Widget`型のウィジェットを用意します。

|型|ウィジェット|ウィジェットの説明|
|:---:|:-------:|:----|
|`str`|`LineEdit`|文字列の入力欄|
|`pathlib.Path`|`FileEdit`|パスの入力欄と、ファイルダイアログを開くボタン|
|`int`|`SpinBox`|数値を上下ボタンで調節できる|
|`float`|`FloatSpinBox`|数値を上下ボタンで調節できる|
|`bool`|`CheckBox`|チェックを入れる/外すことでTrue/Falseを変更|
|`enum.Enum`|`ComboBox`|選択肢を選ぶドロップダウンメニュー|
|`datetime.datetime`|`DateTimeEdit`|日時/時刻を調節できる|
|`datetime.date`|`DateEdit`|日時を調節できる|
|`datetime.time`|`TimeEdit`|時刻を調節できる|

※他にも、ウィジェット型を指定すれば`SpinBox`を`Slider`にしたりもできます。より詳細は[公式ドキュメント](https://napari.org/magicgui/)をご覧ください。

実際に使うと次のようなコードになります。

```python
%gui qt  # QtベースのGUIなので、Jupyterではこれを実行する
from magicgui import magicgui
from datetime import date

@magicgui
def diary(t: date, text: str):
    print(f"{t}: {text}")

diary.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/02e4c2f6-fec5-8314-5c4e-36a8981a7e67.png)

### `magicgui`から`magic-class`へどう拡張したか

`magic-class`では、`@magicgui`を真似て、クラスをデコレートしてGUI化するデコレータをいくつか用意しています。

`@magicclass` &rarr; メインウィンドウとなる通常のウィジェット
`@magicmenu` &rarr; メニュー
`@magiccontext` &rarr; 右クリックで現れるコンテキストメニュー
`@magictoolbar` &rarr; ツールバー

これらでデコレートするクラスの書き方に違いはありません。
さらに、`@magicclass`に関しては、引数`widget_type=...`を与えることで、スクロールバー付きだったり、タブに分けたりしたウィジェットに切り替えることができます。もちろんクラスの書き方に変更はありません。詳細は[ドキュメント](https://hanjinliu.github.io/magic-class/containers.html)にあります。


`magic-class`がクラスをウィジェットに変換する際の要点は

(1) クラスのメソッド一つ一つを`@magicgui`でデコレートして、それを呼び出すボタン(またはメニュー)を追加する
(2) `magicgui.widgets`にある`Widget`オブジェクトはそのまま追加する
(3) クラス内に他のmagic-classが現れた場合、再帰的にウィジェットを作成してから追加する

の3点です。さらに、

(2') 型からウィジェットに変換する関数`vfield`を用意する

ことで、すべてを型&rarr;ウィジェットの変換で済ませることができました。`vfield`はPythonの標準ライブラリ`dataclasses`の`Field`を継承したオブジェクトを返すもので、`dataclasses.field`と使い方が非常に似ています。

```python
a = vfield(int)  # intなので、GUIではSpinBoxに変換
a = vfield(1)    # 1はintなので、1にセットされたSpinBoxに変換
```

のように使います。

### 簡単な例

電卓を実装した例です。

```python
from magicclass import magicclass, vfield

@magicclass
class A:
    a = vfield(int)
    b = vfield(int)

    def add(self):
        self.result = str(self.a + self.b)

    def sub(self):
        self.result = str(self.a - self.b)

    def mul(self):
        self.result = str(self.a * self.b)

    def div(self):
        self.result = str(self.a / self.b)

    result = vfield(str)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/ee07bbd2-53d8-2e74-dd84-aba23a6d307a.png)

クラス`A`の変数`a`, `b`, `add`, `sub`, `mul`, `div`, `result`が順にウィジェットに変換されていることが分かると思います。

# おわりに

GUIはいくつかのライブラリを試しましたが、`magicgui`の理念は斬新で比類ないものだと思っています。`magicgui`の足りない部分を`magic-class`でカバーすることで、GUI開発がかなり捗るようになりました。

ぜひ使っていただけると嬉しいです！