MIDIスイッチャを作る:ダイヤルウィジェットがほしい

# はじめに

python+mididingsで自作MIDIスイッチャを作り変えるにあたって、どうしてもGUIをつけたいんですが、
その前に**どうしてもダイヤルウィジェット**がほしい！！！

まずググってさがしたところ以下のような感じっぽい：

- [GtkExtra(libgtkextraパッケージ)](https://sourceforge.net/projects/gtkextra/) ... ここにあれば一番よかったが、残念ながらダイヤルウィジェットはない。 
- [Gtk+のチュートリアル](http://lmj.nagaokaut.ac.jp/~lu/programming/gtk_tut_ja/gtk_tut_ja-24.html
) ...  Cでカスタムウィジェットを作る時のサンプルとしてダイヤルウィジェットを作ってる
- [GIW - GTK Instrumentation Widgets](https://sourceforge.net/projects/giw/) ... gtk2ベース、グラフプロット用ウィジェットにまぎれてダイヤルウィジェットがある
- [python_dial_widget](https://github.com/nemec/python_dial_widget) ... gtk2ベースだけど、pygtkで書かれてて小さいので改造するならこれかなぁ
- [Dial widget (tk)](https://wiki.tcl-lang.org/page/Dial+widget) ... 個人的にはすごく懐かしのtcl/tkで書かれたダイヤルウィジェット。これも参考にできそう
- [libags-gui](http://www.nongnu.org/gsequencer/api/3.3.7/libags-gui/index.html) .... Advanced Gtk+ Sequencer用のウィジェット、わーお、ダイヤルもピアノもあるぞ！！！
- `Qt`と`FLTK` ... 標準でダイヤルウィジェットがある。

うーんなるほど、`Qt`,`FLTK`,`libags-gui`に完成品がありますが、ダイヤルだけほしいんですよねー、pythonバインディングないのもあるし、うーーんどうしよっかな

参考にできそうなソースもあるし、作るか　←

# 作り方
作り方の方針としては、なんか絵が描けるウィジェットにテキトーにダイヤルの絵かいて、
マウスイベント拾って中心からマウスがポイントしたところの角度出して、
その角度から値設定してダイヤル再描画でいいんじゃね？
というカンジで作ります。
……
…………
(中略)
……………………
………………………………………
[できました](https://github.com/KiyohitoSUZUKI/gmidisw/blob/master/midisw/gtk3/dial-old.py)、以下解説です。

# Gtk.Frameの継承とコンストラクタ

いまだによくわからないんですけど、Gtkのウィジェット拡張して新しいウィジェット作る時って
どれを継承するのが正しいんですかね......
とりあえず今回は`Gtk.Frame`を継承してみました。

```dial.py
class Dial(Gtk.Frame):
    def __init__(self,min=0,max=255,initial_value=0, meter_scale=16,label=""):
        super(Dial, self).__init__(label=label)
```

コンストラクタの引数としては
- min ... ダイヤルのとる値の最小値:int
- max　... ダイヤルのとる値の最大値:int
- initial_value ... ダイヤルの初期値:int
- meter_scale ... ダイヤルの目盛りをいくつ作るか:int
- label ... ダイヤルのラベル、そのまま`Gtk.Frame`に渡す
で構築します。

# cairoと座標系のはなし
なんか色んなサンプルだと、図を書くのに`Gtk.DrawingArea`を作ってexposeイベントもらったときに
cairoからSurfaceオブジェクトとコンテクスト再作成して描画してる例があったのですが、
`draw`イベントのコールバックにはwidgetとコンテクストオブジェクトが渡されるようなので、
わざわざ`Gtk.DrawingArea`を新たに作ることはしてません
(`Gtk.Widget`を継承してるクラスなら自由にcairo使って描画できるという考え方みたい)。

あと図を書くにあたって、座標系がy軸が下に向かって伸びてるので
(まぁこれは大昔、パソコンにBASICが付属してた頃からの慣例ですが)
円を描く時、円周上の点を(r*cosθ,r*sinθ)としてθを増やしながらプロットする場合、
時計まわりになります。これはcairoの`arc(x,y,r,begin_rad,end_rad)`メソッドも同じです。

![coood-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111803/fdc6c0c9-f4b1-d75b-4560-f44c996271df.png)


# ダイヤルの目盛りを書く

まず、コンストラクタで目盛りの情報を作っておきます

```dial.pyのDial.__init__()
        self.mouth_size = 60 # degree
        self.meter_scale = int(meter_scale)
        self.meter_begin = 2*math.pi*((90+self.mouth_size/2)/360)
        self.meter_end =   2*math.pi*((360+90-self.mouth_size/2)/360)
        self.meter_range = (self.meter_end-self.meter_begin)
```

イメージとしてはパックマンみたいな形の図形が下向きに60度口をあけてて、
口の所以外に目盛りと線をひく感じです。
`self.meter_begin`がダイヤルのメーターの始点、
`self.meter_end`がダイヤルのメーターの終点の角度です。
(r,0)を始点に時計回りの90+30度〜360+90-30度が表示上のダイヤルのとる角度になります。

実際の描画はこんな感じです

```dial.py
    def _get_center(self):
        return (self.get_allocated_width()/2,self.get_allocated_height()/2)

    def on_draw(self, widget, cr):
        cx,cy = self._get_center()

        maxr = cx if cy > cx else cy

        # mk meter_scale
        cr.set_line_width(0.5)
        cr.arc(cx, cy, maxr*0.6, self.meter_begin, self.meter_end )
        cr.stroke()

        for i in range(self.meter_scale+1):
            rad_i = self.meter_begin+self.meter_range*(i/self.meter_scale)
            cr.move_to(cx+maxr*0.6*math.cos(rad_i),cy+maxr*0.6*math.sin(rad_i))
            cr.line_to(cx+maxr*0.7*math.cos(rad_i),cy+maxr*0.7*math.sin(rad_i))
            cr.stroke()
```

ウィジェットの中心(`_get_center()`で求める奴)から
半径`maxr`(ウィジェットの縦横の長さのどっちか小さい方を1/2にした値)を最大半径として、
`self.meter_begin`の角度から`self.meter_end`の角度まで円と目盛りを描くイメージです。

# ダイヤルを書く
ダイヤルそのものはタダの円なのでサクっと`arc()`メソッドに
ウィジェットの中心から半径`maxr`を基準にテキトーに0〜2*math.piの角度で弧をひいてねーって渡します。
ダイヤルのノッチ部分は`self.value`を角度に変換して描画します

```dial.pyのDial.on_draw()
        # mk dial
        cr.set_line_width(2)
        cr.arc(cx, cy, maxr*0.5, 0, 2*math.pi)
        cr.stroke()

        # draw value
        cr.set_line_width(5)
        rad = self.meter_begin + (self.meter_end - self.meter_begin)*self.value/self.max
        cr.move_to(cx+maxr*0.2*math.cos(rad),cy+maxr*0.2*math.sin(rad))
        cr.line_to(cx+maxr*0.5*math.cos(rad), cy+maxr*0.5*math.sin(rad))
        cr.stroke()
```


# イベントを受け取って値を設定する
次にマウスがクリックされたり、ドラッグされたまま動かした時のコールバックを書くのですが、
なんと、`Gtk.Frame`は`button_press_event`も`button_release_event`も
`motion_notify_event`(ドラッグしたまま動かした時のイベント)も全部受け付けません（！！！）。
`Gtk.Frame`は`Gtk.Widget`を継承してるはずなのに、なんでやねん！！！、
と思いましたが、`Gtk.EventBox`を子ウィジェットにしてイベントを受け付けることにしました。

```dial.pyのDial.__init__
        self.evb = Gtk.EventBox()
        self.add(self.evb)

        self.evb.connect("button_press_event", self.on_changed)
        self.evb.connect("button_release_event", self.on_changed)
        self.evb.connect("motion_notify_event",self.on_changed)
```

`on_changed`はイベントをうけとって、
マウスの位置とウィジェットの中心から方向ベクトルを作って、
方向ベクトルを元に角度を計算して、実際の値を計算します

```dial.pyの_get_value_from_xyとon_changed
   def _get_value_from_xy(self, posx, posy):
        cx,cy = self._get_center()
        vx,vy = (posx-cx,posy-cy)

        radadj =  math.atan2(vy,vx )

        if radadj < 0 :
            radadj += 2*math.pi
        if radadj > 0 and radadj < (1./2.)*math.pi:
            radadj += 2*math.pi
        
        if radadj <= self.meter_begin:
            radadj = self.meter_begin
        if radadj > self.meter_end:
            radadj = self.meter_end

        val = ((radadj - self.meter_begin)/self.meter_range)*self.max
        return int(val)

   def on_changed(self, widget, ev):
        self.value = self._get_value_from_xy(ev.x, ev.y)
        self.queue_draw()
```

`_get_value_from_xy`でマウスのポイントされた位置と
ウィジェットの中心を結ぶ方向ベクトルから角度を出してます。
`arctan2(y,x)`は渡された方向ベクトルから角度を返してくれますが、
マイナスの値を返すこともあるのでそれらは+360度して正にします。
また、0度〜90度は360度〜360+90度として値がほしいので、これについても補正します。
で、下に30度口をあけてるので、`self.meter_begin`と`self.meter_end`の間に入るように値を補正してから、
角度から実際の値を算出します。

値を設定したら`queue_draw()`で再描画させます。

# できあがり

できあがりは[こんなカンジ](https://twitter.com/fgtsystems/status/1376166174027706370)です。
ちょっと拡張して`Gtk.SpinButton`とくっつけてみました。
![dial.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111803/5dfb8c3f-5ec1-0c29-2fd0-d4afede41aed.png)


# おわりに

とりあえずは、いろんなソースを参考にしてコードを仕上げました。

Gtk+3のリファレンス見てみましたが、色んな所でdeprecatedの嵐....
主に「ボタンの色変えるのにCSS使え、ただしテーマの一部を勝手に変えるのはおすすめしないぜ」、
の所でしたが、シグナル名まで変わったりしてるのがあって色々混乱しました。

まぁロジック的には角度求めるのに`arctan2(y,x)`使うのがミソでしたね。
算数苦手なんで久々に頭使いました。

本来は最大最小＋値を操作するようなウィジェットには
`Gtk.Adjustment`を使ってレンジ情報をまとめるべきとか,
実は`Gtk.Scale`を継承して作った方がよかったのかもしれない、
とか色々ありますが、まぁあんまり調べる気もなかったのでテキトーに作りました。
そのへん、詳しい人教えてplz

次はピアノウィジェットだナ

-----
-2021/04/02追記

いろいろいじってたたら、ダイヤルウィジェットみたいなものは`Gtk.Range`を継承して、
値が変化した時のハンドラは`Gtk.Range`に登録するか、
`Gtk.Range`が持ってる`Gtk.Adjustment`に登録するのが正しいみたいです。

[というわけで作り直してみました](https://qiita.com/fgtsystems/items/aafea6fc5580c97ff794)