Hy で GUI プログラミングをしてみる

この記事は、[Lisp Advent Calendar 2019](https://qiita.com/advent-calendar/2019/lisp) の 16 日目の記事です。

# この記事の要旨

Hy での GUI プログラミング入門として、Python の標準ライブラリに含まれている GUI ライブラリの [tkinter](https://docs.python.org/ja/3/library/tkinter.html) を使用したジェネラティブアートを実装します。

こんな感じの三角関数を使った図形描画で、キー入力によってパラメータを変更できる仕様です。
<img width="402" alt="2019-12-15_23h18_53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543140/e8b79276-8ca5-6397-4920-7fa995f1aae1.png">

# 対象読者

* Hy で GUI プログラミングをしてみたい人

# 環境構築

著者の環境は下記になります。

* Windows 10
* Python 3.7.2 (64bit)

## 仮想環境の構築とモジュールのインストール

GUI ライブラリの tkinter は Python 標準ライブラリのため、仮想環境にインストールするサードパーティー製モジュールは Hy のみです。
Hy は、12/10 時点での最新版をインストールしました（ https://github.com/hylang/hy/tree/39c150d8299d9ff8f47bb78415e22a9fe976d7e5 ）。

```
cd %USERPROFILE$\.virtualenvs
py -3 -m venv venv_hy_geometric_pattern
cd venv_hy_geometric_pattern
Scripts\activate.bat
pip install --upgrade pip
pip install git+https://github.com/hylang/hy.git
deactivate
```

# 仕様
tk.Tk() インスタンスとして作成したウインドウの中に Canvas を作成し、そこに図形を描画します。
アニメーションをさせるために、およそ 60 fps で Canvas を再描画します。
ある時刻の点の座標は、下記の式で求めます。$\theta$ を $\mathrm{d} \theta$ ずつ変化させてその時間での点の配列を作成します。

```math
\left\{ \begin{array}{}
x(\theta , t) = x_{\mathrm{center}} + r \cos \theta + a \cos (\phi \left( t \right) + c \theta) \\

y(\theta , t) = y_{\mathrm{center}} + r \sin \theta + a \sin (\phi \left( t \right) + c \theta)
\end{array} \right.
```

```math
0 \leq \theta < 2 \pi \\
0 \leq \phi < 2 \pi \\
0 \leq c < 2 \pi \\
0 < r + a < \min(x_{\mathrm{center}}, y_{\mathrm{center}}) \\
```

$\left( x_{\mathrm{center}}, y_{\mathrm{center}} \right)$ は Canvas の中央の座標です。
$\phi$ は時間によって $0 \leq \phi < 2 \pi$ をループするようにします。

キー入力で上記の $r, a, c$ と $\theta$ の刻み幅 $\mathrm{d} \theta$ を変更できるようにします。
キーの割り当ては下表の通りです。
また、q キーでウインドウを閉じて終了するようにも設定します。

|変数|減少キー|増加キー|
|:--|:--|:--|
|$r$|z|a|
|$a$|x|s|
|$c$|c|d|
|$\mathrm{d} \theta$|v|f|

# パッケージの構成

```hy``` ディレクトリが PYTHONPATH に追加されているものとします。
\_\_init\_\_.hy の中身は空で、gui.hy に機能を実装していきます。
geometric_pattern.bat から geometric_pattern.gui を呼び出して実行します。

```
hy ─┬─ geometric_pattern ─┬─ __init__.hy
    │                     │
    │                     └─ gui.hy
    │
    └─ geometric_pattern.bat
```

# 実装

```hy:gui.hy
(import [math [sin cos radians]])
(import sys)
(import [tkinter :as tk])

; キャンバスサイズ
(setv *width* 800)
(setv *height* 600)


(defclass GeometricPattern []
  (setv *d-theta-max* 20)
  (setv *deg* 360)
  (setv *d-phi* 1)

  (defn --init-- [self &optional [center-x 0] [center-y 0]]
    "パラメータを初期化し、点のリストを取得する"
    (setv (. self center-x) center-x)
    (setv (. self center-y) center-y)

    (setv (. self r)
          (int (-> 0.8
                   (* (min (. self center-x) (. self center-y)))
                   (/ 3))))
    (setv (. self a)
          (int (-> 0.8
                   (* 2 (min (. self center-x) (. self center-y)))
                   (/ 3))))
    (setv (. self phi) 0)
    (setv (. self c) 104)

    ;; theta を変動させるときの刻み幅
    (setv (. self d-theta) 1)

    ((. self fetch-points)))
  
  (defn dec-r [self event]
    "rを減少する"
    (setv (. self r) (dec (. self r)))
    (if (< (. self r) 1)
        (setv (. self r) 1)))

  (defn inc-r [self event]
    "rを増加する。Canvas の短辺からはみ出さないように上限を指定している"
    (if (< (+ (. self r) (. self a))
           (min (. self center-x) (. self center-y)))
        (setv (. self r) (inc (. self r)))))
  
  (defn dec-a [self event]
    "aを減少する"
    (setv (. self a) (dec (. self a)))
    (if (< (. self a) 1)
        (setv (. self a) 1)))
  
  (defn inc-a [self event]
    "aを増加する。Canvas の短辺からはみ出さないように上限を指定している"
    (if (< (+ (. self r) (. self a))
           (min (. self center-x) (. self center-y)))
        (setv (. self a) (inc (. self a)))))
  
  (defn dec-d-theta [self event]
    (setv (. self d-theta) (dec (. self d-theta)))
    (if (< (. self d-theta) 1)
        (setv (. self d-theta) 1)))
  
  (defn inc-d-theta [self event]
    (setv (. self d-theta) (inc (. self d-theta)))
    (if (> (. self d-theta) (. GeometricPattern *d-theta-max*))
        (setv (. self d-theta) (. GeometricPattern *d-theta-max*))))
  
  (defn dec-c [self event]
    (setv (. self c) (dec (. self c)))
    (if (< (. self c) 0)
        (setv (. self c) (% (. self c) (. GeometricPattern *deg*)))))
  
  (defn inc-c [self event]
    (setv (. self c) (inc (. self c)))
    (if (>= (. self c) (. GeometricPattern *deg*))
        (setv (. self c) (% (. self c) (. GeometricPattern *deg*)))))

  (defn inc-phi [self]
    (setv (. self phi) (inc (. self phi)))
    (setv (. self phi) (% (. self phi) (. GeometricPattern *deg*))))
  
  (defn fetch-points [self]
    "点の座標を取得する"
    (setv (. self points)
          (lfor theta (range 0 (. GeometricPattern *deg*) (. self d-theta))
                (, (+ (+ (. self center-x) (* (. self r) (cos (radians theta))))
                      (* (. self a) (cos (radians (+ (. self phi) (* (. self c) theta))))))
                   (+ (+ (. self center-y) (* (. self r) (sin (radians theta))))
                      (* (. self a) (sin (radians (+ (. self phi) (* (. self c) theta)))))))))

    ;; パスを閉じるために最初の点をリストの最後に追加する
    ((. self points append) (. self points [0]))))


(defclass Simulator []
  (setv *ms* 16)
  
  (defn --init-- [self width height]
    (setv (. self width) width)
    (setv (. self height) height)

    ;; ウインドウとキャンバスの初期化
    (setv (. self window) ((. tk Tk)))
    ((. self window title) :string "Sample Trig Function on Tkinter")
    ((. self window resizable) :width False :height False)
    (setv (. self canvas)
          ((. tk Canvas) (. self window)
                         :width (. self width)
                         :height (. self height)))

    (setv (. self center-x) (/ width 2))
    (setv (. self center-y) (/ height 2))

    ;; 表示する文字の座標
    (setv (. self quit-x) 20)
    (setv (. self quit-y) 30)
    (setv (. self r-x) (- width 20))
    (setv (. self r-y) (- height 120))
    (setv (. self a-x) (- width 20))
    (setv (. self a-y) (- height 90))
    (setv (. self c-x) (- width 20))
    (setv (. self c-y) (- height 60))
    (setv (. self d-theta-x) (- width 20))
    (setv (. self d-theta-y) (- height 30))

    (setv (. self gp) (GeometricPattern (. self center-x) (. self center-y)))

    ;; 背景色（黒）でキャンバスを塗りつぶす
    ((. self canvas create-rectangle) 0
                                      0
                                      (. self width)
                                      (. self height)
                                      :fill "black")
    ((. self draw))
    ((. self canvas pack))

    ;; キーバインドの設定
    ((. self window bind) "<KeyPress-q>" (. self quit))
    ((. self window bind) "<KeyPress-z>" (. self gp dec-r))
    ((. self window bind) "<KeyPress-a>" (. self gp inc-r))
    ((. self window bind) "<KeyPress-x>" (. self gp dec-a))
    ((. self window bind) "<KeyPress-s>" (. self gp inc-a))
    ((. self window bind) "<KeyPress-c>" (. self gp dec-c))
    ((. self window bind) "<KeyPress-d>" (. self gp inc-c))
    ((. self window bind) "<KeyPress-v>" (. self gp dec-d-theta))
    ((. self window bind) "<KeyPress-f>" (. self gp inc-d-theta)))
  
  (defn quit [self event]
    ((. self window destroy)))
  
  (defn draw [self]

    ;; 図形の描画
    ((. self canvas create-line) (. self gp points) :fill "white" :tag "sample")
    
    ;; 文字の描画
    ((. self canvas create-text) (. self quit-x)
                                 (. self quit-y)
                                 :text "Quit: q"
                                 :tag "sample"
                                 :font (, "Arial" 12)
                                 :fill "white"
                                 :anchor (. tk W))
    ((. self canvas create-text) (. self r-x)
                                 (. self r-y)
                                 :text ((. "r: Z < {0:03d} > A" format) (. self gp r))
                                 :tag "sample"
                                 :font (, "Arial" 12)
                                 :fill "white"
                                 :anchor (. tk E))
    ((. self canvas create-text) (. self a-x)
                                 (. self a-y)
                                 :text ((. "a: X < {0:03d} > S" format) (. self gp a))
                                 :tag "sample"
                                 :font (, "Arial" 12)
                                 :fill "white"
                                 :anchor (. tk E))
    ((. self canvas create-text) (. self c-x)
                                 (. self c-y)
                                 :text ((. "c: C < {0:03d} > D" format) (. self gp c))
                                 :tag "sample"
                                 :font (, "Arial" 12)
                                 :fill "white"
                                 :anchor (. tk E))
    ((. self canvas create-text) (. self d-theta-x)
                                 (. self d-theta-y)
                                 :text ((. "d-theta: V < {0:03d} > F" format) (. self gp d-theta))
                                 :tag "sample"
                                 :font (, "Arial" 12)
                                 :fill "white"
                                 :anchor (. tk E)))
  
  (defn delete [self]
    "sampleタグをつけた要素を削除する"
    ((. self canvas delete) "sample"))
  
  (defn loop [self]
    ;; 一つ前の画面をクリア
    ((. self delete))

    ;; 新しい状態を取得して描画
    ((. self gp inc-phi))
    ((. self gp fetch-points))
    ((. self draw))

    ;; 約16ms後にloopを実行
    ((. self window after) (. Simulator *ms*) (. self loop))))


(defn main []
  (setv simulator (Simulator *width* *height*))
  ((. simulator loop))
  ((. simulator window mainloop))
  0)


(when (= --name-- "__main__")
      ((. sys exit) (main)))
```

上記 geometric_pattern.gui モジュールを呼び出して実行するためのバッチファイルを作成します。
仮想環境を ```activate``` し、```hy``` コマンドと hy モジュールにパスが通った状態にします。
そして、```hy``` コマンドを使って geometric_pattern.gui モジュールを実行します。
-m オプションを付けると、パッケージ内のモジュールをドット区切り表記で指定できます。

```geometric_pattern.bat
@ECHO OFF
SETLOCAL

SET THISDIR=%~dp0
SET PYTHONPATH=%THISDIR%;%PYTHONPATH%
SET ACTIVATE=%USERPROFILE%\.virtualenvs\venv_hy_geometric_pattern\Scripts\activate.bat

CALL %ACTIVATE%
hy -m geometric_pattern.gui
SET STATUS=%ERRORLEVEL%
CALL deactivate

IF %STATUS%==0 (
  ECHO Done.
  PAUSE
  EXIT /b 0
) ELSE (
  ECHO Error.
  PAUSE
  EXIT /b 1
)
```

# 実行

それではやっていきましょう。

<img width="402" alt="2019-12-16_15h26_49.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543140/8f052551-ffc2-9964-ea33-a3b64ac0fa70.png">
<img width="402" alt="2019-12-16_15h26_58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543140/f67a2ab5-df4d-3040-c3a2-2e181905a9f3.png">
<img width="402" alt="2019-12-16_15h27_25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543140/4bf6c809-521f-5ec1-b803-cfa8422d6d0f.png">
<img width="402" alt="2019-12-16_15h27_30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543140/fefba46b-4255-76df-ea72-b97e640b1c99.png">

はい。
パスの色や太さを変えてみたり、別の数式に変えてみると楽しいかもしれません。

# まとめ・補足

* Python の標準ライブラリには tkinter という GUI ライブラリがあり、Hy からも利用可能です。
* GUI ライブラリには、他にもサードパーティー製モジュールの Qt for Python (PySide2) などがあります。