wxPython の画面に色々描く(+マウスのイベントを処理する)

まず前回のシンプルなコードをwx_utils.pyを利用するよう変更。

# encoding: utf-8
import wx
import wx_utils
from wx_utils import XRC

class MainFrame(wx.Frame):
    def __init__(self,parent=None):
        pre=wx.PreFrame()
        XRC().LoadOnFrame(pre,parent,'MainFrame')
        self.PostCreate(pre)

def main():
    app = wx.App()                               # アプリケーションのインスタンス作成
    wx_utils.XrcInit("resource/resource.xrc")    # リソースの初期化
    frame = MainFrame()                          # メインフレーム作成
    app.SetTopWindow(frame)                      # アプリケーションのウィンドウ階層のトップに据える
    frame.Show(True)                             # メインフレームを表示
    app.MainLoop()                               # メインフレーム終了待ち

if __name__=="__main__":
    main()

前回はresource モジュールからxrcMainFrame をインポートしてましたが、wx_utils.XrcInit() でリソースを初期化するよう変更。なお、元のwx_utils.py ではXrcInit で読み込むファイルが固定だったのですが、私の手元では引数で指定できるように変更しています。


また、前回はxrcMainFrame を直接利用してましたが、今回はwx.Frame を継承したクラスを作り、初期化時にXRC().LoadOnFrame でリソースを読み込んでいます。(実はこれの内部構造は詳しく調べておりません。)ともかくこれでシンプルなウィンドウが表示されます。結果は前回となんら変わらないのですが。


wx_utils への対応が出来たら次はGDI の利用です。こちら(via:備忘録1号)を参考にしつつ。


GDI はwx.DC (Device Context) クラスを通して利用します。ウィンドウズと同じですね。wx.DC はそのまま利用するのではなく適切なサブクラスを利用しますが、通常のウィンドウへの描画ではwx.PaintDC を利用します。描画を行うタイミングは描画イベントハンドラの中が適切ですが、これはwx.EVT_PAINT のイベントハンドラとしてバインドされた関数を利用します。このイベントハンドラのバインドでwx_utils が便利なわけです。

class MainFrame(wx.Frame):
    binder=wx_utils.bind_manager() # 1. クラス変数としてbind_manager を用意

    def __init__(self,parent=None):
        pre=wx.PreFrame()
        XRC().LoadOnFrame(pre,parent,'MainFrame')
        self.PostCreate(pre)
        
        self.binder.bindall(self)  # 3. bind_manager で登録されたバインド処理を一気に実行

    @binder(wx.EVT_PAINT)          # 2. デコレータを使ってOnPaint をwx.EVT_PAINT と関連付けるよう登録
    def OnPaint(self, evt):
        dc=wx.PaintDC(self)
        dc.DrawLine(0, 0, 100, 100)

コメントの番号付けが上から順番になってませんが、これは実行される順番になります。wx_utils.bind_manager のインスタンスを用意して、そこにイベントと関数のペアを(デコレータを利用して)登録していき、MainFrame のインスタンスが生成されて初期化するタイミングで関連付けを一気に行います。この書き方をしなかった場合MainFrame.__init__() の中でいっぱいself.Bind() を書き連ねることになりますが、それだと見にくかったりバインドし忘れたりと大変なんです。という事はwx_utils.py の提供元でも説明されていました


さて上記のコードでなんだかウィンドウに線が引かれているのが確認できますが、もう少し凝ったことをやってみました。昔、OpenGL の授業でラバーバンドの描画というのをやったのですが、ウィンドウ上でマウスのボタンを押し、ドラッグすると、ボタンを押した位置を基点に現在のマウスカーソルの位置まで直線を引く、ってやつです。これを実現するためにはマウスのイベントも処理しなければなりません。しかしながら、ググってみたんですがwxPython のイベントに関する情報ってのがなかなか思うように探し当てられません。

for x in dir(wx):
    if x.startswith("EVT_"):
        print x

こんなコードを書けばEVT_ から始まる、バインド対象となるイベントの一覧は得られます。EVT_LEFT_DOWN, EVT_LEFT_UP といったいかにもマウスのボタンっぽいイベントはあるのですが、ドラッグ操作を処理するためのイベントがどれなのやら、MOUSE という名前を頼りに探しても見当たりません。イベントのtype を調べてみたりなんやらかんやらで、wxMouseEvent のリファレンスにたどり着き、どうやらEVT_MOTION がそれらしいかな?というところまで手探りで進みました。


イベントがどれなのかさえ分かってしまえばラバーバンドの処理自体は難しくありません。

    @binder(wx.EVT_LEFT_DOWN)
    def OnLeftDown(self, evt):
        self.ox = evt.X
        self.oy = evt.Y
        self.flag = True
    @binder(wx.EVT_LEFT_UP)
    def OnLeftUp(self, evt):
        self.flag = False
        self.Refresh()
    
    @binder(wx.EVT_MOTION)
    def OnMotion(self, evt):
        if self.flag:
            self.px = evt.X
            self.py = evt.Y
            self.Refresh()

マウスの左ボタンが押下されたときに呼び出されるOnLeftDown ではドラッグ開始位置を記録してドラッグ中フラグをTrue に、左ボタンが放されたときに呼び出されるOnLeftUp ではドラッグ中フラグをFalse にしています。マウスカーソルが移動したときに呼び出されるOnMotion ではドラッグ中フラグをみて、ドラッグ中の場合にだけ現在位置を記録して再描画リクエストのself.Refresh() を呼び出します。self.Refresh() はEVT_PAINT のイベントハンドラが呼び出されるようにします。で、EVT_PAINT のイベントハンドラの中では以下のような処理を行います。

    @binder(wx.EVT_PAINT)
    def OnPaint(self, evt):
        if self.flag:
            dc=wx.PaintDC(self)
            dc.SetPen(wx.BLACK_PEN)
            dc.DrawLine(self.ox, self.oy, self.px, self.py)

これで、ラバーバンドの描画がとりあえず実現できます。が、このままだとウィンドウにいっぱい描画するようになると問題があります。たとえば以下のようにOnPaint を修正してみると

    @binder(wx.EVT_PAINT)
    def OnPaint(self, evt):
        dc=wx.PaintDC(self)
        # いっぱい線を引く
        for x in range(10,200,5):
            for y in range(10,200,5):
                dc.DrawLine(x,y,x+90,y)
                dc.DrawLine(x,y,x,y+90)
        # ドラッグ中ならラバーバンド描画
        if self.flag:
            dc.SetPen(wx.BLACK_PEN)
            dc.DrawLine(self.ox, self.oy, self.px, self.py)

赤い格子がラバーバンド描画中にちらつくのが見えると思います。これは、OnPaint() で描画を行う前にウィンドウが一旦クリアされているためです。dc.DrawLine が呼び出されるとすぐにウィンドウにラインを引くので、たくさん線を描画するとその過程が肉眼で確認できてしまい、ちらついてしまうのです。このちらつきを防ぐために一般的な手法としてダブルバッファリングが用いられます。これは、画面には表示されないところで全ての描画をしておき、出来上がった描画結果を一気にウィンドウに表示する方法です。


wxPython でダブルバッファリングを行うには、wx.BufferedDC を利用します。

    @binder(wx.EVT_PAINT)
    def OnPaint(self, evt):
        dc=wx.PaintDC(self)
        dc=wx.BufferedDC(dc)  # 画面に表示されないところで描画を行うためのデバイスコンテキストを作成
        # いっぱい線を引く
        for x in range(10,200,5):
            for y in range(10,200,5):
                dc.DrawLine(x,y,x+90,y)
                dc.DrawLine(x,y,x,y+90)
        # ドラッグ中ならラバーバンド描画
        if self.flag:
            dc.SetPen(wx.BLACK_PEN)
            dc.DrawLine(self.ox, self.oy, self.px, self.py)

先ほどのコードに1行追加しただけです。PaintDC を元にBufferedDC を作成し、それに対して前と同じように描画処理を行っています。ウィンドウズだとメモリデバイスコンテキストを作成してそこに描画して、描画結果を元のデバイスコンテキストに転送、っていう転送の1ステップ(BitBlt 関数)が必要ですが、BufferedDC はデストラクタでそれを勝手にやってくれるようです。楽でいいですね。


さて、実はこれだけだとまだちらつきが発生します。変更前とは違ったちらつき方ですが、これはOnPaint() が呼び出される前に一旦ウィンドウがクリアされているためです。これを防ぐためにはRefresh() にFalse を渡します。

    @binder(wx.EVT_MOTION)
    def OnMotion(self, evt):
        if self.flag:
            self.px = evt.X
            self.py = evt.Y
            self.Refresh(False)

GDI とダブルバッファリングのやり方がわかったので、これで画面に自由に図形を描画する準備が整ったかなーというところです。