LoginSignup
1
1

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-03-29

はじめに

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

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

  • GtkExtra(libgtkextraパッケージ) ... ここにあれば一番よかったが、残念ながらダイヤルウィジェットはない。
  • Gtk+のチュートリアル ... Cでカスタムウィジェットを作る時のサンプルとしてダイヤルウィジェットを作ってる
  • GIW - GTK Instrumentation Widgets ... gtk2ベース、グラフプロット用ウィジェットにまぎれてダイヤルウィジェットがある
  • python_dial_widget ... gtk2ベースだけど、pygtkで書かれてて小さいので改造するならこれかなぁ
  • Dial widget (tk) ... 個人的にはすごく懐かしのtcl/tkで書かれたダイヤルウィジェット。これも参考にできそう
  • libags-gui .... Advanced Gtk+ Sequencer用のウィジェット、わーお、ダイヤルもピアノもあるぞ!!!
  • QtFLTK ... 標準でダイヤルウィジェットがある。

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

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

作り方

作り方の方針としては、なんか絵が描けるウィジェットにテキトーにダイヤルの絵かいて、
マウスイベント拾って中心からマウスがポイントしたところの角度出して、
その角度から値設定してダイヤル再描画でいいんじゃね?
というカンジで作ります。
……
…………
(中略)
……………………
………………………………………
できました、以下解説です。

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

ダイヤルの目盛りを書く

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

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.Framebutton_press_eventbutton_release_event
motion_notify_event(ドラッグしたまま動かした時のイベント)も全部受け付けません(!!!)。
Gtk.FrameGtk.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_beginself.meter_endの間に入るように値を補正してから、
角度から実際の値を算出します。

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

できあがり

できあがりはこんなカンジです。
ちょっと拡張してGtk.SpinButtonとくっつけてみました。
dial.png

おわりに

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

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

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

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

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


-2021/04/02追記

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

というわけで作り直してみました

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1