【Python】 用户图形界面GUI wxpython II 布局和事件
wxpython - 布局和事件
这章主要记录布局器Sizer以及事件的用法。
// 目前还需要记录的:Sizer的Add方法加空白,Sizer的Layout,Sizer的Remove如何有效
■ 布局
之前介绍的所有组件,如果不把它们的pos写死的话,页面上它们会互相重叠,导致没法看。而Sizer就是一个很好的优化布局的工具,通过此可以灵活地管理组件之间的相对位置。
Sizer大概的可以被分成GridSizer(网格布局)和BoxSizer(线性布局).Sizer的用法概括起来就是创建Sizer之后将其关联到一个对象(通常是Panel)。然后用诸如Add,Insert等方法为Sizer添加组件。调用Fit等方法可以让Sizer自动计算子部件尺寸的大小。去除一个部件和Sizer的关联可以用Sizer.Detach方法。//这个存疑,尝试了各种各样的方法,包括了窗口Refresh等等,效果都不好
● GridSizer 网格布局
构造方法中有四个参数决定了这个布局地大致样式,分别是rows,cols,vgap,hgap分别表示网格行数,列数,两相邻列间相隔像素数,两相邻行间相隔像素数。
在用Add方法添加组件的时候默认是先填满一行再开始填下一行。简单GridSizer严格遵守网格布局的含义,当窗口大小发生变化时,其网格的大小也会相应变化,导致加在网格中的组件大小或间距也会发生相应的变化。GridSizer更加适合表格作业が,其改进版的FlexGridSizer可以更加灵活的设置大小和缩放的关系。
网格布局举例;
testSizer = GridSizer(rows=2,cols=2) totalPanel = Panel(self,-1) text1 = StaticText(totalPanel,-1,"Text1:") btn1 = Button(totalPanel,-1,"Button1") text2 = StaticText(totalPanel,-1,"Text2:") btn2 = Button(totalPanel,-1,"Button") testSizer.Add(text1,flag=ALIGN_CENTER) testSizer.Add(btn1,flag=ALIGN_CENTER) testSizer.Add(text2,flag=ALIGN_CENTER) testSizer.Add(btn2,flag=EXPAND) totalPanel.SetSizer(testSizer)
界面效果:
● FlexGridSizer
其构造方法和GridSizer是类似的。但它和GridSizer的区别在于,在默认情况下FGS的网格不会随着窗口的大小变化而变化。而所谓不默认的情况,就是用Sizer对象调用AddGrowableRow(x,m)/AddGrowableCol(y,n)。这两个方法的意思是使得某一行或某一列的网格会随着窗口大小而变化。比如AddGrowableRow(1)是说让第二行会随着窗口纵向长度变化而变化,可以添加第二个参数来决定这行变化的权重。比如AddGrowableRow(0,1);AddGrowableRow(1,2)的意思就是把第一行和第二行都设置为可纵向延伸,且窗口延伸一个单位,第一行延伸三分之一while第二行延伸三分之二个单位。同理AddGrowableCol就是控制列在横向方向的延伸和权重的。(权重这个概念在后面的BoxSizer中还会再提到)
注意:这里所说的可延伸都是只网格的可延伸,而具体到这个网格里面的组件可不可延伸扩展,,就要看Sizer在Add时的flag了。比如说flag设置为ALIGN_CENTER的话,网格延伸了但是组件始终保持在网格的正中间。EXPAND的话组件始终填满整个网格等等。
反过来说,在Sizer.Add组件时如果设置了flag=EXPAND但是这个组件所在的行列都没有被设置成Growable的话就是白瞎,窗口变动无法引起网格变动自然也无法引起组件的变动了。
● GridBagSizer
GS和FGS在添加时都是默认先填满第一行,填满第一行之后再转到第二行。而GBS可以指定在哪行哪列的网格添加组件。
GBS的构造方法没有rows和cols参数,最终Sizer会以几行几列的方式呈现取决于添加的靠近右下角的那个组件有多右下角= =
GBS在Add的时候必须指出两个参数,pos=(x,y)以及size=(m,n)意思是组件填充在第x+1行和第y+1列的网格以及该组件将占据m行n列的位置(有点像xlrd,xlwt里面的合并单元格属性)
● BoxSizer
BS是一个水平行or垂直列,具体是哪个通过构造方法的参数是wx.HORIZONTAL还是wx.VERTICAL来决定。对于HORIZONTAL,其可延伸方向就是纵向,对于VERTICAL自然就是横向了。需要注意的是对于一种BS,只能在一个方向延伸。在某个BS中Add一个ALIGN_CENTER的组件でも,对于非延伸方向上的拖动窗口,组件不会有反应。
BS的Add方法中有proportion参数。proportion参数类似于上面提到的AddGrowableRow的第二个参数类似,就是表明了窗口在可延伸方向上占的延伸权重。通常可以把组件添加到几个HORIZONTAL的sizer里,再把这些sizer作为组件添加到一个VERTICAL的sizer里面去,形成一个看起来比较舒服的页面。注意,如果需要有窗口延伸时组件也延伸的效果的话就需要让那几个HORIZONTAL的sizer也要有flag=EXPAND,因为sizer和panel一样也是有大小的,如果不设置EXPAND,这些sizer的大小也是不会变的。
关于BS的Add,还有一点常用的,就是flag=LEFT|RIGHT|TOP|BOTTOM|ALL,border=xx 通过flag=选项中的一个或几个,并且配合border的数值,调整被Add的元素始终保持指定的几条边和周围的相距border数值个像素的位置。因为是常用的就不啰嗦了。
● 其他
Sizer有个Fit(window)的方法,调用之后可以让窗口根据sizer调整窗口大小,使窗口刚好能够放下sizer中的所有组件
■ 事件
人和组件元素的一些互动会触发事件,事件在wx中以以下方式表示:
wx.EVT_BUTTON 按下按钮的事件
常用的事件还有:
EVT_SIZE 由于用户干预或由程序实现,当一个窗口大小发生改变时发送给窗口。
EVT_MOVE 由于用户干预或由程序实现,当一个窗口被移动时发送给窗口。
EVT_CLOSE 当一个框架被要求关闭时发送给框架。除非关闭是强制性的,否则可以调用event.Veto(true)来取消关闭。
EVT_PAINT 无论何时当窗口的一部分需要重绘时发送给窗口。
EVT_CHAR 当窗口拥有输入焦点时,每产生非修改性(Shift键等等)按键时发送。
EVT_IDLE 这个事件会当系统没有处理其它事件时定期的发送。
EVT_LEFT_DOWN 鼠标左键按下。
EVT_LEFT_UP 鼠标左键抬起。
EVT_LEFT_DCLICK 鼠标左键双击。
EVT_MOTION 鼠标在移动。
EVT_SCROLL 滚动条被操作。这个事件其实是一组事件的集合,如果需要可以被单独捕捉。
EVT_MENU 菜单被选中。
此外还可以自定义事件。可以参考http://blog.csdn.net/tingsking18/article/details/4033639
● Bind方法
仅仅有事件还是不够的,必须要把引起事件的组件和事件处理结合起来,这里用到的就是Bind函数
Bind隶属于wx.EvtHandler,是所有可显示对象(包括Frame在内的绝大多数组件)的父类,所以 这些组件都可以调用Bind。允许一个组件Bind多个事件,此时事件的处理函数中可以写event.Skip(True)来标识某个事件处理函数对改事件不做处理而直接跳过。
Bind的定义是Bind(绑定事件类型,事件处理器,事件源对象),但是通常可以通过某个组件来调用Bind比如btn.Bind(绑定事件类型,事件处理器)就好了。事件类型就是上面那一大票,而事件处理器传递的是个函数对象。这个函数对象要求有且只有一个没有默认值的参数,event。这样的话就引起了一个问题,当我想要传递一些额外的参数给事件处理函数时该怎么办?比如:
#这样子是运行正常的 btn.Bind(EVT_BUTTON,test) def test(event): print "Hello" #但这样会报参数个数不对的错 btn.Bind(EVT_BUTON,test) def test(event,name): print "Hello,%s"%(name) #而在Bind的时候又不能直接给test加参数。。
这种情况其实可以归类为一个更普遍一点的问题,就是如何为一个(在定义时没有给出参数默认值的)函数对象参数默认值?
解决方法:用lambda:
btn.Bind(EVT_BUTTON,lambda evt,name="Frank":test(evt,name)) def test(event,name): print "Hello,%s"%(name)
通过lambda为函数本身再做一层包装而返回一个包装后的函数对象(感觉有点像装饰器的意思)
● 事件处理函数的event参数
如上所说,每个被Bind到组件上的方法必须要有event这个参数,这个event参数抽象的就是事件本身。可以调用一些这样的方法来获取更多关于事件的信息:
event.GetEventObject() 获取引起事件的那个组件的对象
■ 自定义事件 & 多线程支持
在wx中有这样一种比较难过的场景,就是用户触发了一定的事件之后,后台函数要处理很久这个事件,在其处理完成之前用户看到的主窗口界面是被阻塞,卡着不动的(甚至会出现未响应的提示)。一般而言用户是无法忍受这样的等待的。
自然我们就想到是不是可以当用户触发了事件之后,开启一个后台函数的子线程,让它去处理然后保持主界面没有什么变化,在过一段时间后,后台处理完了再发送消息会主界面告诉用户处理已经完成。但是这样也有一个障碍,就是如何实现主线程和子线程之间的通讯呢?因为在后台处理完成的时候还要发回信息让主线程感知到。
一个简单的解决方法就是结合自定义事件和多线程。下面是一个我写的实例,它是一个通过ssh连接某台机器并验证账号密码的界面。在用户点击了连接后,因为ssh连接需要一定时间,所以用户要等很久,于是整合进了多线程。把它分成几部分来看:
第一部分(自定义事件相关)
import sys, paramiko, ConfigParser, os from MyExceptions import * from MainFrame import MainFrame
# 上面这些都是辅助的模块,不是这篇的重点可以忽略 # from wx import * from threading import Thread LOGIN_EVENT_ID = NewId() # 所有事件都有一个固有的ID,自定义事件也不例外。这句为我自定义的事件分配一个ID(wx内部用的一个ID)。 class LoginEvent(PyEvent): """自定义一个事件类,用PyEvent做基类,在其构造方法中通过SetEventType来为这类事件分配一个ID。 message参数是为了每次构建事件时都可以通过这个message指出这个事件的一些信息,方便事件处理函数来做出相应的操作 """ def __init__(self, message): PyEvent.__init__(self) self.SetEventType(LOGIN_EVENT_ID) self.data = message
第二部分(自定义线程)
class LoginThread(Thread): """自定义一个继承自Thread类的类,构造方法中一般有个wxObject用来指代一个组件对象(大多数情况是Frame本身) 重载run方法,做具体的操作,在操作中适当的地方用wx.PostEvent方法手动引发一个事件,事件源是wxObject,事件是前面自定义事件类的一个实例 """ def __init__(self, server, user, password, wxObject): Thread.__init__(self) self.server = server self.user = user self.password = password self.wxObject = wxObject def run(self): try: ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ssh.connect(self.server, 22, self.user, self.password, timeout=15) PostEvent(self.wxObject, LoginEvent("success")) except TimeOutException: PostEvent(self.wxObject, LoginEvent(u"连接超时")) except AuthenticationException: PostEvent(self.wxObject, LoginEvent(u"密码验证失败")) except SocketError: PostEvent(self.wxObject, LoginEvent(u"Socket错误导致连接失败")) except Exception, e: PostEvent(self.wxObject, LoginEvent(str(e)))
这个自定义线程类通过重载run方法来写明线程的具体操作(详情见threading和Queue的那一篇),构造方法中出现了一个wxObject供PostEvent这个方法用。这个方法是个比较有意思的,它的作用就是手动触发事件。比如在这里,ssh连接失败或成功都会触发事件,由于自定义事件LoginEvent中有data这个属性,所以还可以对不同的登录失败原因给出说明。这里要注意,那就是登录成功也一定要触发事件,否则就没办法通过事件这个渠道来实现线程间通信了。
第三部分(界面的构造):
class MyFrame(Frame): def __init__(self): #详细的构造方法略去# self.btn.Bind(EVT_BUTTON,self._login) #点击按钮确定,开始登录操作 self.Connect(-1,-1,LOGIN_EVENT_ID,self._loginHandler) # Connect方法和xxx.Bind很像,就是把一个对象和某种事件联系起来,并给予一个处理事件的函数, # 和Bind不同的是Connect方法用的是EVENT的ID来绑定的。EVENT的ID可以通过EVT_BUTTON.typeID来查看,别的wx自带的event,在获取到其ID之后也可以转化成Connect方法来做 # 这个self应该要和后文中自定义线程类初始化时传进去的wxObject参数一致(保证出事件的组件和监听的组件是一样的 def _login(self,event): #略过了一些冻结界面的操作,比如self.btn.Disable()等。这样的话按下登录之后可以保证用户不会在有线程在登录中的时候再按登录出现混乱 #还有做一些输入值的检查,获取输入值等等 t = LoginThread(server,user,password,self)
t.setDaemon(True) #设置t为守护进程的原因是如果用户按下确定开始登录,然后又想按取消的话,主线程可以在守护线程没完成前就退出。否则按了取消还是要等登录完成才有反应
t.start() # 开启一个线程,当线程还在运行且没有触发你的自定义事件的这段时间里,主框架不会再被阻塞了。 # 且由于之前调用了Disable,主框架内部的组件也是不可互动的。当线程触发一个事件(在这个例子里就是登录出错或者成功了)那么就调用之前在框架的构造方法里提到的绑定自定义事件的处理函数 # 然后在_loginHandler里面对事件的data属性做个判断,如果是成功的data那么就做成功之后该做的操作,如果是失败了就做失败之后的操作。 def _loginHandler(self,event): if event.data != "success": #之前自定义事件类中的辅助信息参数data的价值在这里体现出来了。可以对触发的事件做判断,根据判断结果确定要做的是失败处理还是成功处理 MessageBox(event.data,u"登录错误") self.okButton.SetLabel(u"确定") self.okButton.Enable() for item in [self.serverInput, self.userInput, self.passInput]: item.SetEditable(True) #失败的情况就把之前所有冻结的界面组件解冻,因为要期待用户再输入 else: server = self.serverInput.GetValue() user = self.userInput.GetValue() password = self.passInput.GetValue() self.Close() MainFrame(server, user, password).Show() #登录成功的话就关闭这个窗口然后Show出新的下一个窗口就行了
这部分中需要小注意的一点是,原先不用多线程可能在开始登录操作的语句后面会接上比如提示登录成功等操作,但是用了多线程之后,线程开启的start方法后面最好不要跟任何语句了。因为线程嘛,一旦开启之后主线程也马上开始往下走,会导致那个登录成功马上跳出来。理想的解决方法是把这种提示语句的条件包装进线程的事件触发里,然后在主框架的事件处理方法那里对相应的事件处理。这样就可以实现所谓“线程结束后主框架的操作”了。
这样就可以做到在登录过程中,主界面不会处于未响应状态,但是后台仍然正常在登录了:(图里IP不小心删掉了一个数字,不要在意。。)
■ 在整个wx中加入事件之后可以看到,组件之间的关系复杂起来了。建议的程序构造方式是这样子的:
每一个窗口都是一个类,不同窗口用不同类来表示
窗口中的组件写在类的构造方法里,并且把需要进行取值等和其他方法互动操作的组件要设置成“self.组件”。与组件绑定的事件处理方法写成“self.方法”的类方法。需要子窗口时把子窗口的构造方法中加个parent参数,然后在构造子窗口时把父窗口传递进去即可。