Tkinter
Tkinter
- 本篇的所有窗口都在本机真的弹出来过、真的关掉了(每个示例跑完自动
destroy),下面贴的不是「应该是这样」,是实测到的数值:控件坐标、尺寸、cget回读、事件字段、报错原文。
-
实测环境:
1
2
3
4
5
6
7
8Python 3.14.10
TclVersion 9.0 TkVersion 9.0 patchlevel = 9.0.4
windowingsystem = win32
tk scaling = 1.3330053300533005 # 125% 缩放
屏幕像素 = 2048 x 1152(winfo_screenwidth 给的是逻辑像素)
默认字体 = TkDefaultFont -> ('-family', 'Microsoft YaHei UI', '-size', 9, ...)
ttk 可用主题 = ('winnative', 'clam', 'alt', 'default', 'classic', 'vista', 'xpnative')
当前主题 = vista- Python 3.14 带的是 Tk 9.0,不是老教程里的 Tk 8.6,所以看到
info patchlevel回 9.0.4 不用怀疑自己装错。 tk scaling是 1.333,意味着 Tk 9 自己按 DPI 做了缩放:width=10的 Label 实测占 76 像素(9pt 字体的 10 个字符 × 1.333)。- 默认字体已经是「Microsoft YaHei UI」,所以中文示例不需要再指定字体就能正常显示。
- Python 3.14 带的是 Tk 9.0,不是老教程里的 Tk 8.6,所以看到
Python提供了多个图形开发界面的库
- Tkinter:Python 标准库里自带的 Tk GUI 工具包接口(“Tk interface”)。Tk 和 Tkinter 可以在绝大多数 Unix 平台、Windows 和 macOS 上用,Tk 8.0 之后的版本实现了本地窗口风格。它是标准库,不需要 pip 安装。
- wxPython:开源的 GUI 库,能做出完整、功能齐全的原生界面,需要自己安装。
- Jython:跑在 JVM 上的 Python,除少数标准模块外用的是 Java 的库,界面走 Swing、AWT 或 SWT,可以动态或静态编译成 Java 字节码。它不是 CPython,装不了 C 扩展。
PySide6/PyQt6(Qt,控件最全、跨平台一致性好)和Kivy(触屏)。入门选 Tkinter 的理由只有一个:它已经在标准库里,不用装东西。
Tkinter编程
-
说明:Tkinter 内置在 Python 安装包中,装好 Python 就能
import tkinter;官方 IDLE 本身就是用 Tkinter 写的。1
2
3
4
5实测 import 之后能直接用的东西:
tkinter.Tk / Toplevel / Frame / Label / Button / Entry / Text / Scrollbar
Checkbutton / Radiobutton / Listbox / Scale / Spinbox / Menu / Canvas
PhotoImage / BitmapImage / StringVar 等变量类
tkinter.ttk(要单独 from tkinter import ttk) -
步骤(四步,永远不变):
- 导入
tkinter模块。 - 创建控件对象(第一个参数是父窗口)。
- 指定这个控件的 master,即它属于哪个窗口或哪个 Frame。
- 告诉几何管理器(geometry manager,简称 GM)「有一个控件产生了」,也就是调用
pack()、grid()或place()。不叫 GM 上场的话,控件永远看不见。
- 导入
简单示例
1 | import tkinter |
-
这段跑起来就是一个 400x400、左上角在 (200, 200) 的空窗口。把
geometry的各种写法实测一遍,winfo_*是真实读回来的值:1
2
3
4for g in ['400x400+200+200', '400x400-200-200', '400x400', '-300-400', '+100+100', '400x400+abc']:
win.geometry(g)
win.update_idletasks()
print("%-16r -> %s winfo=%dx%d%+d%+d" % (g, win.geometry(), win.winfo_width(), win.winfo_height(), win.winfo_x(), win.winfo_y()))1
2
3
4
5
6'400x400+200+200' -> 400x400+200+200 winfo=400x400+200+200
'400x400-200-200' -> 400x400-200-200 winfo=400x400+1432+513
'400x400' -> 400x400-200-200 winfo=400x400+1432+513
'-300-400' -> 400x400-300-400 winfo=400x400+1332+313
'+100+100' -> 400x400+100+100 winfo=400x400+100+100
'400x400+abc' -> TclError: bad geometry specifier "400x400+abc"- 减号是从屏幕右/下边缘往回收,所以
-200-200在 2048 宽的屏上算出 x=1432(2048-200-400 再减掉窗口边框的余量)。想「右下角」就用减号写法,这是原笔记400x400-200-200的本意。 - 只写大小不写位置(
400x400)时位置保持不动,上面第三行就还是上一轮的-200-200。 - 只写位置不写大小(
-300-400)时大小保持不动,这就是原笔记 Text 例子里win.geometry('-300-400')的实际效果——它没有改尺寸,只把窗口挪到右下。 - 写错分隔符不会给你提示,直接
TclError: bad geometry specifier。
- 减号是从屏幕右/下边缘往回收,所以
-
mainloop()之后代码就不往下走了,这是 Tkinter 和「脚本」最不一样的地方:1
2
3没进 mainloop 时也能读控件,但要手动推一轮:
win.update_idletasks() # 只算布局,不处理事件
win.update() # 处理一批事件- 写测试或者批量生成截图时通常不调
mainloop(),改成update()加after(ms, destroy)自动关窗,本篇所有实测就是这么做的。
- 写测试或者批量生成截图时通常不调
主窗口能拿到的信息
1 | import tkinter |
1 | 2048 1152 |
winfo_rootx/rooty是「客户区左上角相对屏幕」的坐标,实测比geometry给的 120 大了 8,那是窗口边框宽度;算鼠标绝对坐标时要用 root 版。winfo_pointerxy()直接给出当前鼠标在屏幕上的位置,做「跟随鼠标」的功能时用它。winfo_reqwidth是控件「希望的大小」,winfo_width是「实际大小」;没调用update()之前winfo_width恒等于 1,这是新手最常见的「为什么量出来都是 1x1」。
Tkinter控件
- 下面每个控件都给「参数说明 + 实测结果」。控件的通用参数(
master、text、bg、fg、font、width、height、padx、pdy)在经典控件上是通用的,ttk系列的控件不吃bg/fg,要靠ttk.Style,后面 Combobox 一节实测到了。 - 读回属性一律用
cget('名字')或widget['名字'];写属性用config(...)或widget['名字'] = ...,两种写法实测等价。
Label控件
1 | import tkinter |
-
参数说明基本都对,但有三个要改成「实测口径」:
width/height的单位是字符,不是像素(Label、Entry、Text这些文字型控件都这样)。实测font=('楷体', 20)+width=30, height=5的 Label,请求尺寸是 426 x 141 像素。wraplength的单位才是像素。原示例给的是10,10 像素连一个 20 号字都放不下,实测结果是把文本挤成每行一个字:同样那段 16 字的文本,wraplength=10时尺寸 34 x 438,wraplength=200时 202 x 87。所以这个值一般给 100 以上。anchor的合法值实测是 9 个:n s w e c ne se sw nw,另外center也接受;写成x之类会直接报错。justify只影响多行文本的对齐(left、right、center)。
-
想改已经建好的 Label,用
config或下标都行,实测等价:1
2
3label.config(text='换了文字')
label['text'] = '又换了一次'
print(label.cget('text'), label['bg'])
Button控件
1 | import os |
-
三种
command写法都对:普通函数、lambda、以及win.quit这种「函数本身」。唯一不能写的是command=func()——那样在构造按钮时就执行了一次,command收到的是返回值None,点按钮反而没反应。实测:1
tkinter.Button(win, command=func()) # 构造时就打印了一次「点击回调 func()」
-
button3是这段代码最大的坑:os.system会阻塞。 实测在本机跑os.system('notepad.exe'):1
2os.system('notepad.exe') 返回 = 143 耗时 = 278.73 秒
os.system('ping -n 2 127.0.0.1 >nul') 耗时 = 2.01 秒 返回 = 0-
os.system要等子进程结束才返回(Windows 上是cmd.exe /c等),所以点下这个按钮之后整个界面会冻住,直到记事本被关闭。上面 278 秒就是探针等不到、被超时杀掉才返回的,143 是被强杀的返回码。 -
界面里要起外部程序,正确写法是
subprocess.Popen(['notepad.exe']),它立即返回,还能拿到 pid 之后自己收尾:1
2subprocess.Popen 不阻塞,耗时 = 0.001 秒, pid = 51220
Popen + kill + wait 后返回码 = 1
-
-
command=win.quit与command=win.destroy的区别:quit()只结束mainloop(),窗口对象还在(后面还能再mainloop());destroy()是真的销毁窗口。退出程序用quit更安全。 -
程序里想「模拟点一下按钮」,用
invoke(),别用合成事件:1
2
3
4event_generate('<ButtonRelease-1>') -> 不触发 command
event_generate('<ButtonPress-1>') + ('<ButtonRelease-1>') -> 也不触发 command
button.invoke() -> 触发 command(实测执行了一次)
按钮拿到焦点后 event_generate('<space>', keysym='space', keycode=32) -> 触发 command
Entry控件
1 | import tkinter |
-
实测两个
get()拿到的是同一个值:1
2e.get() = '请输入密码'
entry.get() = '请输入密码' -
建议把
tkinter.Variable()换成tkinter.StringVar()。Variable是这一族类的基类,能跑,但它的get()返回的是 Tcl 层原样转换出来的东西(列表会变成元组),而StringVar/IntVar/BooleanVar/DoubleVar会帮你把类型定死。实测各类型的转换行为:1
2
3
4
5
6StringVar().set('abc') -> get() = 'abc' 类型 str
IntVar().set('12') -> get() = 12 类型 int
IntVar().set('abc') -> TclError: expected floating-point number but got "abc"
BooleanVar().set(1) -> get() = True 类型 bool
DoubleVar().set('3.5') -> get() = 3.5 类型 float
StringVar(value='构造时给值') -> Entry 建好就有内容- 也就是说
IntVar会立刻帮你校验「是不是数字」,这是它比基类Variable值钱的地方。
- 也就是说
-
show='*'只是显示成星号,entry.get()拿到的还是明文,实测:1
show='*' 时:变量.get()='secret' entry.get()='secret' entry.cget('show')='*'
-
state三个值实测:1
2
3state='normal' -> 可读写
state='readonly' -> 只能读,程序里 insert 实测被忽略且不报错(内容保持原样)
state='disabled' -> 同上,insert 不报错但写不进去- 「不报错但没写进去」比抛异常更难查,所以置灰输入框后要记得先把
state改回normal再赋值。
- 「不报错但没写进去」比抛异常更难查,所以置灰输入框后要记得先把
-
变量变化可以被监听(比到处手写刷新函数干净):
1
2
3v = tkinter.StringVar()
v.trace_add('write', lambda *args: print('变了', args))
v.set('新值')1
变了 ('PY_VAR1', '', 'write')
- 回调固定收三个参数:变量在 Tcl 里的名字、空串、动作(
write/read/unset)。
- 回调固定收三个参数:变量在 Tcl 里的名字、空串、动作(
1 | import tkinter |
- 点按钮就把输入框内容打印出来。这里
entry是全局名字,回调里直接引用,是 Tkinter 入门最常见的写法;控件多了以后建议改成「回调里通过闭包或类属性拿控件」,不然变量名会互相覆盖。
Text控件
-
不带滚动条:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20import tkinter
# 创建主窗口
win = tkinter.Tk()
# 设置标题
win.title('Text')
# 设置大小和位置
win.geometry('400x400-200-200')
'''
文本控件,用于显示多行文本
'''
# height显示的行数
text = tkinter.Text(win, width=30, height=4)
text.pack()
str = '''If there is anyone out there who still doubts that America is a place where all things are possible, who still wonders if the dream of our founders is alive in our time, who still questions the power of our democracy, tonight is your answer.'''
text.insert(tkinter.INSERT, str)
win.mainloop()-
width=30, height=4是「30 个字符宽、4 行高」,超出的部分靠内部滚动。 -
变量名别用
str,那是内置类型;换成content。 -
Text的索引是「行.列」,行从 1 开始、列从 0 开始,这点和别的控件完全不同。实测:1
2
3
4
5
6插入 '第一行\n第二行\n第三行' 之后
get('1.0', END) = '第一行\n第二行\n第三行\n' # END 会多带一个换行
get('1.0', 'end-1c') = '第一行\n第二行\n第三行' # 去掉末尾那个换行的写法
index(END) = '4.0'
count('1.0', END, 'lines') = (3,) # 返回的是元组
count('1.0', END, 'chars') = (12,) -
原笔记后面写的
text.delete(0.0, tkinter.END)能用:0.0被 Tk 夹到最小合法索引1.0,实测清空后内容确实变成''。但请写'1.0',因为0.0属于「侥幸对」,同一套里'0'之类就会报bad text index。 -
tkinter.INSERT是「插入点」这个特殊索引;想让新内容追加到末尾,要么先text.mark_set(tkinter.INSERT, 'end'),要么直接text.insert(tkinter.END, ...)。实测两种都能追加:1
mark_set(INSERT, 'end') 后 insert('尾巴') -> '插到最前第一行\n第二行\n第三行\n尾巴'
-
Text支持给一段文字上样式,这是它比Entry强的地方:1
2
3text.tag_config('red', foreground='red')
text.insert(tkinter.END, '红字', 'red')
print(text.tag_names(), text.tag_ranges('red'))1
('red',) ([(<text index object '3.0'>, <text index object '3.5'>)],)
-
撤销相关的方法实测只有这几个:
edit_modified、edit_redo、edit_reset、edit_separator、edit_undo(没有edit_can_undo,网上教程里的写法在 3.14 上是AttributeError)。
-
-
带滚动条:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28import tkinter
# 创建主窗口
win = tkinter.Tk()
# 设置标题
win.title('Text')
# 设置大小和位置
win.geometry('-300-400')
'''
文本控件,用于显示多行文本
'''
# 创建滚动条
scroll = tkinter.Scrollbar()
# 创建Text
text = tkinter.Text(win, width=50, height=8)
# side放到窗体的那一侧 fill填充
scroll.pack(side=tkinter.RIGHT, fill=tkinter.Y)
text.pack(side=tkinter.LEFT, fill=tkinter.Y)
# 关联
scroll.config(command=text.yview)
text.config(yscrollcommand=scroll.set)
str = '''If there is anyone out there who still doubts that America is a place where all things are possible...'''
text.insert(tkinter.INSERT, str)
win.mainloop()-
关联是双向的,两条都不能少:
scroll.config(command=text.yview)让「拖滚动条」能滚文本,text.config(yscrollcommand=scroll.set)让「滚轮/键盘滚文本」能反过来更新滚动条的位置。 -
win.geometry('-300-400')只挪位置不改尺寸(见「简单示例」那节的实测),这里等于没设大小。 -
用程序验证联动是否真的生效,看
yview()的返回值(可见区间占全文的比例):1
2
340 行文本塞进 height=8 的 Text,滚动区间 = (0.0, 0.2186)
yview_moveto(0.5) 之后 = (0.4387, 0.6573) scroll.get() = (0.4387, 0.6573)
see('20.0') 之后 = (0.4651, 0.6837)scroll.get()和text.yview()数值一致,就说明yscrollcommand这条路是通的。
-
Scrollbar()建的时候不给master,实测它会默认挂到根窗口上,规范写法是tkinter.Scrollbar(win)。
-
CheckButton控件
1 | import tkinter |
-
控件的类名是
Checkbutton(b 小写),原笔记的小标题写「CheckButton」只是称呼,写代码时大小写错了会AttributeError。Radiobutton同理。 -
实测行为:
1
2
3
4BooleanVar() 默认 False,可以 BooleanVar(value=True) 直接给初值
check1.invoke() 一次 -> hobby1.get() = True,command 被调用,Text 内容 'money\npower\n'
再 invoke() 一次 -> hobby1.get() = False,Text 内容 'power\n'
select() / deselect() / toggle() / invoke() 四个方法都存在hobby1.get() == True可以写成if hobby1.get():,因为BooleanVar.get()实测返回的就是真正的bool。command只在用户点选时执行,用hobby1.set(True)从程序里改值不会触发update(),需要刷新时要自己调(或者用trace_add)。
-
invoke()是「切换并执行 command」,而select()/deselect()只改状态不执行回调——这个区别很容易踩,批量勾选完记得手动刷新。 -
想要「没有小方框、看起来像按钮」的复选效果,用
indicatoron=False:1
indicatoron=False 的 Checkbutton 请求宽度 58 像素,选中时 selectcolor 回读为 SystemWindow
RadioButton控件
1 | import tkinter |
-
「一组共用一个变量」这句是关键,实测互斥生效:
1
2
3
4初始未选时 r.get() -> 0(不是异常,IntVar 没赋值时读到 0)
radio2.invoke() -> r.get()=2,回调记录 [2]
radio3.invoke() -> r.get()=3,自动取消 radio2 的选中
r.set(3) 从程序里反向设置 -> 三个控件的 cget('value') 与 r.get() 相同的那个会亮- 注意
cget('value')实测返回的是int(tkinter 帮你转好了),但同一份代码在老版本上可能拿到字符串,比较时最好int(x.cget('value'))。
- 注意
-
判断选中状态的写法:经典控件没有
instate()(那是ttk才有的),实测hasattr(radio, 'instate')为False,只能比较变量值。 -
单选组想真正分组,就把同一组放进同一个
Frame,或者干脆用不同的变量名。跨 Frame 共用一个变量也能互斥,因为互斥是靠变量而不是靠容器。
Listbox控件
-
列表框控件,用来显示若干条文本、并让用户选。四种
selectmode是它最主要的差别,实测常量值:1
2
3
4tkinter.SINGLE = 'single'
tkinter.BROWSE = 'browse'
tkinter.MULTIPLE = 'multiple'
tkinter.EXTENDED = 'extended'SINGLE:只能选一个,不支持「按住拖过几项就连选」。BROWSE(默认):和SINGLE类似,但按下后拖动鼠标可以改选,鼠标抬起才定下来,用来做菜单式选择最顺手。MULTIPLE:点一下就切换那一项的选中状态,可以随意点选多项。EXTENDED:支持Shift连选、Ctrl点选,是最常用的多选模式。
Listbox控件上(BROWSE)
1 | import tkinter |
-
实测这一段的最终状态(把注释掉的调用逐个放开跑过):
1
2
3
4
5
6
7
8
9
10
11
12size = 7
[0] 'cool'
[1] 'good'
[2] 'nice'
[3] 'handsome'
[4] 'vg'
[5] 'vn'
[6] ('very good', 'very nice')
select_set(2,5) 之后 curselection() = (2, 3, 4, 5) 类型 tuple
selection_includes(1) = False selection_includes(3) = True 类型 bool
delete(1,3) 之后 = ['cool', 'vg', 'vn', ('very good', 'very nice')]
get(2,4) 返回 = ('nice', 'handsome', 'vg') 类型 tupleinsert(tkinter.ACTIVE, 'cool')实测插到了下标 0,注释里「在开始添加」这句是对的:没有活动项时ACTIVE就等于开头。- 「把列表当成一个元素添加」这句要小心:
lb.insert(tkinter.END, ['very good', 'very nice'])不是把整个列表当成一格文字。Tk 会把它转成 Tcl 列表,读回来是一个元组('very good', 'very nice'),显示出来则是一行两个词。想要一格显示['very good', 'very nice']这种字面文本,得自己str(list)或' '.join(list)。 delete(1,3)删的是区间(1、2、3 三项都没了),和注释一致。curselection()在 Tk 9 上返回的是整数元组;老教程里常见的是字符串元组('2', '3'),写int(x)转换的代码在新版本上照样能跑,但不转的更要留意类型。
-
index能接受的写法实测:1
2index('end') = 5 index('@10,10') = 0(屏幕坐标反查第几项)
index('last') -> TclError: bad listbox index "last": must be active, anchor, end, @x,y, or an index
Listbox控件中(SINGLE)
1 | import tkinter |
-
listvariable绑的变量存的是整个列表(不是选中项),实测:1
2
3
4lbv.get() = "('good', 'nice', 'handsome', 'vg', 'vn')" 类型 str
直接问 Tcl 层拿 = ('good', 'nice', 'handsome', 'vg', 'vn') 类型 tuple
lbv.set('AAA BBB CCC') -> 列表变成 ['AAA', 'BBB', 'CCC'](按空格切)
lbv.set(('带 空 格', '第二项')) -> 列表正确分成两项- 所以
StringVar.get()拿到的是「列表转成的字符串」,想按项处理用lb.get(i),或者lbv._tk.getvar(lbv._name)。元素里带空格时用元组 set 才不会被切开。
- 所以
-
双击取选中项那句
lb.get(lb.curselection())在 Tk 9 上能用(curselection()是整数元组,get接受),但没选中时会抛TclError(tuple index out of range或bad listbox index),稳健写法是:1
2
3sel = lb.curselection()
if sel:
print(lb.get(sel[0]))
Listbox控件中下(EXTENDED)
1 | import tkinter |
- 这段没调
mainloop()前把 15 项全塞进去了,实测size()=15、index('end')=15,yscrollcommand和command两条联动都写上了(这里用的是sc['command'] = lb.yview这种下标写法,和config(command=...)等价)。 EXTENDED模式下多选的结果一律从curselection()读,项数多时它是排序好的整数元组。
Listbox控件下(MULTIPLE)
1 | import tkinter |
- 四种模式的代码只有
selectmode一个词不同,其余全一样,实际项目里可以直接把模式做成参数。
Scale控件
1 | import tkinter |
-
注释里那句「
tickinterval选择值将会为该值的倍数」是错的,实测:1
2
3tickinterval=20 时 scale.set(37) -> get() = 37 # 不会吸附到 40
再把 resolution=10 设上,set(37) -> get() = 40 # 吸附是 resolution 干的
resolution 默认值 = 1.0tickinterval只决定刻度标签每隔多少显示一个数字,管的是「好不好看」;真正把值吸附到某个倍数的是resolution。
-
set/get实测:scale.set(20)之后get()返回 int 20。 -
length=200在HORIZONTAL下就是宽度 200 像素;orient的取值实测是字符串'horizontal'和'vertical'。 -
想拿值又不想点按钮,就绑变量:
1
Scale(variable=IntVar()) 之后 set(4) -> 变量.get() = 4
Spinbox控件
1 | import tkinter |
-
实测:
1
2
3
4
5
6
7from_=0, to=100, increment=1 且 v.set(20) -> v.get()='20' sp.get()='20'
sp.invoke('buttonup') -> 21
sp.invoke('buttondown') 两次 -> 19
values=(0,2,4,6,8) 点一下上箭头 -> 2
手动写进非法内容 sp2.insert(0,'abc') -> get()='abc',Spinbox 不校验内容
wrap=True 时当前值 10 再点上箭头 -> 1(回到最小值)
wrap 默认值 = 0,format 默认 = 空串- 注意绑的是
StringVar,所以v.get()是字符串'20';要做数值运算请绑IntVar或者自己int()。 invoke('buttonup')/invoke('buttondown')是程序化点箭头的方法,实测可用,测试代码里很有用。- Spinbox 不挡你输非法值,所以「值必须在 0 到 100」这种事必须自己在回调里判断。
from_=0结尾那个下划线是必须的:from是 Python 关键字,tkinter 帮你把from_/to翻译成 Tcl 的-from/-to。
- 注意绑的是
Menu控件
Menu顶层菜单
1 | import tkinter |
-
结构是三层:菜单条(挂在窗口上)→ 级联菜单(
add_cascade)→ 菜单项(add_command)。少了win.config(menu=menubar)这一步,菜单条不会出现。 -
实测读数(简化成 4 项的版本):
1
2
3
4
5
6menubar.index('end') = 1 即菜单条上有 2 个级联菜单
menu1 有 5 项(4 个命令 + 1 条分隔线,分隔线也算一项)
menu1.entrycget(0, 'label') = 'Python'
menu1.type(0) = 'command'
menu1.invoke(0) -> 对应 command 被执行
新建 Menu 的 tearoff 默认值 = 0- Tk 9 里
tearoff默认已经是 0(不撕离),所以tearoff=False写不写效果一样;老教程写它是因为 Tk 8 时代默认是 1,菜单顶上会多一条虚线,点一下能把菜单撕成独立小窗。 - 分隔线占一个索引位,所以「第 3 项」到底是哪个要看
index,实测menu1.invoke(2)触发的是第 3 个命令(C++),不是分隔线后面那个。
- Tk 9 里
-
菜单项还有别的类型,实测可用的方法:
add_command、add_checkbutton、add_radiobutton、add_cascade、add_separator,配合entryconfigure/delete/insert可以动态改。 -
用 lambda 带参数时要闭包写法,
command=lambda i=item: func(i);直接command=func会让所有项都调同一个无参函数(原笔记就是这样,所以点哪一项打印都一样)。
Menu鼠标右键菜单
1 | import tkinter |
-
和顶层菜单唯一的区别是不要
win.config(menu=...),改成绑定<Button-3>再menubar.post(绝对坐标)。 -
坐标必须用
event.x_root/event.y_root(相对屏幕),用event.x/event.y(相对控件)菜单会跑到屏幕左上角去。 -
实测两件事:
-
合成的鼠标事件里
x_root/y_root是-1,post会算出非法位置;要测试得event_generate('<Button-3>', x=120, y=90, rootx=300, rooty=300)手工给坐标:1
2不带坐标合成 -> (0, 0, -1, -1)
带坐标合成 -> (120, 90, 300, 300) -
post出来的菜单是模态的:探针里真调menubar.post(...)之后update()不再返回,脚本卡死到超时。所以这段代码只能在真实桌面上右键验证,别在自动化脚本里直接 post。
-
-
右键菜单常见需求是「先删干净再按目标类型填项」,方法是
menu.delete(0, 'end')再重新add_command。有个坑要记:菜单被删空之后menu.index('end')返回的是None而不是 0,实测:1
2删除前 3 项;delete(0,'end') 之后 index('end') = None
所以拿它做算术(index('end')+1)会 TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
Combobox控件
1 | import tkinter |
-
实测读数:
1
2
3
4
5current(5) 之后 com.get()='北京' cv.get()='北京'
cget('values') = ('黑龙江', '吉林', '辽宁', '广州', '深圳', '北京', '上海') 类型 tuple
current() 回读 = 5 项数 = 7
com.set('手输的值') 之后 current() = -1 # 输入的内容不在候选值里时,current 就是 -1
com.instate(['readonly']) = True / ['disabled'] = True- 注意参数名是
value(单数),实测com['value'] = (...)、com.config(values=(...))、com['values'] = (...)三种写法都能把候选值设上,但读出来一律是复数cget('values'),返回元组。 current(5)是按索引设默认值,比set('北京')稳(后者依赖字符串完全一致)。<<ComboboxSelected>>是虚拟事件,用户选择时才发;程序里com.current(0)之后想触发回调,得自己com.event_generate('<<ComboboxSelected>>'),实测收到:['黑龙江']。
- 注意参数名是
-
ttk系控件不吃bg/fg,样式统一走ttk.Style,实测:1
2
3
4from tkinter import ttk
style = ttk.Style(win)
style.configure('TCombobox', fieldbackground='lightyellow')
print(style.theme_use(), style.theme_names())1
vista ('winnative', 'clam', 'alt', 'default', 'classic', 'vista', 'xpnative')
-
state有三个值:normal(可输入)、readonly(只能选)、disabled(灰掉)。readonly下程序里set()依然有效,只是用户不能手输。
Frame控件
1 | import tkinter |

-
实测出来的坐标(400x300 窗口,
update()之后读):1
2
3frm x=170 y=0 60x46
frm_l x=0 y=0 30x46 左上 x=0 y=0 30x23 左下 x=0 y=23 30x23
frm_r x=30 y=0 30x46 右上 x=0 y=0 30x23 右下 x=0 y=23 30x23- 子控件的
winfo_x是相对父容器的,所以frm_l里的「左上」是 0,0,而frm自己在窗口里是 x=170(pack默认把子容器居中放,这就是为什么它会往中间挤)。 - 每个 Label 只有 23 像素高、30 像素宽,是因为字只有两个,
pack不撑开。想撑开用fill和expand。
- 子控件的
-
Frame 可以带边框当分区,实测:
1
Frame(bd=5, relief='raised', padx=8, pady=8) -> 请求尺寸 68x49(内容 30x23 + 内边距 16 + 边框 10 左右)
relief可选raised、sunken、groove、ridge、solid、flat,配合bd(borderwidth)用。
-
Frame 最主要的用途就是给布局分组:同一 Frame 里用
grid,外层 Frame 之间用pack,就不会出现「一个容器里 pack 和 grid 混用」的报错(见「布局」一节实测)。 -
一句话补充:
tkinter.Listbox只能放纯文本、一行一项;要图标、要树、要表格,用下面的ttk.Treeview。
数据显示
表格数据
1 | import tkinter |

-
四步是固定的:定列(
columns)→ 设列宽(column)→ 设表头(heading)→ 塞数据(insert)。少第一步的话后两步会报「列不存在」。 -
实测读数:
1
2
3
4
5
6
7tree['columns'] = ('姓名', '年龄', '身高', '体重') 类型 tuple
两行的 iid = ('I001', 'I002') # 不给 iid 时 Tk 自动生成 I001 这种
item('I001') = {'text': 'line1', 'height': 1, 'hidden': 0, 'image': '',
'imageanchor': '', 'values': ['陆彦旭', 28, 165, 80], 'open': 0, 'tags': ''}
set('I001', '姓名') = 陆彦旭
item('I001', 'values') = ('陆彦旭', '28', '165', '80')
列 '#0' 的宽度 = 200 show = ('tree', 'headings')-
insert的第一个参数是父节点,空串表示顶层;第二个参数是位置索引,0是插到最前,tkinter.END是追加到末尾。写tree.insert('', 0, ...)和tree.insert('', 1, ...)就是第 1、2 行。 -
text是「树列」(#0) 上显示的文字,values才是各数据列的内容。 只看这段代码会以为text='line1'是一列,其实它显示在最左边那一列。做纯表格要把它藏起来:1
tree.configure(show='headings') # 只留表头那一排,#0 列不显示
1
show='headings' 之后 displaycolumns = ('#all',)
-
读出来的值类型不一致这点要注意:整个
item(iid)字典里数值会被转成int(28),而item(iid,'values')还是字符串('28')。要算数就自己int()。 -
数据列数固定,
values给得比列多时不会报错,实测 8 个值照样存进去,但只有前 4 个有列可显示。
-
1 | iid = tree.insert('', tkinter.END, '自定义iid', text='带 iid 的行', values=('张三', 1, 2, 3)) |
1 | custom True 2 |
- 建议主动给 iid(第三个位置参数),后面
item/set/delete都能直接按名字找行,比记I001稳。 ttk.Treeview没有open()/close()方法(实测hasattr(tree, 'open')是False),要展开收起某一行用tree.item(iid, open=True);有move、detach、reattach、identify_row、selection_set,实测都可用。
树状数据
1 | import tkinter |

-
树的层级就是靠
insert(父iid, 位置, iid, ...)的第一个参数表达;子节点 iid 用中文也能当键,实测把insert的返回值当父节点传进去即可。 -
实测读回来的结构:
1
2
3
4
5
6
7insert 返回的 iid = '中国' '黑龙江'
顶层 get_children('') = ('中国', '美国', '英国')
get_children('中国') = ('黑龙江', '吉林', '辽宁')
get_children('黑龙江') = ('哈尔滨', '五常')
parent('哈尔滨') = '黑龙江' parent('中国') = ''(顶层的 parent 是空串)
初始 item('中国','open') = 0(默认收起)
item('中国', open=True) 之后 = 1 -
展开/收起的正确写法只有这一种:
1
2tree.item('中国', open=True) # 展开
tree.item('中国', open=False) # 收起 -
原笔记里
values=('F1')少了个逗号,('F1')在 Python 里就是字符串'F1'。实测它和values=('F1',)结果一样(都只占一个值),所以这个笔误在树视图里看不出来;但只要values里有多个元素就必须写逗号,习惯上建议一律写成('F1',)。 -
给树加滚动条用
ttk.Scrollbar(orient='vertical', command=tree.yview)加tree.configure(yscrollcommand=sb.set);或者直接给height让它自己滚(本例height=15)。
布局
- Tkinter 有三种几何管理器:
place绝对布局、pack相对布局、grid表格布局。同一个父容器里只能用一个(混用会报错,下面实测到了),不同 Frame 之间可以各用各的。
绝对布局(place)
1 | import tkinter |

-
实测坐标和尺寸(
update()之后读):1
2
3
4good 在 10,10 尺寸 38x23
nice 在 50,50 尺寸 29x23
cool 在 100,100 尺寸 31x23
把窗口改成 600x500 之后:三个标签仍然在 10,10 / 50,50 / 100,100- 「窗口变化对位置没有影响」这句是对的,实测改大小后坐标一个都没动。
-
place还有两种很有用的写法,实测:1
2
3relx=0.5, rely=0.5, anchor='center' -> 落在父容器中心(随窗口大小变)
place(x=200, y=200, width=120, height=40) -> 强制控件尺寸 120x40
place_forget() 之后 winfo_ismapped() = 0(隐藏,配置还在,再 place() 会回来)- 只有
place能直接指定控件的宽高(pack/grid只能靠ipadx/width间接近似),做「悬浮按钮」「背景图层」这种必须叠在一起的效果也只能用它。
- 只有
相对布局(pack)
1 | import tkinter |

-
实测两种窗口大小下的布局结果,能直接看出
fill和side的语义:1
2
3
4
5
6
7
8
9
10
11400x400 时:
左 good x=0 y=0 38x400
右 nice x=371 y=0 29x400
上 handsome x=38 y=0 333x23
下 beautiful x=38 y=377 333x23
改成 600x500 之后:
左 good x=0 y=0 38x500 # fill=Y 高度跟着窗口
右 nice x=571 y=0 29x500
上 handsome x=38 y=0 533x23 # fill=X 宽度跟着窗口
下 beautiful x=38 y=477 533x23
再加一个 pack(fill=BOTH, expand=True) 的控件 -> 吃掉剩下的 533x454-
side决定「贴着哪条边」,fill决定「沿着哪个方向被拉满」,expand=True才会占掉剩余空间。三个参数不是一回事,实测上面清楚。 -
pack是按调用顺序排队的,先pack的先占边,所以调换label1.pack(...)的顺序会改变版面。 -
label.pack_info()可以把当前设置读回来,实测:1
2{'in': <tkinter.Tk object .>, 'anchor': 'center', 'expand': 0, 'fill': 'y',
'ipadx': 0, 'ipady': 0, 'padx': 0, 'pady': 0, 'side': 'left'}
-
表格布局(grid)
1 | import tkinter |

-
实测坐标(
width=10的 Label 是 76 像素宽,所以第二列从 x=76 开始):1
2
3
4
5
6good x=0 y=0 76x23
wonderful x=76 y=0 76x23
cool x=0 y=23 76x23
handsome x=76 y=23 76x23
grid_info() = {'in': <tkinter.Tk object .>, 'column': 1, 'row': 0, 'columnspan': 1,
'rowspan': 1, 'ipadx': 0, 'ipady': 0, 'padx': 0, 'pady': 0, 'sticky': ''} -
columnspan=2加sticky='we'可以让一个控件横跨两列并左右贴边,实测改完grid_info()里就是'columnspan': 2, 'sticky': 'ew'(Tk 把we规范化成ew,读回时顺序变了,不影响效果)。 -
同一个父容器里混用两种几何管理器会直接报错,实测:
1
TclError: cannot use geometry manager "pack" inside ".": grid is already managing its content window
- 所以「外层 Frame 用
pack,里面各 Frame 用grid」是标准做法(见 Frame 一节)。
- 所以「外层 Frame 用
-
三种布局的选择:绝大多数界面用
grid(表单、表格)或pack(工具条、上下堆叠)就够了;只有需要精确像素位置或叠放时才用place。
事件


-
绑定事件的通用形式:
1
2
3widget.bind('<事件序列>', 处理函数) # 覆盖同序列上已有的函数
widget.bind('<事件序列>', 处理函数, add='+') # 追加,不覆盖
widget.bind_all('<事件序列>', 处理函数) # 整个应用范围都收到 -
处理函数固定收一个参数(event 对象),不需要时也要写
def f(event):或lambda e: ...,否则 Tk 抛TypeError。 -
实测 event 对象的字段(同一个事件,鼠标和键盘各自有什么):
1
2
3
4
5
6
7
8
9
10# 鼠标事件(<Button-1>,用 event_generate 指定 x=3, y=4)
btn.x = 3 btn.y = 4 btn.x_root = 302 btn.y_root = 185
btn.num = 1 btn.widget = <tkinter.Button object .!button>
btn.state = 0 btn.time = 0
btn.keysym = '??' btn.keycode = '??' btn.char = '??'
btn.detail -> AttributeError: 'Event' object has no attribute 'detail'
# 键盘事件(<Key> keysym='a')
key.char = 'a' key.keycode = 65 key.keysym = 'a'
key.num = '??' key.x = -159 key.y = -182字段 含义 实测备注 x, y 相对控件内部的坐标 键盘事件里这两个值是垃圾数(实测 -159),别用 x_root, y_root 相对屏幕的绝对坐标 post菜单要用它;合成事件不传就是 -1num 鼠标按键编号 左 1、中 2、右 3 keysym 按键名字 'a'、'A'、'Return'、'BackSpace'keycode 系统键码 a/A 都是 65,BackSpace 8,Up 38 char 换算出的字符 功能键是空串,回车是 '\r'widget 触发事件的控件对象 用它判断是谁 state 修饰键位掩码 按位表示 Shift/Ctrl/Alt 等 time 事件时间戳 合成事件里是 0 -
鼠标事件上没有
detail这个属性(AttributeError),而keysym/keycode/char存在但值是字符串'??'。这两件事都很坑:1
2if event.char: # 鼠标事件里 char 是 '??',非空,条件成立 -> 错判
int(event.keycode) # 鼠标事件里 '??' 直接 ValueError- 安全写法是先看
event.type/绑定的事件序列,或者isinstance(event.keycode, int)再转。 - 想读可能不存在的字段用
getattr(event, 'detail', None),它会接住AttributeError。
- 安全写法是先看
-
鼠标事件
鼠标点击事件
1 | import tkinter |
-
编号和按键的对应关系实测正确:
num=1左、2中、3右。 -
<Button-1>是「按下」的别名,不是「一次完整点击」,这是最容易理解错的一条。实测:1
2
3绑了 ButtonPress-1 / ButtonRelease-1 / Button-1 / Double-Button-1 四个处理函数
发一次 <Button-1> -> 只有 ['click'] # 只命中 press 那条
发完整的 press+release 两次 -> ['double', 'release', 'double', 'release']- 也就是说连点时 Tk 会把这次按下归类成
Double,<Button-1>(单击)那条绑定就不跑了,抬起始终会跑。想要「一次干净的点击」,绑<ButtonRelease-1>比绑<Button-1>可靠。
- 也就是说连点时 Tk 会把这次按下归类成
-
连击是累积的:同一位置连续发 4 次
<Button-1>,实测依次命中click、double、triple、triple(第 4 次仍算三击)。
鼠标移动事件
1 | import tkinter |
-
<B1-Motion>是「按住左键拖动」,不按键的移动要绑<Motion>,实测两者都收得到:1
2
3按下 -> 拖到 (25,35) -> 抬起,收到的顺序:
[('enter',1,1), ('motion',25,35), ('release',25,35), ('leave',400,400)]
单独发 <Motion> -> [('Motion', 9, 9)]<B1-Motion>触发频率极高(鼠标每动一点就来一次),处理函数里别放重活,典型做法是只做「记位置」,真正的事留在<ButtonRelease-1>里做。
鼠标释放事件
1 | import tkinter |
- 实测
release的x/y是抬起处的坐标,和拖动的终点一致((25,35)),所以「拖出一个矩形再算面积」这类操作要绑它。
鼠标进入和离开事件
1 | import tkinter |
-
<Enter>是鼠标进入,不是回车键! 回车是<Return>(小键盘回车在 Windows 上叫<KP_Enter>,实测合成它会报no keycode for keysym "KP_Enter")。这是 Tkinter 最经典的命名坑,实测确认:1
2
3
4绑 <Enter> 的控件收到的是鼠标 enter 事件,坐标是鼠标进入处的位置
绑 <Return> 的窗口在合成事件时要先有焦点才收得到:
有焦点之后 = [('Return', 'Return', 13, "'\\r'")]
没焦点时 = []
按键事件
响应所有按键事件
1 | import tkinter |
-
<Key>是「按下和抬起都收」的总称,只想要其中一个就绑<KeyPress>或<KeyRelease>,实测顺序:1
依次发 KeyPress、KeyRelease -> ['press', 'release']
-
原笔记注释掉的「
label.focus_set()绑在控件上」那种写法也对,区别是绑在win上是整窗范围,绑在具体控件上要那个控件有焦点。实测焦点没设对时收不到键盘事件,focus_force()比focus_set()更能真正把焦点抢过来。 -
实测各字符的字段(合成事件里只给
keysym就够,char由 Tk 自己算):1
2
3
4
5char='a' keycode=65 keysym=a state=0
char='A' keycode=65 keysym=A state=1
char='' keycode=8 keysym=BackSpace state=0
char='' keycode=38 keysym=Up state=0
char=' ' keycode=32 keysym=space state=0- 字母的大小写 keycode 相同(都是 65),靠
keysym和state区分,所以判断「按了 A」要看event.keysym,看keycode会误判。 - 功能键(退格、方向键)的
char是空串,keysym才有名字。
- 字母的大小写 keycode 相同(都是 65),靠
响应特殊按键事件
1 | import tkinter |
- 这些名字都是合法的 keysym,实测绑
<BackSpace>能命中:('BackSpace', 8, "''")。 - 有个反直觉的点:单独绑
<Shift_L>时,实测合成事件收不到(修饰键会被当成 state 变化而不是按键事件),要靠<Key>配event.state判断,或者绑具体的组合键。 label.focus_set()写在label.pack()之前也能跑(实测没报错),但习惯上先 pack 再 focus,某些平台在控件还没映射时设焦点会不生效。
指定按键事件
1 | import tkinter |
- 事件序列的尖括号可以省,实测
win.bind('a', func)正常工作并收到了事件。不过推荐写全<a>,可读性好,也能避免和某些 Tcl 简写冲突。
组合按键事件
1 | import tkinter |
-
写法是修饰键用
Control-、Alt-、Shift-前缀,大小写敏感:实测<Control-Alt-c>、<Shift-Up>、<Control-p>、<Control-KeyPress-p>四种绑定都能注册成功(最后一个表示「只在按下时」)。 -
组合键的触发不好用合成事件验证:实测
event_generate('<Control-Alt-c>', keysym='c', keycode=67, state=12)没能命中处理函数——state位掩码要对得上(Shift=1、Control、Alt 各位按平台不同),手写这个数不可靠。这类快捷键请在真实键盘上按一遍确认。 -
快捷键最常见的两种用法:
1
2win.bind('<Control-s>', lambda e: save()) # Ctrl+S
win.bind('<Escape>', lambda e: win.destroy()) # Esc 关窗
绑定行为的三个实测细节
-
同一个控件同一个事件重复
bind会覆盖,只保留最后一个;要并存就加add='+'(或add=True),实测:1
2绑两次(默认) -> 只跑 ['第二个']
第二次 add=True -> 跑 ['第二个', '第三个'] -
处理函数返回字符串
"break"用来阻止后续处理,但实测作用范围有限:同一控件上add='+'追加的那几个回调照样全部执行,父容器的绑定也仍然会跑:1
2按钮上的处理函数返回 "break" -> 实际收到 ['按钮', '窗口']
同一层三个 add 的回调,第一个返回 break -> ['A', 'B', 'C', '窗口']- 所以别指望
break当「终止一切」用,它的定位是阻止 Tk 的类绑定(比如让 Text 不处理某个键)这一层。
- 所以别指望
-
bind_all是全局的,任何控件上发生的事件都会进这个回调,实测在另一个按钮上点也能收到。想解绑:1
2b.unbind('<Button-1>') 返回 None,之后该控件不再触发(bind_all 的还在)
方法是 unbind_all(有下划线),不存在 unbindall -
窗口事件两个必用的:
1
2
3<Configure>:尺寸或位置一变就触发,实测一次改大小收到 3 次(窗口和它的子控件都会发),
要区分是谁变的就比 event.widget
WM_DELETE_WINDOW:接管右上角关闭按钮1
win.protocol('WM_DELETE_WINDOW', lambda: (save(), win.destroy()))
- 不注册
protocol的话点关闭就直接退了,没保存提示要自己做。
- 不注册
-
自动关窗的组合(本篇所有实测都靠它):
1
2win.after(1000, win.destroy) # 1 秒后自己关掉
win.mainloop()- 实测这种写法窗口会正常出现并在 1 秒后销毁,
mainloop()随之返回。
- 实测这种写法窗口会正常出现并在 1 秒后销毁,