第十一章 GUI界面编程
1 初识GUI
1.1 黑框框和窗口,差的不只是好看
前面十章写的程序,运行过程都是一条直线。代码从上往下走,走到 input() 停下来等你敲字,敲完继续走,走完结束。
图形界面程序不是这么跑的。它启动之后会进一个死循环,专门等着你做动作。你点一下按钮,它去执行一段代码,执行完回到循环里接着等。你不点,它就不动。
markdown
┌───────────────────────────┐
│ 启动,建好窗口,进入循环 │
└─────────────┬─────────────┘
▼
┌────────────────┐
│ 等一件事发生 │ ◄─────────┐
└────────┬───────┘ │
▼ │
点鼠标 / 敲键盘 / 定时器到点 │
▼ │
┌────────────────┐ │
│ 找到对应的处理器 │ │
│ 跑你写的那个函数 │ ───────────┘
└────────────────┘
│
│ 窗口被关掉
▼
退出循环,程序结束
这个循环有个名字,叫事件循环 ,或者说消息循环。你在 wxPython 里写的 app.MainLoop() 就是它。
理解了这一点,GUI 编程里很多"怪事"就说得通了。比如为什么在窗口显示之后写的 print 要等窗口关掉才输出,因为循环把程序堵在那儿了。再比如为什么不能在按钮的响应函数里写 time.sleep(60),因为那 60 秒里循环停摆,窗口点哪都没反应,系统直接给你标成"未响应"。
1.2 Python 里能用的 GUI 库
Python 没有官方 GUI 库,可选的第三方不少。挑一个之前,这张表值得看两眼。
| 库 | 出身 | 装起来 | 控件长相 | 授权 | 什么时候选它 |
|---|---|---|---|---|---|
| tkinter | 标准库自带 | 不用装 | 用系统主题,偏朴素 | Python 许可 | 小工具、教学、不想加依赖 |
| wxPython | wxWidgets 的 Python 绑定 | pip install wxPython | 调系统原生控件 | LGPL | 桌面程序,要跟系统长得像 |
| PyQt5 / PyQt6 | Qt 的 Python 绑定 | pip install PyQt6 | 自绘,现代,各平台一致 | GPL 或商业 | 界面复杂、要好看、能接受 GPL |
| PySide6 | Qt 官方绑定 | pip install PySide6 | 自绘,同 Qt | LGPL | 想要 Qt 又要宽松许可 |
| PySimpleGUI | 对上面几层的封装 | pip install pysimplegui | 取决于底层 | LGPL | 几十行拼一个表单 |
| Kivy | 独立框架 | pip install kivy | 自绘,偏触屏 | MIT | 触屏设备,含手机 |
tkinter 随 Python 一起装好,import tkinter 就能用,写个小窗体连十行都用不了。这章不用它,改讲 wxPython,原因有两个。
一是控件。wxPython 在 Windows 上调的是 Windows 自己的按钮和列表框,在 macOS 上调 macOS 的,程序看上去就是本系统原生程序,不用额外调样式。tkinter 的控件是自己画的一套,能用,但摆在专业软件旁边会显得格格不入。
二是布局。你给的小节里点名要讲 BoxSizer,那是 wxPython 的布局器。wxPython 用 sizer 算位置,控件只管自己"想要多大",剩下的交给布局器。窗口拉大拉小、换台机器、改系统字体,都不用改代码。tkinter 用的是 pack 和 grid,思路不同。
顺手放一段 tkinter 的对照,感受一下两者的差别。
ini
import tkinter as tk
root = tk.Tk()
tk.Label(root, text="你好").pack()
tk.Button(root, text="点我").pack()
root.mainloop()
四行出一个窗口。真要写个小工具,tkinter 其实够用。这章往下都是 wxPython。
1.3 装 wxPython
pip install wxPython
装完先确认一下版本。
python
>>> import wx
>>> wx.__version__
'4.3.1'
>>> wx.version()
'4.3.1 msw (phoenix) wxWidgets 3.3.3'
>>> wx.PlatformInfo[1]
'wxMSW'
wx.__version__ 是 Python 这层的版本,wx.version() 会把底层的 C++ 库版本一起报出来。最后那个 wxMSW 说明我跑在 Windows 上,如果你用的是 Linux,这里会是 wxGTK,macOS 是 wxOSX。
有个坑提前说。国内常见的 pip 镜像有时候没同步这个包,装上会报 No matching distribution found,看着像版本不兼容,其实是源里没有。加个官方源就行。
arduino
pip install wxPython -i https://pypi.org/simple
wxPython 的 wheel 有二十来兆,装的过程要等一会儿,不是卡住了。
1.4 一个窗口由哪些零件拼出来
wxPython 里的类挺多,常用的其实就这几层。
markdown
wx.App 应用程序,一个进程只能有一个
│
└─ wx.Frame 顶层窗口,就是那个带标题栏和关闭按钮的框
│
├─ wx.MenuBar 菜单栏,贴在标题栏下面
├─ wx.StatusBar 状态栏,贴在窗口最底下
│
└─ wx.Panel 面板,真正装控件的那块底板
│
├─ wx.StaticText 一行字
├─ wx.TextCtrl 输入框
├─ wx.Button 按钮
└─ ... 其它控件
wx.Sizer 布局器,不是控件,看不见,只负责算位置
每层干什么,一句话说清。
wx.App 是应用程序对象,管事件循环,也管一些全局设置。一个进程只能有一个,写两个不会报错但行为会很怪。
wx.Frame 是顶层窗口,有标题栏、最大化最小化按钮、可拖的边框。一个程序可以有多个 Frame,主窗口关掉程序就结束。
wx.Panel 是容器。控件通常不直接放在 Frame 上,而是先放一个 Panel 占满窗口,再把控件放到 Panel 上。这么绕一层是有实际收益的,Panel 支持 Tab 键在控件之间跳、有默认的背景色、能正确处理滚动。直接往 Frame 上堆控件,Tab 键切换会失灵,背景色也不对。
wx.Sizer 不是控件,它不占像素,也没有窗口句柄。它的活儿是拿着一堆控件,算出每个控件该放哪儿、多大。这章第 4 节全讲它。
1.5 关于这一章的代码
我这边的环境是 Windows 11、Python 3.13、wxPython 4.3.1,系统缩放 100%。
文章里凡是出现像素数字的地方,比如按钮宽 75 高 25,都是在这台机器上实跑出来的。换台机器、换个字体、改一下系统缩放,这些数字会变。变是对的,正因为会变,才需要布局器,别把文章里的数字当标准答案。
代码块的组织方式有两种,提前说明免得看混。
带 >>> 的是交互环境里敲的,一行一行能跟着试。整段的 .py 文件里带 MainLoop() 的那种,会真的弹出窗口,文章里只做语法检查,你在本地跑才能看到效果。剩下的脚本块都能直接跑,后面跟着的输出块是实跑结果。
2 创建应用程序
2.1 最小的那个程序
能弹出一个空窗口的 wxPython 程序,五行就够了。
python
# hello.py
import wx
app = wx.App()
frame = wx.Frame(None, title="你好,窗口", size=(400, 300))
frame.Show()
app.MainLoop()
存成 hello.py 跑起来,屏幕上会多一个 400 乘 300 的空窗口。关掉它,程序退出。
这五行一行都不能少,各管一摊。
第一行 wx.App() 建出应用程序对象。它必须比任何窗口都早创建,因为后面的控件要靠它初始化底层资源。
第二行 wx.Frame(None, ...) 建窗口,第一个参数是父窗口,None 表示没有父窗口,那它就是顶层窗口。title 是标题栏上的字,size 是窗口大小。
第三行 Show() 让窗口显示出来。wxPython 里新建的窗口默认是隐藏的,忘了这行,程序跑起来什么都没有,也不报错。这个坑我踩过不止一次。
第四行 MainLoop() 进事件循环,程序停在这儿。窗口关掉,循环结束,MainLoop() 返回,程序往下走然后退出。
2.2 wx.App 到底管什么
在交互环境里看一眼这个对象。
python
>>> import wx
>>> app = wx.App(False)
>>> frame = wx.Frame(None, title="第一个窗口")
>>> frame.GetTitle()
'第一个窗口'
>>> frame.GetSize()
wx.Size(400, 250)
>>> frame.GetMenuBar() is None
True
>>> frame.IsShown()
False
wx.App(False) 那个 False 是 redirect 参数,意思是别把 stdout 重定向到窗口,让 print 继续往终端输出。写 GUI 程序调试的时候这个参数挺重要,不然你的 print 全都看不见。
新建的 Frame 不给 size,默认是 400 乘 250。GetMenuBar() 返回 None,因为还没建菜单栏。IsShown() 是 False,印证了上一节说的窗口默认隐藏。
App 对象有三件事是别人替不了的。
一是初始化。底层那套图形库要先起来,之后才能创建窗口。你试试在 wx.App() 之前写 wx.Frame(None),会直接崩。
二是事件循环。MainLoop() 是它的方法。
三是善后。窗口都关掉之后,App 负责回收资源。
想知道循环什么时候开始、什么时候结束,可以子类化 wx.App,重写两个方法。
python
>>> class MyApp(wx.App):
... def OnInit(self):
... print("OnInit 开始")
... self.frame = wx.Frame(None, title="子类化的 App")
... self.SetTopWindow(self.frame)
... return True
... def OnExit(self):
... print("OnExit 收尾")
... return 0
...
>>> my = MyApp(False)
OnInit 开始
>>> my.GetTopWindow().GetTitle()
'子类化的 App'
OnInit 在循环开始前被调用,返回值很关键,返回 False 的话程序直接退出,窗口都不建。SetTopWindow 是指定主窗口,主窗口关掉时整个程序就结束。
小项目不用这么写,app = wx.App() 再加几行建窗口就够。等你的程序需要在启动时读配置、连数据库、决定要不要弹登录框,再换成子类化的写法,那些准备工作放进 OnInit 里正合适。
2.3 窗口的常用设置
python
>>> frame.SetTitle("改过的标题")
>>> frame.GetTitle()
'改过的标题'
>>> frame.SetSize((500, 320))
>>> frame.GetSize()
wx.Size(500, 320)
>>> frame.SetMinSize((300, 200))
>>> frame.GetMinSize()
wx.Size(300, 200)
标题、尺寸、最小尺寸,都成套出现。SetMinSize 定的是用户能拖到的最小大小,配合布局器用,能避免窗口被拖到控件挤成一团。
窗口还有一些常用操作,列在这儿。
| 方法 | 作用 |
|---|---|
SetTitle(text) / GetTitle() |
标题栏文字 |
SetSize((w, h)) / GetSize() |
整个窗口的大小,含标题栏和边框 |
GetClientSize() |
客户区大小,也就是能放控件的那部分 |
SetMinSize((w, h)) / SetMaxSize((w, h)) |
能被拖到的最小和最大尺寸 |
SetPosition((x, y)) / GetPosition() |
屏幕坐标,原点在屏幕左上角 |
Centre() |
挪到屏幕中间 |
SetIcon(icon) |
标题栏左上角那个小图标 |
Show() / Hide() |
显示和隐藏 |
Maximize() / Restore() |
最大化和还原 |
CreateStatusBar(n) |
建状态栏,n 是分几格 |
SetStatusText(text, i) |
往状态栏第 i 格写文字 |
Close() |
请求关闭,可以被事件处理器拦下来 |
GetSize() 和 GetClientSize() 是两个不同的数。窗口 400 乘 250 的时候,客户区只有 384 乘 211,差的那部分是标题栏、边框和菜单栏。算布局时用客户区。
状态栏用起来是这样。
python
>>> bar = frame.CreateStatusBar()
>>> frame.SetStatusText("就绪")
>>> bar.GetStatusText()
'就绪'
>>> bar.GetFieldsCount()
1
CreateStatusBar() 不给参数就是一格。要分格就给数字,然后用 SetStatusText(text, i) 往第 i 格写。
python
>>> frame2 = wx.Frame(None, title="两格")
>>> bar2 = frame2.CreateStatusBar(2)
>>> frame2.SetStatusText("左边", 0)
>>> frame2.SetStatusText("右边", 1)
>>> bar2.GetStatusText(1)
'右边'
>>> bar2.GetFieldsCount()
2
2.4 Panel 和父子关系
wxPython 里每个控件都要挂在一个父窗口上,创建时第一个参数就是它。这层关系决定了三件事,控件显示在什么地方、父窗口关掉时它跟着销毁、Tab 键的切换顺序。
python
>>> panel = wx.Panel(frame)
>>> btn = wx.Button(panel, label="点我")
>>> btn.GetParent() is panel
True
>>> type(wx.GetTopLevelParent(btn)).__name__
'Frame'
>>> len(panel.GetChildren())
1
>>> len(frame.GetChildren())
2
GetParent() 拿到直接父窗口,wx.GetTopLevelParent() 一直往上找到最外层那个 Frame。最后两行有意思,Panel 下面挂了 1 个控件,Frame 下面却有 2 个子对象,多出来的那个是上一节建的状态栏。
新手最容易写错的是这一行。
python
>>> wrong = wx.Button(frame, label="点我") # 挂到 Frame 上了,不是 Panel
>>> wrong.GetParent() is panel
False
>>> wx.GetTopLevelParent(wrong) is frame
True
控件建出来了,父窗口却是 Frame 不是 Panel。这种错不会崩,只是控件不显示在你想让它出现的地方,背景色和 Tab 顺序也不对。
更极端一点,父窗口直接给 None,wxPython 会当场抛错,信息是 all controls must have parents,翻译过来就是"控件都得有爹"。这条我在 4.3.1 上实测过,语句是 wx.Button(None, label="点我")。
窗口空空如也又查不出原因的时候,第一件事就是把控件的第一个参数挨个看一遍。
2.5 用 Frame 子类组织代码
控件一多,全都堆在全局作用域里就不好管了。常规做法是自己写一个 Frame 的子类,把建控件的代码放进 __init__,把响应函数写成方法。
python
import wx
class MyFrame(wx.Frame):
def __init__(self):
super().__init__(None, title="第二个窗口", size=(360, 240))
panel = wx.Panel(self)
self.label = wx.StaticText(panel, label="点一下按钮", pos=(20, 20))
self.btn = wx.Button(panel, label="点我", pos=(20, 60))
self.btn.Bind(wx.EVT_BUTTON, self.on_click)
self.clicks = 0
def on_click(self, event):
self.clicks += 1
self.label.SetLabel("点了 %d 次" % self.clicks)
app = wx.App(False)
frame = MyFrame()
print(frame.GetTitle())
print(frame.label.GetLabel())
print(frame.btn.GetLabel(), frame.btn.GetSize())
print("计数", frame.clicks)
text
第二个窗口
点一下按钮
点我 (75, 25)
计数 0
这段代码没有 Show() 也没有 MainLoop(),所以不会弹窗,只把几个初始值打出来。想看真窗口,在末尾补上 frame.Show() 和 app.MainLoop() 两行就行。
这么做的好处是把界面和逻辑收在一个类里。self.label、self.btn 存成属性,任何方法里都能改。self.clicks 存状态,比全局变量干净得多。事件绑定的 self.on_click 是个方法,wxPython 调用时会自动把事件对象作为第二个参数传进去,所以方法签名里必须有 event,哪怕用不上。
这一段到这儿为止,控件都是用 pos 参数硬摆的位置。那个写法有毛病,第 4 节会讲该换成什么。
3 常用控件
3.1 控件的三个共同点
wxPython 有几十种控件,摸清规律之后不用一个个背。
创建时第一个参数是父窗口 ,后面跟 id、label、pos、size、style、validator、name。日常用关键字参数写,顺序就不重要了。
python
>>> b1 = wx.Button(panel, label="确定", size=(80, 30))
>>> b2 = wx.Button(panel, -1, "确定", (10, 10), (80, 30))
>>> b1.GetLabel(), b2.GetLabel()
('确定', '确定')
>>> b2.GetPosition(), b2.GetSize()
(wx.Point(10, 10), wx.Size(80, 30))
id 一般用 wx.ID_ANY(值是 -1),让 wxPython 自己分配。只有菜单项和对话框按钮才需要指定固定 id,比如 wx.ID_OK、wx.ID_EXIT,因为要靠 id 区分点了哪个。
取值和设值成对出现 。文字类用 GetLabel 和 SetLabel,输入类用 GetValue 和 SetValue,选择类用 GetSelection 和 SetSelection。记住这个规律,新控件上手就能猜出方法名。
都能绑事件 。所有控件都有 Bind 方法,第 5 节讲。
3.2 显示文字和接收输入
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="文字与输入")
panel = wx.Panel(frame)
label = wx.StaticText(panel, label="用户名")
text = wx.TextCtrl(panel, value="张三")
note = wx.TextCtrl(panel, value="第一行\n第二行\n第三行",
style=wx.TE_MULTILINE)
pwd = wx.TextCtrl(panel, value="123456", style=wx.TE_PASSWORD)
ro = wx.TextCtrl(panel, value="只读的内容", style=wx.TE_READONLY)
print(label.GetLabel(), label.GetSize())
print(text.GetValue(), text.GetSize())
text.AppendText(",你好")
print(text.GetValue())
print("多行一共", note.GetNumberOfLines(), "行")
print("第二行是", repr(note.GetLineText(1)))
print("密码框取回", repr(pwd.GetValue()))
print("只读框能编辑吗", ro.IsEditable())
text
用户名 (37, 18)
张三 (111, 25)
张三,你好
多行一共 3 行
第二行是 '第二行'
密码框取回 '123456'
只读框能编辑吗 False
wx.StaticText 是一行文字,用户改不了,只能靠 SetLabel 改。它有个特点,显示的文字变了,控件尺寸会自动跟着变,不用手动调。
wx.TextCtrl 是输入框,靠 style 参数变出四种形态。
| 样式 | 效果 |
|---|---|
| 默认 | 单行输入框 |
wx.TE_MULTILINE |
多行,能回车换行 |
wx.TE_PASSWORD |
输入内容显示成圆点,GetValue 取到的还是原文 |
wx.TE_READONLY |
只读,能选中能复制,不能改 |
wx.TE_CENTER |
文字居中 |
wx.TE_PROCESS_ENTER |
按回车会触发 EVT_TEXT_ENTER 事件 |
样式能叠加,用竖线连起来。
python
>>> multi = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY)
>>> multi.IsMultiLine(), multi.IsEditable()
(True, False)
默认的单行输入框不会响应回车键。想做那种"输完直接回车提交"的效果,得加 wx.TE_PROCESS_ENTER,然后绑 wx.EVT_TEXT_ENTER,第 5 节的待办清单里就是这么干的。
多行输入框还有几个专属方法,GetNumberOfLines() 数行数,GetLineText(i) 取第 i 行,AppendText(s) 在末尾追加。
3.3 按钮
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="按钮")
panel = wx.Panel(frame)
ok = wx.Button(panel, label="确定", size=(80, 30))
cancel = wx.Button(panel, label="取消", size=(80, 30))
toggle = wx.ToggleButton(panel, label="暂停")
print(ok.GetLabel(), ok.GetSize(), ok.GetBestSize())
ok.SetLabel("保存")
print("改完叫", ok.GetLabel())
ok.Disable()
print("禁用后还能点吗", ok.IsEnabled())
ok.Enable()
print("恢复了", ok.IsEnabled())
print("开关按钮按下没", toggle.GetValue())
toggle.SetValue(True)
print("按下之后", toggle.GetValue())
text
确定 (80, 30) (80, 30)
改完叫 保存
禁用后还能点吗 False
恢复了 True
开关按钮按下没 False
按下之后 True
wx.Button 是最普通的按钮,点一下触发一次 EVT_BUTTON。SetDefault() 能把它设成默认按钮,那样按回车就等于点它,登录框上很有用。
wx.ToggleButton 是能按下去不弹起来的按钮,GetValue() 返回 True 或 False 表示当前是按下还是弹起。适合做"开始/暂停"这种切换。
GetBestSize() 返回控件"想要的"大小,也就是刚好装下内容时的尺寸。布局器算位置时会参考它。上面那个按钮给了固定 80 乘 30,所以 GetSize 和 GetBestSize 一样,如果没给 size,两个值也会一样,因为按钮创建时就按最佳尺寸定了。
3.4 让用户做选择
这一组控件方法最多,也最容易搞混。
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="选择类控件")
panel = wx.Panel(frame)
cb = wx.CheckBox(panel, label="记住我")
rb1 = wx.RadioButton(panel, label="男", style=wx.RB_GROUP)
rb2 = wx.RadioButton(panel, label="女")
ch = wx.Choice(panel, choices=["北京", "上海", "广州"])
lb = wx.ListBox(panel, choices=["Python", "Java", "Go"])
cbo = wx.ComboBox(panel, choices=["A", "B"], style=wx.CB_DROPDOWN)
print("复选框勾了吗", cb.GetValue())
cb.SetValue(True)
print("勾上之后", cb.GetValue())
print("单选默认选中", rb1.GetValue(), rb2.GetValue())
rb2.SetValue(True)
print("选了女之后", rb1.GetValue(), rb2.GetValue())
print("下拉选项", ch.GetItems())
print("当前选中", ch.GetSelection(), repr(ch.GetStringSelection()))
ch.SetSelection(1)
print("选了之后", ch.GetSelection(), ch.GetStringSelection())
ch.Append("深圳")
print("追加一项", ch.GetItems(), "共", ch.GetCount(), "项")
print("列表内容", lb.GetItems())
lb.SetSelection(0)
print("选中第", lb.GetSelection(), "项是", repr(lb.GetString(0)))
lb.Append("Rust")
lb.Delete(0)
print("追加又删掉第一项", lb.GetItems())
print("组合框选项", cbo.GetItems(), "当前值", repr(cbo.GetValue()))
cbo.SetValue("自己输入的内容")
print("可以直接打字", repr(cbo.GetValue()))
text
复选框勾了吗 False
勾上之后 True
单选默认选中 True False
选了女之后 False True
下拉选项 ['北京', '上海', '广州']
当前选中 -1 ''
选了之后 1 上海
追加一项 ['北京', '上海', '广州', '深圳'] 共 4 项
列表内容 ['Python', 'Java', 'Go']
选中第 0 项是 'Python'
追加又删掉第一项 ['Java', 'Go', 'Rust']
组合框选项 ['A', 'B'] 当前值 ''
可以直接打字 '自己输入的内容'
几个容易记混的地方。
CheckBox 用 GetValue() 返回布尔值 ,不是 IsChecked()。单选按钮也一样用 GetValue()。
RadioButton 靠 wx.RB_GROUP 分组 。同一组里只能选一个。给第二个、第三个按钮都别加这个样式,它们就自动归属到前面最近那个带 RB_GROUP 的组里。忘了加 RB_GROUP,所有单选按钮会变成一组,选了男就选不了女。
Choice 和 ComboBox 长得像,行为不同 。Choice 只能从列表里选,不能自己输入。ComboBox 既能选也能输入,适合做"选一个或者自己填"的效果。给 ComboBox 加 wx.CB_READONLY,它就变得跟 Choice 一样只能选。
ListBox 的 GetSelection() 没选中时返回 -1 ,也就是 wx.NOT_FOUND。写事件处理器时一定要先判断,直接拿 -1 去 GetString(-1) 会报错。
还有个 wx.CheckListBox,列表每一项前面带个复选框,选多项时特别好用。第 5 节的待办清单用的就是它。
python
>>> clb = wx.CheckListBox(panel, choices=["买牛奶", "写周报"])
>>> clb.Check(0, True) # 把第 0 项勾上
>>> clb.IsChecked(0) # 看第 0 项勾没勾
True
>>> clb.GetCheckedItems() # 所有勾上的行号
(0,)
3.5 数值和进度
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="数值控件")
panel = wx.Panel(frame)
sl = wx.Slider(panel, value=50, minValue=0, maxValue=100,
style=wx.SL_HORIZONTAL | wx.SL_LABELS)
sp = wx.SpinCtrl(panel, value="18", min=0, max=120)
ga = wx.Gauge(panel, range=100)
print("滑块", sl.GetValue(), "范围", sl.GetMin(), "到", sl.GetMax())
sl.SetValue(80)
print("拖到", sl.GetValue())
print("数字框", sp.GetValue())
sp.SetValue(20)
print("改成", sp.GetValue())
sp.SetRange(1, 10)
sp.SetValue(5)
print("改范围之后", sp.GetMin(), sp.GetMax(), sp.GetValue())
ga.SetValue(30)
print("进度条", ga.GetValue(), "/", ga.GetRange())
text
滑块 50 范围 0 到 100
拖到 80
数字框 18
改成 20
改范围之后 1 10 5
进度条 30 / 100
wx.SpinCtrl 是带上下箭头的数字输入框,比让用户直接往 TextCtrl 里敲数字省心,省掉了"用户输入了 abc"这种麻烦。它的参数是 min、max、value,注意 value 传的是字符串。
wx.Gauge 是进度条,只能看不能拖。跑耗时任务时用它告诉用户"还在跑"。有个细节,进度条要动起来,主线程不能被占住,第 5 节会讲为什么。
3.6 分组框,以及一个容易忽略的警告
wx.StaticBox 是那个带标题的方框,用来把相关控件框在一起。单独用它没什么用,要跟 wx.StaticBoxSizer 配合。
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="分组框", size=(300, 220))
panel = wx.Panel(frame)
panel.SetSize((300, 200))
box = wx.StaticBox(panel, label="登录信息")
sizer = wx.StaticBoxSizer(box, wx.VERTICAL)
user = wx.TextCtrl(box) # 注意这里,父窗口是 box 不是 panel
pwd = wx.TextCtrl(box, style=wx.TE_PASSWORD)
sizer.Add(user, 0, wx.EXPAND | wx.ALL, 6)
sizer.Add(pwd, 0, wx.EXPAND | wx.ALL, 6)
panel.SetSizer(sizer)
panel.Layout()
print("分组框标题", box.GetLabel())
print("用户名框", user.GetPosition(), user.GetSize())
print("密码框", pwd.GetPosition(), pwd.GetSize())
text
分组框标题 登录信息
用户名框 (11, 25) (278, 25)
密码框 (11, 62) (278, 25)
这个坑得单独拎出来说。放进 StaticBoxSizer 的控件,父窗口要写成那个 StaticBox,而不是外层的 Panel。写错了程序照样能跑,但 wxPython 会在终端里抱怨一句。
kotlin
Element wxTextCtrl@... of wxStaticBoxSizer should be created as
child of its wxStaticBox and not of wxPanel@...
这句话的意思是"你的控件该挂到 StaticBox 上,不是 Panel 上"。在某些系统上,挂错了会导致控件画在框的外面,或者 Tab 键顺序乱掉。看到这句警告,回去改父窗口就行。
3.7 菜单栏和状态栏
菜单栏不算控件,它是 Frame 的一部分。
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="菜单", size=(360, 240))
bar = wx.MenuBar()
file_menu = wx.Menu()
item_open = file_menu.Append(wx.ID_OPEN, "打开(&O)")
file_menu.AppendSeparator()
item_exit = file_menu.Append(wx.ID_EXIT, "退出(&Q)")
bar.Append(file_menu, "文件(&F)")
frame.SetMenuBar(bar)
print("菜单项文字", item_open.GetItemLabel())
print("菜单项的 id", item_open.GetId(), "| wx.ID_OPEN =", wx.ID_OPEN)
print("退出项的 id", item_exit.GetId(), "| wx.ID_EXIT =", wx.ID_EXIT)
print("菜单里有几个条目", file_menu.GetMenuItemCount())
print("菜单栏上有几个菜单", bar.GetMenuCount())
print("菜单栏标签", bar.GetMenuLabel(0))
text
菜单项文字 打开(&O)
菜单项的 id 5000 | wx.ID_OPEN = 5000
退出项的 id 5006 | wx.ID_EXIT = 5006
菜单里有几个条目 3
菜单栏上有几个菜单 1
菜单栏标签 文件(&F)
几个约定要记。
&O 里的 & 不是真的要显示,它定义快捷键,用户按 Alt+O 就等于点这一项。菜单上会显示成"打开(O)"。
菜单项用标准 id,wx.ID_OPEN、wx.ID_EXIT、wx.ID_ABOUT、wx.ID_SAVE 这些都是 wxPython 预定义好的,值是固定的(wx.ID_OPEN 是 5000,wx.ID_EXIT 是 5006)。用标准 id 的好处是绑事件时能直接指定,不用记自己起的编号。
AppendSeparator() 加一条分隔线,它也算一个条目,所以上面那个菜单 GetMenuItemCount() 是 3。
菜单的事件绑在 Frame 上,不是绑在菜单项上。第 5 节细说。
3.8 常用控件速查
一张表收下这节讲的全部控件。
| 控件 | 干什么 | 取值 | 设值 | 常用事件 |
|---|---|---|---|---|
StaticText |
显示一行文字 | GetLabel() |
SetLabel(s) |
一般不绑 |
TextCtrl |
单行或多行输入 | GetValue() |
SetValue(s) |
EVT_TEXT、EVT_TEXT_ENTER |
Button |
按钮 | GetLabel() |
SetLabel(s) |
EVT_BUTTON |
ToggleButton |
能按下去的按钮 | GetValue() |
SetValue(b) |
EVT_TOGGLEBUTTON |
CheckBox |
复选框 | GetValue() |
SetValue(b) |
EVT_CHECKBOX |
RadioButton |
单选按钮 | GetValue() |
SetValue(b) |
EVT_RADIOBUTTON |
Choice |
下拉列表,只能选 | GetStringSelection() |
SetSelection(i) |
EVT_CHOICE |
ComboBox |
下拉列表,能选能填 | GetValue() |
SetValue(s) |
EVT_COMBOBOX、EVT_TEXT |
ListBox |
列表 | GetSelection() |
SetSelection(i) |
EVT_LISTBOX |
CheckListBox |
带复选框的列表 | IsChecked(i) |
Check(i, b) |
EVT_CHECKLISTBOX |
Slider |
滑块 | GetValue() |
SetValue(n) |
EVT_SLIDER |
SpinCtrl |
带箭头的数字框 | GetValue() |
SetValue(n) |
EVT_SPINCTRL |
Gauge |
进度条 | GetValue() |
SetValue(n) |
没有 |
StaticBox |
分组框 | GetLabel() |
SetLabel(s) |
没有 |
3.9 所有控件都有的方法
有些方法定义在 wx.Window 上,控件全都能用。
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="通用方法")
panel = wx.Panel(frame)
label = wx.StaticText(panel, label="提示文字")
btn = wx.Button(panel, label="按钮")
panel.SetBackgroundColour(wx.Colour(240, 240, 240))
label.SetForegroundColour("red")
label.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT,
wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD))
label.SetToolTip("鼠标停一会儿会看到这行字")
print("面板背景色", panel.GetBackgroundColour())
print("文字颜色", label.GetForegroundColour())
print("字号", label.GetFont().GetPointSize(), "粗细", label.GetFont().GetWeight())
print("是不是粗体", label.GetFont().GetWeight() == wx.FONTWEIGHT_BOLD)
print("提示文字", label.GetToolTip().GetTip())
btn.Hide()
print("藏起来了", btn.IsShown())
btn.Show()
btn.Disable()
print("禁用后", btn.IsEnabled())
btn.Enable()
print("名字", btn.GetName(), "| 类型", type(btn).__name__)
text
面板背景色 (240, 240, 240, 255)
文字颜色 (255, 0, 0, 255)
字号 12 粗细 700
是不是粗体 True
提示文字 鼠标停一会儿会看到这行字
藏起来了 False
禁用后 False
名字 button | 类型 Button
| 方法 | 作用 |
|---|---|
Enable() / Disable() |
启用和禁用,禁用的控件变灰点不动 |
Show() / Hide() |
显示和隐藏 |
IsEnabled() / IsShown() |
查询当前状态 |
SetBackgroundColour(c) |
背景色,参数是 wx.Colour 或颜色名字符串 |
SetForegroundColour(c) |
前景色,也就是文字颜色 |
SetFont(f) |
字体,wx.Font(字号, 字族, 样式, 粗细) |
SetToolTip(s) |
鼠标悬停提示 |
SetFocus() |
把输入焦点移过来 |
GetParent() |
拿父窗口 |
Destroy() |
销毁控件,从界面上彻底移除 |
Refresh() |
请求重画 |
GetBestSize() |
控件理想的大小 |
SetMinSize((w, h)) |
最小尺寸,布局器不会把它压得比这更小 |
Disable() 这个很实用。表单没填完的时候把提交按钮禁掉,比等用户点了再弹窗报错舒服得多。
4 BoxSizer布局
前面几节的控件都是用 pos 参数摆的位置。那个写法该扔掉了,这节讲该换成什么。
4.1 用坐标摆控件会出什么事
先做个实验,同样的窗口,一个用坐标,一个用布局器,然后把窗口从 300 宽拉到 600 宽。
python
import wx
app = wx.App(False)
# 用坐标摆
frame1 = wx.Frame(None, title="坐标", size=(300, 200))
panel1 = wx.Panel(frame1)
box1 = wx.TextCtrl(panel1, pos=(10, 10), size=(200, 25))
frame1.Layout()
print("坐标摆的,窗口 300 宽", box1.GetSize())
frame1.SetSize((600, 200))
frame1.SendSizeEvent()
print("坐标摆的,窗口 600 宽", box1.GetSize())
# 用布局器摆
frame2 = wx.Frame(None, title="布局器", size=(300, 200))
panel2 = wx.Panel(frame2)
sizer = wx.BoxSizer(wx.HORIZONTAL)
box2 = wx.TextCtrl(panel2)
sizer.Add(box2, 1, wx.EXPAND | wx.ALL, 10)
panel2.SetSizer(sizer)
frame2.Layout()
print("布局器摆的,窗口 300 宽", box2.GetSize())
frame2.SetSize((600, 200))
frame2.SendSizeEvent()
print("布局器摆的,窗口 600 宽", box2.GetSize())
# 忘了 SetSizer 会怎样
frame3 = wx.Frame(None, title="忘了设布局", size=(300, 200))
panel3 = wx.Panel(frame3)
btn_a = wx.Button(panel3, label="A")
btn_b = wx.Button(panel3, label="B")
print("忘了 SetSizer,两个按钮都在", btn_a.GetPosition(), btn_b.GetPosition())
text
坐标摆的,窗口 300 宽 (200, 25)
坐标摆的,窗口 600 宽 (200, 25)
布局器摆的,窗口 300 宽 (264, 141)
布局器摆的,窗口 600 宽 (564, 141)
忘了 SetSizer,两个按钮都在 (0, 0) (0, 0)
坐标摆的那个,窗口从 300 拉到 600,输入框还是 200 宽,右边空出一大片。布局器摆的那个从 264 变成 564,跟着窗口一起长。
这还只是拉窗口。真到用户机器上,问题比这多,系统字体比你的大、缩放开到 150%、程序跑到 macOS 上、界面要翻译成英文,每一种都够你把所有坐标重算一遍。
最后那两行更常见。两个按钮都待在 (0, 0),因为控件创建时不给 pos 就默认在原点,而你没有调用 SetSizer 告诉它们该去哪。窗口一片空白或者控件全叠在左上角,九成是这个原因。
4.2 布局器不止一种
wxPython 的布局器都叫 Sizer,各有各的用处。
| 布局器 | 怎么排 | 什么时候用它 |
|---|---|---|
wx.BoxSizer |
一行或者一列 | 用得最多,几乎所有界面都有它 |
wx.StaticBoxSizer |
带标题框的一行或一列 | 把相关控件框成一组 |
wx.GridSizer |
固定行列,每格一样大 | 计算器键盘、九宫格 |
wx.FlexGridSizer |
行列可以不等宽,能指定某列跟着长 | 表单,左边标签右边输入框 |
wx.GridBagSizer |
按行列坐标放,能跨行跨列 | 复杂的表单 |
wx.WrapSizer |
排不下自动换行 | 工具栏、标签云 |
这节标题是 BoxSizer,因为它是地基,其它几种要么建立在它之上,要么只在特定场合用一次。把 BoxSizer 的三个参数吃透,剩下的看一眼文档就能上手。
4.3 Add 的三个参数
往 BoxSizer 里放东西只有一条命令。
css
sizer.Add(控件, proportion, flag, border)
四个参数各有分工。
控件,也可以是另一个 sizer,这是嵌套的关键。
proportion,占几份。0 表示不抢空间,用自己的理想大小。大于 0 时,sizer 把多余的空间按份数分下去。
flag,一堆行为开关的组合,管对齐、拉伸、边距方向。
border,边距像素数,得配合 flag 里的方向常量才生效。
BoxSizer 建的时候要选方向,方向定了之后,控件排队的方向叫主轴,另一个方向叫副轴。
scss
wx.BoxSizer(wx.VERTICAL) wx.BoxSizer(wx.HORIZONTAL)
┌───────────────┐ ┌───────────────────────┐
│ 控件 一 │ │ 一 │ 二 │ 三 │
├───────────────┤ │ │ │ │
│ 控件 二 │ │ │ │ │
├───────────────┤ └───────────────────────┘
│ 控件 三 │
└───────────────┘
主轴是竖直方向 主轴是水平方向
副轴是水平方向 副轴是竖直方向
proportion 分的是主轴方向上的多余空间,wx.EXPAND 管的是副轴方向撑不撑满。这个区分搞清楚,布局就明白一大半了。
4.4 proportion 到底怎么分
还是做实验。容器宽 400,放三个按钮,比例分别是 1 比 2 比 1。
python
import wx
app = wx.App(False)
def layout(props):
frame = wx.Frame(None, title="比例", size=(400, 120))
panel = wx.Panel(frame)
panel.SetSize((400, 100))
sizer = wx.BoxSizer(wx.HORIZONTAL)
for name, prop in zip(("左", "中", "右"), props):
sizer.Add(wx.Button(panel, label=name), prop, wx.EXPAND | wx.ALL, 5)
panel.SetSizer(sizer)
panel.Layout()
return sizer
for item in layout((1, 2, 1)).GetChildren():
w = item.GetWindow()
print("%s 占 %3d 像素,从 x=%3d 开始"
% (w.GetLabel(), item.GetSize().GetWidth(), item.GetPosition().x))
print()
for item in layout((0, 0, 0)).GetChildren():
w = item.GetWindow()
print("%s 占 %3d 像素,从 x=%3d 开始"
% (w.GetLabel(), item.GetSize().GetWidth(), item.GetPosition().x))
text
左 占 100 像素,从 x= 0 开始
中 占 200 像素,从 x=100 开始
右 占 100 像素,从 x=300 开始
左 占 85 像素,从 x= 0 开始
中 占 85 像素,从 x= 85 开始
右 占 85 像素,从 x=170 开始
按比例分时,总份数是 4,每份 100 像素,于是成了 100、200、100。
rust
|<-- 100 -->|<------ 200 ------>|<-- 100 -->|
┌───────────┬───────────────────┬───────────┐
│ 左 │ 中 │ 右 │ 容器宽 400
└───────────┴───────────────────┴───────────┘
proportion 1 2 1
全是 0 时,每个按钮只拿自己需要的 85 像素,加起来 255,剩下 145 像素空在右边,谁都不给。
这里有个细节值得留意。那 85 像素是"格子"的宽度,里面已经包含了上下左右各 5 像素的边距,按钮本身只有 75 宽。sizer 的 GetSize() 报的是格子尺寸,控件的 GetSize() 报的是控件尺寸,差一个边距。
想验证的话,把两个值一起打出来就行。
python
import wx
app = wx.App(False)
f = wx.Frame(None, title="边距", size=(300, 100))
p = wx.Panel(f)
p.SetSize((300, 60))
s = wx.BoxSizer(wx.HORIZONTAL)
btn = wx.Button(p, label="A")
s.Add(btn, 1, wx.EXPAND | wx.ALL, 10)
p.SetSizer(s)
p.Layout()
print("格子", s.GetChildren()[0].GetSize())
print("控件", btn.GetSize())
text
格子 (300, 60)
控件 (280, 40)
4.5 flag 那些常量
flag 是位标记,能用竖线叠加。常用的就这些。
| 常量 | 作用 |
|---|---|
wx.LEFT / wx.RIGHT / wx.TOP / wx.BOTTOM |
哪个方向留边距 |
wx.ALL |
四个方向都留,值是 240 |
wx.EXPAND |
副轴方向撑满格子 |
wx.ALIGN_LEFT / wx.ALIGN_RIGHT |
副轴上靠左或靠右 |
wx.ALIGN_TOP / wx.ALIGN_BOTTOM |
副轴上靠上或靠下 |
wx.ALIGN_CENTER |
副轴上居中,值是 2304 |
wx.ALIGN_CENTER_HORIZONTAL / wx.ALIGN_CENTER_VERTICAL |
只在一个方向上居中 |
wx.SHAPED |
拉伸时保持宽高比 |
wx.FIXED_MINSIZE |
控件的最小尺寸不许被压缩 |
wx.RESERVE_SPACE_EVEN_IF_HIDDEN |
控件隐藏时也占着位置 |
wx.ALL 的值是 240,因为它是 16 + 32 + 64 + 128 这四个方向常量相加的结果。想留三边不留下边,写 wx.LEFT | wx.RIGHT | wx.TOP 就行。
wx.EXPAND 和不加的区别,还有右对齐的效果,看实测。
python
import wx
app = wx.App(False)
def make(title, flag_text, flag_btn):
frame = wx.Frame(None, title=title, size=(300, 200))
panel = wx.Panel(frame)
panel.SetSize((300, 160))
sizer = wx.BoxSizer(wx.VERTICAL)
edit = wx.TextCtrl(panel)
btn = wx.Button(panel, label="确定")
sizer.Add(edit, 0, flag_text | wx.ALL, 5)
sizer.Add(btn, 0, flag_btn | wx.ALL, 5)
panel.SetSizer(sizer)
panel.Layout()
return edit, btn
edit1, btn1 = make("什么都不加", 0, 0)
print("什么都不加 输入框", edit1.GetPosition(), edit1.GetSize())
print("什么都不加 按钮 ", btn1.GetPosition(), btn1.GetSize())
edit2, btn2 = make("加了 flag", wx.EXPAND, wx.ALIGN_RIGHT)
print("加 EXPAND 输入框", edit2.GetPosition(), edit2.GetSize())
print("加右对齐 按钮 ", btn2.GetPosition(), btn2.GetSize())
text
什么都不加 输入框 (5, 5) (111, 25)
什么都不加 按钮 (5, 40) (75, 25)
加 EXPAND 输入框 (5, 5) (290, 25)
加右对齐 按钮 (220, 40) (75, 25)
垂直排列时,副轴是水平方向。不加 flag,输入框保持自己的 111 宽,右边空着。加了 wx.EXPAND,输入框撑到 290,也就是容器 300 减去两边各 5 的边距。按钮加了 wx.ALIGN_RIGHT,从 x=5 挪到了 x=220。
wx.EXPAND 和 wx.ALIGN_CENTER 是互斥的,一个要撑满一个要居中,同时加上以 EXPAND 为准,居中的那条会被忽略。
4.6 嵌套,用 sizer 套 sizer
真正的界面不可能一个 sizer 摆平,都是一层套一层。拿登录框当例子。
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="登录", size=(380, 260))
panel = wx.Panel(frame)
outer = wx.BoxSizer(wx.VERTICAL)
grid = wx.FlexGridSizer(3, 2, 10, 10)
grid.AddGrowableCol(1, 1)
user = wx.TextCtrl(panel)
pwd = wx.TextCtrl(panel, style=wx.TE_PASSWORD)
city = wx.Choice(panel, choices=["北京", "上海", "广州"])
for text, ctrl in (("用户名", user), ("密码", pwd), ("城市", city)):
grid.Add(wx.StaticText(panel, label=text), 0, wx.ALIGN_CENTER_VERTICAL)
grid.Add(ctrl, 0, wx.EXPAND)
outer.Add(grid, 0, wx.EXPAND | wx.ALL, 15)
remember = wx.CheckBox(panel, label="记住我")
outer.Add(remember, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 15)
row = wx.BoxSizer(wx.HORIZONTAL)
row.AddStretchSpacer(1)
login = wx.Button(panel, label="登录")
cancel = wx.Button(panel, label="取消")
row.Add(login, 0, wx.RIGHT, 8)
row.Add(cancel, 0)
outer.Add(row, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 15)
panel.SetSizer(outer)
frame.Layout()
print("窗口", frame.GetSize(), "客户区", frame.GetClientSize())
print("这个布局需要的最小尺寸", outer.GetMinSize())
for name, ctrl in (("用户名框", user), ("密码框", pwd), ("城市下拉", city),
("记住我", remember), ("登录", login), ("取消", cancel)):
print(" %-8s" % name, ctrl.GetPosition(), ctrl.GetSize())
text
窗口 (380, 260) 客户区 (364, 221)
这个布局需要的最小尺寸 (188, 197)
用户名框 (62, 15) (287, 25)
密码框 (62, 50) (287, 25)
城市下拉 (62, 85) (287, 25)
记住我 (15, 125) (60, 17)
登录 (191, 157) (75, 25)
取消 (274, 157) (75, 25)
跑出来的窗口长这样,坐标是我机器上实跑的结果。
ini
┌─ 登录 ──────────────────────────────────── □ ─ × ─┐
│ │
│ 用户名 ┌──────────────────────────────────┐ │
│ │ x=62 y=15 宽 287 高 25 │ │
│ 密 码 ├──────────────────────────────────┤ │
│ │ x=62 y=50 │ │
│ 城 市 ├──────────────────────────────────┤ │
│ │ x=62 y=85 │ │
│ │
│ ☐ 记住我 │
│ x=15 y=125 │
│ │
│ ┌──────┐ ┌──────┐ │
│ │ 登录 │ │ 取消 │ │
│ └──────┘ └──────┘ │
│ x=191 x=274 │
│ y=157 │
└────────────────────────────────────────────────────┘
结构是三层。
scss
outer (垂直 BoxSizer)
├─ grid (FlexGridSizer 3 行 2 列) 表单区
├─ remember (复选框) 单独一行
└─ row (水平 BoxSizer) 按钮行
├─ 弹簧 (AddStretchSpacer)
├─ 登录
└─ 取消
最外层是个垂直的 BoxSizer,管三块内容从上往下排。中间的表单区是个 FlexGridSizer,三行两列,左边标签右边输入框,AddGrowableCol(1, 1) 让第二列吃掉多余宽度,所以三个输入框都是 287 宽,跟容器一样长。底部的按钮行是个水平 BoxSizer,前面塞了个弹簧把两个按钮推到右边。
sizer.Add(...) 里塞另一个 sizer,就完成了嵌套。嵌套层数没有限制,实际项目里三四层很常见。
4.7 弹簧和固定间隔
按钮行里用到了 AddStretchSpacer,它会产生一个看不见的、可以伸缩的空白。跟它的兄弟 AddSpacer 对比一下。
python
import wx
app = wx.App(False)
def row(use_stretch, gap):
frame = wx.Frame(None, title="间隔", size=(400, 120))
panel = wx.Panel(frame)
panel.SetSize((400, 100))
sizer = wx.BoxSizer(wx.HORIZONTAL)
if use_stretch:
sizer.AddStretchSpacer(1)
ok = wx.Button(panel, label="登录")
cancel = wx.Button(panel, label="取消")
sizer.Add(ok, 0, wx.ALL, 5)
sizer.AddSpacer(gap)
sizer.Add(cancel, 0, wx.ALL, 5)
panel.SetSizer(sizer)
panel.Layout()
return ["%s x=%d" % (w.GetLabel(), w.GetPosition().x) for w in (ok, cancel)]
print("只加 20 像素固定间隔", row(False, 20))
print("前面加弹簧再加间隔 ", row(True, 20))
text
只加 20 像素固定间隔 ['登录 x=5', '取消 x=110']
前面加弹簧再加间隔 ['登录 x=215', '取消 x=320']
AddSpacer(n) 是死的数字,窗口怎么变它都是 n 像素。AddStretchSpacer(prop) 是按比例抢空间的,窗口拉宽它跟着变宽,所以能把后面的控件一路推到边上。
想在两个控件中间塞弹簧把它们顶到两头,就在中间加。
scss
sizer.Add(ok, 0, wx.ALL, 5)
sizer.AddStretchSpacer(1)
sizer.Add(cancel, 0, wx.ALL, 5)
想让一组控件在容器里居中,两边各放一个弹簧。
scss
sizer.AddStretchSpacer(1)
sizer.Add(btn, 0, wx.ALL, 5)
sizer.AddStretchSpacer(1)
4.8 Fit 让窗口刚好装下
布局器知道它最少需要多大,问一句就能拿到。
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="Fit")
panel = wx.Panel(frame)
sizer = wx.BoxSizer(wx.VERTICAL)
sizer.Add(wx.TextCtrl(panel), 0, wx.EXPAND | wx.ALL, 10)
panel.SetSizer(sizer)
panel.Layout()
print("Fit 之前窗口", frame.GetSize())
print("布局器的最小尺寸", sizer.GetMinSize())
frame.Fit()
print("Fit 之后窗口", frame.GetSize())
print("Fit 之后客户区", frame.GetClientSize())
text
Fit 之前窗口 (400, 250)
布局器的最小尺寸 (131, 45)
Fit 之后窗口 (147, 84)
Fit 之后客户区 (131, 45)
Fit() 把窗口缩到刚好装下所有控件。默认的 400 乘 250 对于一个输入框来说太大了,Fit() 之后变成 147 乘 84,看上去就正常了。
SetSizerAndFit(sizer) 是 SetSizer(sizer) 加 Fit() 两步合并成一步,写起来省一行。
有一点要注意,Fit() 只保证窗口够大,不保证控件不会被压缩。想让某个控件在窗口变小时保持原始大小,用 SetMinSize 定死,或者加 wx.FIXED_MINSIZE 这个 flag。
4.9 表格类的三种
表单用 FlexGridSizer,格子大小一致的用 GridSizer,跨行跨列的用 GridBagSizer。三个放一起跑一遍。
python
import wx
app = wx.App(False)
def dump(sizer, title):
print(title)
for item in sizer.GetChildren():
w = item.GetWindow()
text = w.GetLabel() if w.GetLabel() else type(w).__name__
print(" %-12s" % text, w.GetPosition(), w.GetSize())
# GridSizer,每格一样大
f1 = wx.Frame(None, title="Grid", size=(360, 160))
p1 = wx.Panel(f1)
p1.SetSize((360, 140))
gs = wx.GridSizer(2, 3, 5, 5)
for i in range(6):
gs.Add(wx.Button(p1, label=str(i + 1)), 0, wx.EXPAND)
p1.SetSizer(gs)
p1.Layout()
dump(gs, "GridSizer 2 行 3 列,容器 360 乘 140")
# FlexGridSizer,第二列跟着长
f2 = wx.Frame(None, title="FlexGrid", size=(360, 200))
p2 = wx.Panel(f2)
p2.SetSize((360, 180))
fg = wx.FlexGridSizer(3, 2, 8, 8)
fg.AddGrowableCol(1, 1)
for label in ("用户名", "密码", "邮箱"):
fg.Add(wx.StaticText(p2, label=label), 0, wx.ALIGN_CENTER_VERTICAL)
fg.Add(wx.TextCtrl(p2), 0, wx.EXPAND)
p2.SetSizer(fg)
p2.Layout()
dump(fg, "FlexGridSizer 3 行 2 列,第二列可伸缩")
# GridBagSizer,能跨行跨列
f3 = wx.Frame(None, title="GridBag")
p3 = wx.Panel(f3)
p3.SetSize((360, 160))
gb = wx.GridBagSizer(6, 6)
gb.Add(wx.StaticText(p3, label="标题"), (0, 0), flag=wx.ALIGN_CENTER_VERTICAL)
gb.Add(wx.TextCtrl(p3), (0, 1), span=(1, 2), flag=wx.EXPAND)
gb.Add(wx.StaticText(p3, label="内容"), (1, 0), flag=wx.ALIGN_CENTER_VERTICAL)
gb.Add(wx.TextCtrl(p3, style=wx.TE_MULTILINE), (1, 1), span=(2, 2), flag=wx.EXPAND)
gb.AddGrowableCol(1)
gb.AddGrowableRow(2)
p3.SetSizer(gb)
p3.Layout()
dump(gb, "GridBagSizer,内容框跨了两行")
text
GridSizer 2 行 3 列,容器 360 乘 140
1 (0, 0) (116, 67)
2 (121, 0) (116, 67)
3 (242, 0) (116, 67)
4 (0, 72) (116, 67)
5 (121, 72) (116, 67)
6 (242, 72) (116, 67)
FlexGridSizer 3 行 2 列,第二列可伸缩
用户名 (0, 3) (37, 18)
TextCtrl (45, 0) (315, 25)
密码 (0, 36) (25, 18)
TextCtrl (45, 33) (315, 25)
邮箱 (0, 69) (25, 18)
TextCtrl (45, 66) (315, 25)
GridBagSizer,内容框跨了两行
标题 (0, 3) (25, 18)
TextCtrl (31, 0) (329, 25)
内容 (0, 31) (25, 18)
TextCtrl (31, 31) (329, 129)
GridSizer 那六个格子都是 116 乘 67,360 减去两个 5 像素间隔再除以 3,等于 116.67,取整成 116。格子一样大是它的特点,也是它的限制,放个特别长的按钮进去它不会变宽。
FlexGridSizer 的第二列被 AddGrowableCol(1, 1) 指定成可伸缩,于是三个输入框都拿到 315 宽,而第一列保持标签需要的 37。
GridBagSizer 的 Add 多了两个参数,pos 是行列坐标 (行, 列),span 是占几行几列。上面那个多行输入框占了 (1, 1) 起步的两行两列,所以高度是 129,比单行输入框高出一截。
4.10 布局出问题时的排查顺序
窗口长得不对,按这个顺序查,能省不少时间。
- 有没有调
SetSizer。忘了的话所有控件都在(0, 0)。 - 控件的父窗口写对了没有。放进
StaticBoxSizer的控件要挂在那個StaticBox上。 - 加完控件之后有没有
Layout()。动态添加控件后必须调一次,不然新控件不显示。 proportion是不是忘了给。想让控件跟着窗口长,得给大于 0 的值。- 副轴方向是不是漏了
wx.EXPAND。垂直 sizer 里的控件不会自动变宽。 - 边距方向有没有写。
border数字给了但 flag 里没有wx.ALL或具体方向,边距是 0。 - 窗口有没有定死大小。给了固定
size又想让内容撑开,先用Fit()试试。 - 嵌套的层级对不对。一个容器只能有一个主 sizer,别给同一个 Panel 设两次。
5 事件处理
控件摆好了,点下去得有反应。这节讲怎么让控件响应你的操作。
5.1 一次事件里有三个角色
事件源,出事的那个控件,比如被点的按钮。
事件对象,wxPython 包装出来的一个对象,带着这次事件的全部细节,鼠标在哪、按了哪个键、输入框现在是什么内容。
事件处理器 ,你写的那个函数,参数固定是 event。
三者通过 Bind 连起来。
php
self.btn.Bind(wx.EVT_BUTTON, self.on_click)
# 事件源 事件类型 处理器
wx.EVT_BUTTON 是事件类型,一个 PyEventBinder 对象,不是字符串也不是整数。真正派发事件时用的类型编号在它的 typeId 属性里,wx.EVT_BUTTON.typeId 是 10010。
5.2 Bind 的四种写法
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="绑定", size=(300, 200))
panel = wx.Panel(frame)
btn = wx.Button(panel, label="点我", pos=(20, 20))
log = []
def fire(win, evt):
return win.GetEventHandler().ProcessEvent(evt)
def make_event(win):
e = wx.CommandEvent(wx.EVT_BUTTON.typeId, win.GetId())
e.SetEventObject(win)
return e
btn.Bind(wx.EVT_BUTTON, lambda e: log.append("写法一,绑在控件上"))
fire(btn, make_event(btn))
print(log)
text
['写法一,绑在控件上']
第一种是最常见的,谁出事绑谁。
第二种是 self.Bind,效果跟第一种一样,写在 Frame 子类里时用 self.Bind 更顺手,因为 self 指的还是那个按钮。
第三种绑在父窗口上,靠 source 参数指定关心哪个控件。
scss
frame.Bind(wx.EVT_BUTTON, handler, btn)
第四种按 id 绑,适合菜单和动态生成的控件。
ini
frame.Bind(wx.EVT_MENU, on_exit, id=wx.ID_EXIT)
frame.Bind(wx.EVT_BUTTON, handler, id=100, id2=200) # id 在 100 到 200 之间的都归它
后两种为什么能生效,5.4 节讲。
5.3 事件对象里有什么
所有事件对象都有这几个方法。
| 方法 | 返回 |
|---|---|
GetEventObject() |
事件源,那个控件 |
GetId() |
控件的 id |
GetEventType() |
事件类型编号 |
Skip() |
让事件继续往下传 |
GetSkipped() |
有没有被 Skip 过 |
不同类型的事件还带着自己的信息。
| 事件 | 专属信息 |
|---|---|
EVT_TEXT |
GetString() 拿到输入框当前的文字 |
EVT_CHECKBOX |
IsChecked() 有没有勾上 |
EVT_CHOICE / EVT_COMBOBOX |
GetSelection() 行号,GetString() 文字 |
EVT_LISTBOX |
GetSelection() 行号,GetString() 文字 |
EVT_CHECKLISTBOX |
GetSelection() 被点的那一行 |
EVT_SLIDER |
GetInt() 当前值 |
EVT_KEY_DOWN |
GetKeyCode() 键码,GetModifiers() 修饰键 |
EVT_MOTION / EVT_LEFT_DOWN |
GetPosition() 鼠标坐标 |
EVT_MENU |
GetId() 点了哪一项 |
写处理器时拿不准事件里有什么,就先 print(dir(event)) 看一眼,或者直接把整个对象打出来。
5.4 事件会往上传,走多远看 Skip
wxPython 的事件分两类。命令事件(wx.CommandEvent 及其子类)会沿着控件树往上传,鼠标键盘这类事件不会。
往上传这件事有个前提,处理完要调 Skip()。不调,事件就停在你这儿。三种情况对比一下。
python
import wx
app = wx.App(False)
def once(skip_btn, skip_panel):
frame = wx.Frame(None, title="冒泡")
panel = wx.Panel(frame)
button = wx.Button(panel, label="点我")
log = []
def on_btn(e):
log.append("按钮")
if skip_btn:
e.Skip()
def on_panel(e):
log.append("面板")
if skip_panel:
e.Skip()
def on_frame(e):
log.append("窗口")
button.Bind(wx.EVT_BUTTON, on_btn)
panel.Bind(wx.EVT_BUTTON, on_panel)
frame.Bind(wx.EVT_BUTTON, on_frame)
e = wx.CommandEvent(wx.EVT_BUTTON.typeId, button.GetId())
e.SetEventObject(button)
button.GetEventHandler().ProcessEvent(e)
return log
print("谁都不调 Skip ", once(False, False))
print("只有按钮调 Skip ", once(True, False))
print("按钮面板都调 Skip", once(True, True))
text
谁都不调 Skip ['按钮']
只有按钮调 Skip ['按钮', '面板']
按钮面板都调 Skip ['按钮', '面板', '窗口']
第一次只有按钮的处理器跑了,因为它在处理完没有放行。第二次按钮调了 Skip,事件接着传到面板,面板没放行,到此为止。第三次两层都放行,一路传到窗口。
sql
窗口的处理器 ← 第三次到这里
▲
│ Skip
面板的处理器 ← 第二次到这里
▲
│ Skip
按钮的处理器 ← 第一次停在这儿
▲
│
用户点了按钮
这个机制解释了 5.2 节里后两种写法为什么成立。事件从按钮出发,一路往上冒,冒到窗口时窗口上的处理器就被触发了。窗口上没写 source 也没写 id,就接收所有冒上来的同类事件。写了,就只接收来自指定控件或者指定 id 范围的那几个。
Skip() 还有个用法是保留控件的默认行为。比如在 EVT_CLOSE 里弹出"确定要退出吗",用户选确定就 Skip() 让它真的关掉,选取消就什么都不做,窗口关不掉。
python
import wx
app = wx.App(False)
asked = []
def on_close(e):
asked.append("收到关闭请求")
# 不调 Skip,这一步窗口关不掉
frame = wx.Frame(None, title="关不掉的窗口")
frame.Bind(wx.EVT_CLOSE, on_close)
frame.Close()
print(asked, "窗口还在吗", bool(frame))
text
['收到关闭请求'] 窗口还在吗 True
bool(frame) 是 True,窗口还在。想要它能关,处理器里补一句 e.Skip()。
5.5 在窗口上统一绑定
控件多的时候,挨个给每个按钮绑一遍很啰嗦。可以在窗口上绑一次,靠 source 区分。
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="统一绑定", size=(300, 200))
panel = wx.Panel(frame)
b1 = wx.Button(panel, label="第一个", pos=(20, 20))
b2 = wx.Button(panel, label="第二个", pos=(20, 60))
log = []
frame.Bind(wx.EVT_BUTTON, lambda e: log.append("只认第一个按钮"), b1)
def fire(win):
e = wx.CommandEvent(wx.EVT_BUTTON.typeId, win.GetId())
e.SetEventObject(win)
return win.GetEventHandler().ProcessEvent(e)
print("点第一个,返回", fire(b1), log)
print("点第二个,返回", fire(b2), log)
text
点第一个,返回 True ['只认第一个按钮']
点第二个,返回 False ['只认第一个按钮']
第二个按钮的事件没人接,ProcessEvent 返回 False。这个返回值有用,它表示事件有没有被处理掉。
菜单几乎都是在窗口上绑的,因为菜单项不是普通控件,没有 Bind 的地方。
ini
frame.Bind(wx.EVT_MENU, self.on_exit, id=wx.ID_EXIT)
frame.Bind(wx.EVT_MENU, self.on_about, id=wx.ID_ABOUT)
也可以绑一次然后在函数里判断 id,看个人习惯。
python
def on_menu(self, event):
if event.GetId() == wx.ID_EXIT:
self.Close()
elif event.GetId() == wx.ID_ABOUT:
self.SetStatusText("版本 1.0")
5.6 常用事件速查
| 事件 | 什么时候触发 | 常绑在谁身上 |
|---|---|---|
wx.EVT_BUTTON |
点按钮 | Button |
wx.EVT_TEXT |
输入框内容变了 | TextCtrl |
wx.EVT_TEXT_ENTER |
在输入框里按回车 | 需要 wx.TE_PROCESS_ENTER |
wx.EVT_CHECKBOX |
勾上或取消勾选 | CheckBox |
wx.EVT_RADIOBUTTON |
选中某个单选按钮 | RadioButton |
wx.EVT_CHOICE |
下拉列表换了选项 | Choice |
wx.EVT_COMBOBOX |
组合框换了选项 | ComboBox |
wx.EVT_LISTBOX |
列表选中项变了 | ListBox |
wx.EVT_CHECKLISTBOX |
勾选列表里某一项 | CheckListBox |
wx.EVT_SLIDER |
拖滑块 | Slider |
wx.EVT_SPINCTRL |
数字框的值变了 | SpinCtrl |
wx.EVT_MENU |
点了菜单项 | Frame |
wx.EVT_CLOSE |
窗口要被关掉 | Frame |
wx.EVT_KEY_DOWN |
按下键盘 | Panel 或 Frame |
wx.EVT_CHAR_HOOK |
按键,能被父窗口截获 | Frame |
wx.EVT_LEFT_DOWN / wx.EVT_LEFT_UP |
鼠标按下和松开 | 任意控件 |
wx.EVT_MOTION |
鼠标移动 | 任意控件 |
wx.EVT_SIZE |
窗口大小变了 | Frame 或 Panel |
wx.EVT_TIMER |
定时器到点 | Frame |
5.7 键盘、鼠标和定时器
键盘和鼠标事件有个坑,它们只发给有焦点的控件。给 Frame 绑 EVT_KEY_DOWN,焦点在输入框上的时候,Frame 收不到,得给输入框绑,或者用 wx.EVT_CHAR_HOOK,它会先经过父窗口。
定时器用起来简单,创建、绑定、启动三步。
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="定时器")
panel = wx.Panel(frame)
timer = wx.Timer(panel)
print("刚创建,在跑吗", timer.IsRunning(), "间隔", timer.GetInterval())
timer.Start(1000)
print("启动之后", timer.IsRunning(), "间隔", timer.GetInterval())
timer.Stop()
print("停掉之后", timer.IsRunning())
timer2 = wx.Timer(frame)
timer2.Start(500)
print("绑在窗口上的那个", timer2.IsRunning(), timer2.GetInterval())
timer2.Stop()
text
刚创建,在跑吗 False 间隔 0
启动之后 True 间隔 1000
停掉之后 False
绑在窗口上的那个 True 500
wx.Timer 的构造参数是要通知的那个窗口,事件绑在它身上。
php
self.timer = wx.Timer(self)
self.Bind(wx.EVT_TIMER, self.on_tick, self.timer)
self.timer.Start(1000) # 每 1000 毫秒触发一次
这段代码没有 MainLoop() 所以定时器不会真的响,事件循环没转起来。想看它动,得放进真正的程序里。
5.8 循环里绑事件,Lambda 的坑
假设要建一排按钮,每个点了打印自己的名字。很自然地会这么写。
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="循环绑定")
panel = wx.Panel(frame)
names = ["第一个", "第二个", "第三个"]
log = []
def fire(win):
e = wx.CommandEvent(wx.EVT_BUTTON.typeId, win.GetId())
e.SetEventObject(win)
return win.GetEventHandler().ProcessEvent(e)
buttons = []
for i, name in enumerate(names):
b = wx.Button(panel, label=name)
b.Bind(wx.EVT_BUTTON, lambda e: log.append(names[i]))
buttons.append(b)
for b in buttons:
fire(b)
print("这么写的结果", log)
log.clear()
buttons2 = []
for i, name in enumerate(names):
b = wx.Button(panel, label=name)
b.Bind(wx.EVT_BUTTON, lambda e, i=i: log.append(names[i]))
buttons2.append(b)
for b in buttons2:
fire(b)
print("改好的结果 ", log)
text
这么写的结果 ['第三个', '第三个', '第三个']
改好的结果 ['第一个', '第二个', '第三个']
三个按钮,第一种写法全都打印"第三个"。
原因是 lambda 里的 i 不是当场取值的,它记的是变量本身。循环跑完 i 停在 2,等到按钮被点,三个 lambda 都去读那个已经是 2 的 i。
改法就是第二种,给 lambda 加个默认参数 i=i。默认参数在函数定义时就求值了,每个 lambda 各自存了一份当时的值。
这个坑不只在 wxPython 里,任何把闭包放进循环的地方都会遇到。
5.9 别在主线程里干重活
回到 1.1 节那张图。事件循环是单线程的,你的处理器就在那个线程里跑。处理器跑多久,界面就卡多久。
python
def on_start(self, event):
for i in range(10000000): # 界面在这儿卡住,点哪都没反应
do_something(i)
self.SetStatusText("完成")
跑十秒钟,窗口白掉,标题栏显示"未响应",用户以为程序死了。
三条路可以走。
一,把活扔到别的线程。 用 threading.Thread,注意子线程里不能直接改界面,wxPython 不是线程安全的。改界面要交给主线程。
python
import threading
def on_start(self, event):
def work():
result = heavy_job()
wx.CallAfter(self.show_result, result) # 回到主线程再改界面
threading.Thread(target=work, daemon=True).start()
def show_result(self, result):
self.SetStatusText("算完了")
wx.CallAfter 把函数调用排进主线程的事件队列,等循环空闲时执行。这是子线程跟界面通信的标准做法。
二,边干边让出控制权。 在循环里定期调 wx.YieldIfNeeded(),让事件循环插空处理一下界面。
三,用定时器分片。 把大任务切成小块,每次 EVT_TIMER 到点时干一小块,顺便更新进度条。
第三条对新手最友好,不用碰线程,也不会遇到那些奇怪的并发问题。
5.10 一个完整的例子,待办清单
把这一章的东西攒起来,做个能用的小东西。数据存 SQLite,正好接上第十章。
先说界面长什么样。
┌─ 待办清单 ──────────────────────────────── □ ─ × ─┐
│ 文件 │
├────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────┐ ┌──────┐ │
│ │ 输入待办,回车添加 │ │ 添加 │ │
│ └────────────────────────────┘ └──────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ ☐ 买牛奶 │ │
│ │ ☑ 写周报 │ │
│ │ ☐ 交电费 │ │
│ │ │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌────────┐ ┌──────────────┐ │
│ │ 删除选中 │ │ 清除已完成 │ │
│ └────────┘ └──────────────┘ │
├────────────────────────────────────────────────────┤
│ 共 3 项,2 项未完成 │
└────────────────────────────────────────────────────┘
窗口 420 乘 380,客户区 404 乘 296。输入框在 (10, 10),宽 303;添加按钮在 (319, 10),宽 75;列表占 (10, 45) 到 (394, 251);两个底部按钮在 y=261,x 分别是 232 和 313。这些数字是我在自己机器上量出来的,你跑出来会略有出入。
代码分成两块。先是主程序,存成 todo.py。
python
# todo.py
import os
import sqlite3
import wx
DB = "todo.db"
def connect():
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
con.execute("""CREATE TABLE IF NOT EXISTS todo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER DEFAULT 0)""")
con.commit()
return con
class TodoFrame(wx.Frame):
def __init__(self, parent=None):
super().__init__(parent, title="待办清单", size=(420, 380))
self.con = connect()
self._build_ui()
self._build_menu()
self.CreateStatusBar()
self.refresh()
def _build_ui(self):
panel = wx.Panel(self)
outer = wx.BoxSizer(wx.VERTICAL)
top = wx.BoxSizer(wx.HORIZONTAL)
self.input = wx.TextCtrl(panel, style=wx.TE_PROCESS_ENTER)
self.add_btn = wx.Button(panel, label="添加")
top.Add(self.input, 1, wx.EXPAND | wx.RIGHT, 6)
top.Add(self.add_btn, 0)
outer.Add(top, 0, wx.EXPAND | wx.ALL, 10)
self.lst = wx.CheckListBox(panel)
outer.Add(self.lst, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
bottom = wx.BoxSizer(wx.HORIZONTAL)
self.del_btn = wx.Button(panel, label="删除选中")
self.clear_btn = wx.Button(panel, label="清除已完成")
bottom.AddStretchSpacer(1)
bottom.Add(self.del_btn, 0, wx.RIGHT, 6)
bottom.Add(self.clear_btn, 0)
outer.Add(bottom, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
panel.SetSizer(outer)
self.input.Bind(wx.EVT_TEXT_ENTER, self.on_add)
self.add_btn.Bind(wx.EVT_BUTTON, self.on_add)
self.lst.Bind(wx.EVT_CHECKLISTBOX, self.on_check)
self.del_btn.Bind(wx.EVT_BUTTON, self.on_delete)
self.clear_btn.Bind(wx.EVT_BUTTON, self.on_clear_done)
self.Bind(wx.EVT_CLOSE, self.on_close)
self.panel = panel
self.outer = outer
def _build_menu(self):
mb = wx.MenuBar()
m = wx.Menu()
m.Append(wx.ID_ABOUT, "关于")
m.AppendSeparator()
m.Append(wx.ID_EXIT, "退出")
mb.Append(m, "文件")
self.SetMenuBar(mb)
self.Bind(wx.EVT_MENU, self.on_exit, id=wx.ID_EXIT)
self.Bind(wx.EVT_MENU, self.on_about, id=wx.ID_ABOUT)
# 数据读写
def refresh(self):
rows = self.con.execute(
"SELECT id, title, done FROM todo ORDER BY id").fetchall()
self.ids = [r["id"] for r in rows]
self.lst.Set([r["title"] for r in rows])
for i, r in enumerate(rows):
self.lst.Check(i, bool(r["done"]))
left = sum(1 for r in rows if not r["done"])
self.SetStatusText("共 %d 项,%d 项未完成" % (len(rows), left))
def selected_index(self):
i = self.lst.GetSelection()
return None if i == wx.NOT_FOUND else i
# 事件处理
def on_add(self, event):
title = self.input.GetValue().strip()
if not title:
self.SetStatusText("先写点内容再添加")
return
self.con.execute("INSERT INTO todo (title) VALUES (?)", (title,))
self.con.commit()
self.input.SetValue("")
self.refresh()
self.SetStatusText("添加了一项")
def on_check(self, event):
i = event.GetSelection()
if i is None or i >= len(self.ids):
return
self.con.execute("UPDATE todo SET done = ? WHERE id = ?",
(int(self.lst.IsChecked(i)), self.ids[i]))
self.con.commit()
self.refresh()
def on_delete(self, event):
i = self.selected_index()
if i is None:
self.SetStatusText("先选中一项")
return
self.con.execute("DELETE FROM todo WHERE id = ?", (self.ids[i],))
self.con.commit()
self.refresh()
def on_clear_done(self, event):
done = [self.ids[i] for i in range(self.lst.GetCount())
if self.lst.IsChecked(i)]
if not done:
self.SetStatusText("没有已完成的事项")
return
self.con.executemany("DELETE FROM todo WHERE id = ?",
[(x,) for x in done])
self.con.commit()
self.refresh()
self.SetStatusText("清除了 %d 项" % len(done))
def on_about(self, event):
self.SetStatusText("待办清单 1.0,数据存在 todo.db 里")
def on_exit(self, event):
self.Close()
def on_close(self, event):
self.con.close()
event.Skip()
if __name__ == "__main__":
if os.path.exists(DB):
os.remove(DB)
app = wx.App()
TodoFrame().Show()
app.MainLoop()
代码里有几处我想单独说说。
refresh() 是全剧的核心。它从数据库读一遍,重建列表,重设勾选状态,更新状态栏。任何改动之后都调它一次,界面跟数据就不会脱节。这种"改完数据刷一遍"的做法比"哪里改了改哪里"省心,数据量不大的时候性能也够。
self.ids 存的是数据库里的 id,跟列表里的行号一一对应。列表第 0 行不一定对应数据库 id 为 1 的记录,删过数据就会错位,所以必须留这么一个映射。on_delete 里用的就是 self.ids[i],不是 i。
on_close 最后那句 event.Skip() 不能少。少了的话窗口关不掉,因为你在处理器里把事件拦下了。
wx.TE_PROCESS_ENTER 加 EVT_TEXT_ENTER,让输入框和添加按钮共用同一个 on_add,两个入口一份逻辑。
想不点鼠标就验证一遍逻辑,可以写个驱动脚本,用 ProcessEvent 手动派发事件。存成 drive.py,跟 todo.py 放一起。
python
# drive.py
import os
import wx
import todo
if os.path.exists("todo.db"):
os.remove("todo.db")
app = wx.App(False)
frame = todo.TodoFrame()
def fire(win, evt):
return win.GetEventHandler().ProcessEvent(evt)
def click(win):
e = wx.CommandEvent(wx.EVT_BUTTON.typeId, win.GetId())
e.SetEventObject(win)
fire(win, e)
print("刚打开 ", frame.lst.GetItems(), "|", frame.GetStatusBar().GetStatusText())
for title in ("买牛奶", "写周报", "交电费"):
frame.input.SetValue(title)
click(frame.add_btn)
print("添加三项 ", frame.lst.GetItems(), "|", frame.GetStatusBar().GetStatusText())
frame.input.SetValue(" ")
click(frame.add_btn)
print("空内容添加", frame.lst.GetItems(), "|", frame.GetStatusBar().GetStatusText())
frame.lst.Check(1, True)
e = wx.CommandEvent(wx.EVT_CHECKLISTBOX.typeId, frame.lst.GetId())
e.SetEventObject(frame.lst)
e.SetInt(1)
fire(frame.lst, e)
print("勾上第2项 ", [frame.lst.IsChecked(i) for i in range(frame.lst.GetCount())],
"|", frame.GetStatusBar().GetStatusText())
click(frame.del_btn)
print("没选中就删", frame.lst.GetItems(), "|", frame.GetStatusBar().GetStatusText())
frame.lst.SetSelection(0)
click(frame.del_btn)
print("删掉第1项 ", frame.lst.GetItems(), "|", frame.GetStatusBar().GetStatusText())
click(frame.clear_btn)
print("清除已完成", frame.lst.GetItems(), "|", frame.GetStatusBar().GetStatusText())
print("数据库里 ", [dict(r) for r in frame.con.execute("SELECT id, title, done FROM todo")])
跑出来的结果。
text
刚打开 [] | 共 0 项,0 项未完成
添加三项 ['买牛奶', '写周报', '交电费'] | 添加了一项
空内容添加 ['买牛奶', '写周报', '交电费'] | 先写点内容再添加
勾上第2项 [False, True, False] | 共 3 项,2 项未完成
没选中就删 ['买牛奶', '写周报', '交电费'] | 先选中一项
删掉第1项 ['写周报', '交电费'] | 共 2 项,1 项未完成
清除已完成 ['交电费'] | 清除了 1 项
数据库里 [{'id': 3, 'title': '交电费', 'done': 0}]
对着这段输出能看出几件事。空内容被拦住了,列表没变,状态栏给了提示。没选中就点删除也被拦住了。删掉第 1 项之后,列表里剩下的是"写周报"和"交电费",数据库里它们的 id 是 2 和 3。清除已完成只清掉了勾上的"写周报",最后库里只剩 id 为 3 的"交电费"。
这个驱动脚本还有个用处,改完代码不用一遍遍点鼠标,跑一下就知道逻辑对不对。GUI 程序难自动化测试,这是最省事的一招。
6 小结
6.1 这一章讲了什么
GUI 程序的运行方式 。它是事件驱动的,启动后进循环等着,你做一个动作它响应一次。MainLoop() 之后的代码要等窗口关完才执行。
窗口的层次 。wx.App 管循环,wx.Frame 是顶层窗口,wx.Panel 是装控件的底板,wx.Sizer 算位置。
控件 。第一个参数是父窗口,取值设值成对出现,通过 Bind 接事件。
布局 。别用坐标,用 sizer。Add(控件, proportion, flag, border) 四个参数,proportion 管主轴方向分多少,flag 管对齐和副轴方向撑不撑满,border 得配方向常量才生效。
事件 。命令事件会往父窗口传,传不传得下去看 Skip()。绑在父窗口上用 source 或 id 指定关心谁。循环里绑事件要给 lambda 加默认参数。
6.2 十个容易踩的坑
- 忘了
Show(),窗口建了但不显示,也不报错。 - 忘了
SetSizer,所有控件堆在(0, 0)。 - 控件的父窗口写错,写成
None会直接抛错,写成别的窗口就不显示。 - 放进
StaticBoxSizer的控件,父窗口要写成那个StaticBox。 - 想让控件跟着窗口长,忘了给
proportion或者副轴方向忘了wx.EXPAND。 - 动态加完控件没调
Layout()。 EVT_CLOSE处理器里忘了Skip(),窗口关不掉。- 循环里用 lambda 绑事件没加默认参数,所有按钮行为一样。
- 在主线程里跑耗时任务,界面卡成"未响应"。
- 在子线程里直接改界面,应该用
wx.CallAfter。
6.3 动手做几道题
第一题,建一个窗口,标题叫"计算器",宽 300 高 400,把最大化按钮去掉,但保留关闭和最小化。
python
>>> f = wx.Frame(None, title="计算器", size=(300, 400),
... style=wx.DEFAULT_FRAME_STYLE & ~wx.MAXIMIZE_BOX)
>>> f.HasFlag(wx.MAXIMIZE_BOX)
False
>>> f.HasFlag(wx.CLOSE_BOX)
True
>>> f.GetSize()
wx.Size(300, 400)
样式是位标记,想去掉某一项就用 & ~那个常量,想加上就用 |。
第二题,用水平 BoxSizer 做一行,左边是标签"姓名",中间输入框占满剩余空间,右边是"查询"按钮。容器宽 400,问输入框多宽。
python
>>> f2 = wx.Frame(None, title="查询行", size=(400, 120))
>>> p2 = wx.Panel(f2)
>>> p2.SetSize((400, 80))
>>> s = wx.BoxSizer(wx.HORIZONTAL)
>>> lab = wx.StaticText(p2, label="姓名")
>>> edit = wx.TextCtrl(p2)
>>> btn = wx.Button(p2, label="查询")
>>> for w, prop, flag in ((lab, 0, wx.ALIGN_CENTER_VERTICAL),
... (edit, 1, wx.EXPAND),
... (btn, 0, 0)):
... s.Add(w, prop, flag | wx.ALL, 5)
...
>>> p2.SetSizer(s)
>>> p2.Layout()
True
>>> edit.GetPosition(), edit.GetSize()
(wx.Point(40, 5), wx.Size(270, 70))
标签连带边距占 40,按钮占 85,中间的 280 全给了输入框,扣掉左右各 5 的边距,控件本身是 270 宽。高度是 70,因为 wx.EXPAND 让它在副轴方向也撑满了。
第三题,下面这段跑完会打印什么,为什么。
python
import wx
app = wx.App(False)
frame = wx.Frame(None, title="冒泡")
panel = wx.Panel(frame)
btn = wx.Button(panel, label="点我")
log = []
btn.Bind(wx.EVT_BUTTON, lambda e: log.append("按钮"))
panel.Bind(wx.EVT_BUTTON, lambda e: log.append("面板"))
frame.Bind(wx.EVT_BUTTON, lambda e: log.append("窗口"))
e = wx.CommandEvent(wx.EVT_BUTTON.typeId, btn.GetId())
e.SetEventObject(btn)
btn.GetEventHandler().ProcessEvent(e)
print(log)
text
['按钮']
只有按钮那个跑了。命令事件虽然会往上冒泡,但每一层处理完不调 Skip(),事件就停在那儿。想让三个都跑,按钮和面板的处理器末尾都要加 e.Skip()。
第四题,给三个按钮各绑一个处理器,点了打印自己的序号。先写出会出错的版本,再改对。
python
>>> names = ["第一个", "第二个", "第三个"]
>>> wrong = [lambda: names[i] for i in range(3)]
>>> [f() for f in wrong]
['第三个', '第三个', '第三个']
>>> right = [lambda i=i: names[i] for i in range(3)]
>>> [f() for f in right]
['第一个', '第二个', '第三个']
不加默认参数,三个 lambda 记住的都是循环结束时那个 i,值是 2。加了 i=i,每个 lambda 在定义时就存下了自己的那一份。