PythonのクラスをGUIで扱いやすいようラップ


既存のクラスのGUIを書こうとすると既存コードを弄る必要がある件

GUIプログラムを書く際に、ユーザの入力に対応して内部の状態を変更して、GUIを手動で更新みたいのは面倒すぎるので、内部の状態を更新するとUIは自動で更新される仕組みにしたい、と思うわけですが、それをやろうとすると結局既存のコードに手をいれる必要があって。めんどくさいなーーと思ったりします。

悪いパターン

obj.handleEvent()
if val == 1 and obj.isActive():
    btn1.enabled = True
    btn2.enabled = False
else:
    btn1.enabled = False
    btn2.enabled = True

良いパターン

obj.handleEvent() # UIは自動で更新

既存クラスのメソッド呼び出しをフックする

そこで簡単な解決方法として、メソッド呼び出しをラッパー経由で行い、呼び出しをフックしてコールバックする仕組みを考えました。GUI向けに書かれてないクラスに対してイベントハンドラを注入する感じ。
具体的には、__getattr__で属性値アクセスをフックして、インスタンスメソッドなら、インスタンス等に対する参照と、コールバック呼び出しとメソッド呼び出しを含むクロージャを返します。

この仕組みを使えば、既存のコードに変更を加える事なく、新しくラップクラスを個別に定義することもなく、綺麗にGUIに取り込めそう。

もともとはC#IronPythonをうまく連携させられないか考えて書いたクラスでしたが他のGUIプラットフォームでも使えそうな感じ。

# coding: utf-8
# modelproxy.py
import inspect

class ModelProxy:
   def __init__(self, object):
       self.__dict__["_object"] = object
       self.__dict__["_handlers"] = {}
   def addHandler(self, methodname, callback):
       if methodname in self._handlers:
           self._handlers[methodname].append(callback)
       else:
           self._handlers[methodname] = [callback]
   def update(self, methodname):
       for callback in self._handlers.get(methodname, []):
           callback(self._object)
   def __getattr__(self, name):
       obj = self.__dict__["_object"]
       attr = getattr(obj, name, None)
       if inspect.ismethod(attr):
           fun = attr
           def gun(*args, **kwargs):
               ret = fun(*args, **kwargs)
               self.update(name)
               return ret
           return gun
       else:
           return attr

サンプルとして、wxPythonTkinterで、fooモジュールにあるBinaryStateクラスの状態に応じて、複数のGUIコントロールを自動で更新するコードを書いてみました。

foo.py

class BinaryState:
    def __init__(self):
        self.state = 0
    def flip(self):
        self.state = 1 - self.state
    def getState(self):
        return self.state

Tkinterの場合

tktest.py

# coding: utf-8
# see: http://www.pythonware.com/library/tkinter/introduction/
from Tkinter import *
import modelproxy
from foo import BinaryState

class App:
    def __init__(self, master):
        frame = Frame(master)
        frame.pack()
                
        self.btn = Button(frame, text='change state')
        self.btn.bind('<Button-1>', lambda e: self.bs.flip()) ####
        self.btn.pack()
        
        self.btn2 = Button(frame, text='hoge')
        self.btn2.pack()
        
        self.svr = StringVar()
        self.lbl = Label(frame, textvariable=self.svr)
        self.lbl.pack()
        
        # create model proxy
        self.bs = modelproxy.ModelProxy(BinaryState())
        # set handler
        self.bs.addHandler('flip', self.updateLabel) ####
        self.bs.addHandler('flip', self.updateVisible) ####

        self.bs.update('flip')
    
    def updateLabel(self, e):
        self.svr.set(['off', 'on'][self.bs.getState()])
    def updateVisible(self, e):
        self.btn2.config(state = [DISABLED, NORMAL][self.bs.getState()])

def main():
    root = Tk()
    app = App(root)
    root.mainloop()
    
if __name__ == '__main__':
    main()

wxPythonの場合

wxtest.py

# coding: utf-8

import wx
import modelproxy
from foo import BinaryState

class Frame(wx.Frame):
    def __init__(self):
        wx.Frame.__init__(self, None, -1, 'wx', size=(200, 150))
        
        self.pnl = wx.Panel(self)
        self.btn = wx.Button(self.pnl, -1, 'change state')
        self.btn.Bind(wx.EVT_BUTTON, lambda e: self.bs.flip()) ####

        self.btn2 = wx.Button(self.pnl, -1, 'hoge', pos=(0, 30))

        self.lbl = wx.StaticText(self.pnl, -1, '', pos=(20, 70))
        
        self.bs = modelproxy.ModelProxy(BinaryState())
        self.bs.addHandler('flip', self.updateLabel) ####
        self.bs.addHandler('flip', self.updateVisible) ####

        self.bs.update('flip')

    def updateLabel(self, e):
        self.lbl.SetLabel(['off', 'on'][self.bs.getState()])
    def updateVisible(self, e):
        if self.btn2.Enabled:
            self.btn2.Disable()
        else:
            self.btn2.Enable()

def main():
    app = wx.PySimpleApp()
    Frame().Show()
    app.MainLoop()

if __name__ == '__main__':
    main()

これを使えば、実行中のPythonプログラムの状態を動的に表示していくプログラムを簡単に書いたりできるだろうなーとか妄想。