Tkinter

Tkinter

  • 本篇的所有窗口都在本机真的弹出来过、真的关掉了(每个示例跑完自动 destroy),下面贴的不是「应该是这样」,是实测到的数值:控件坐标、尺寸、cget 回读、事件字段、报错原文。
  • 实测环境:

    1
    2
    3
    4
    5
    6
    7
    8
    Python 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提供了多个图形开发界面的库

  • 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
2
3
4
5
6
7
8
9
10
11
import tkinter

# 创建主窗口
win = tkinter.Tk()
# 设置标题
win.title('Hello')
# 设置大小和位置,大小:400x400,位置:+200+200
win.geometry('400x400+200+200')

# 进入消息循环
win.mainloop()
  • 这段跑起来就是一个 400x400、左上角在 (200, 200) 的空窗口。把 geometry 的各种写法实测一遍,winfo_* 是真实读回来的值:

    1
    2
    3
    4
    for 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
2
3
4
5
6
7
8
9
import tkinter

win = tkinter.Tk()
win.geometry("400x400+120+120")
win.update()
print(win.winfo_screenwidth(), win.winfo_screenheight())
print(win.winfo_rootx(), win.winfo_rooty(), win.winfo_pointerxy())
print(win.winfo_reqwidth(), win.winfo_reqheight())
win.destroy()
1
2
3
2048 1152
128 151 (720, 480)
200 200
  • 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
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
28
29
30
31
32
33
34
35
36
37
38
import tkinter

win = tkinter.Tk()
win.title('Label')
win.geometry("400x400+200+200")

'''
Label:标签控件可以显示文本
参数:
win 父窗体
text 显示的文本内容
bg 背景色
fg 字体颜色
font (字体, 大小)
width 宽度
height 高度
wraplength 指定text文本中多宽进行换行
justify 设置换行后的对齐方法,left、right、center
anchor 位置:n上北 s下南 w左西 e右东 c(center)居中 ne se sw nw
'''

label = tkinter.Label(
win,
text='天下无双',
bg='blue',
fg='white',
font=('楷体', 20),
width=30,
height=5,
wraplength=10,
justify='left',
anchor='c',
)

# 显示出来
label.pack()

win.mainloop()
  • 参数说明基本都对,但有三个要改成「实测口径」:

    • 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
    3
    label.config(text='换了文字')
    label['text'] = '又换了一次'
    print(label.cget('text'), label['bg'])

Button控件

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
import os
import tkinter


def func():
print('天下无双')


win = tkinter.Tk()
win.title('Button')
win.geometry("400x400+200+200")

# 创建按钮
button1 = tkinter.Button(win, width=10, text='按钮1', command=func)
button1.pack()

button2 = tkinter.Button(win, width=10, text='按钮2', command=lambda: print('国士无双'))
button2.pack()

# 打开记事本
button3 = tkinter.Button(win, width=10, text='notepad', command=lambda: os.system('notepad'))
button3.pack()

button4 = tkinter.Button(win, width=10, text='退出', command=win.quit)
button4.pack()

win.mainloop()
  • 三种 command 写法都对:普通函数、lambda、以及 win.quit 这种「函数本身」。唯一不能写的是 command=func()——那样在构造按钮时就执行了一次,command 收到的是返回值 None,点按钮反而没反应。实测:

    1
    tkinter.Button(win, command=func())   # 构造时就打印了一次「点击回调 func()」
  • button3 是这段代码最大的坑:os.system 会阻塞。 实测在本机跑 os.system('notepad.exe'):

    1
    2
    os.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
      2
      subprocess.Popen 不阻塞,耗时 = 0.001 秒, pid = 51220
      Popen + kill + wait 后返回码 = 1
  • command=win.quit 与 command=win.destroy 的区别:quit() 只结束 mainloop(),窗口对象还在(后面还能再 mainloop());destroy() 是真的销毁窗口。退出程序用 quit 更安全。

  • 程序里想「模拟点一下按钮」,用 invoke(),别用合成事件:

    1
    2
    3
    4
    event_generate('<ButtonRelease-1>')                  -> 不触发 command
    event_generate('<ButtonPress-1>') + ('<ButtonRelease-1>') -> 也不触发 command
    button.invoke() -> 触发 command(实测执行了一次)
    按钮拿到焦点后 event_generate('<space>', keysym='space', keycode=32) -> 触发 command

Entry控件

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
28
import tkinter

# 创建主窗口
win = tkinter.Tk()
# 设置标题
win.title('Entry')
# 设置大小和位置
win.geometry('400x400-200-200')

'''
输入控件
用于显示简单的文本内容
'''

# 绑定变量
e = tkinter.Variable()
# show 密文显示 show='*'
entry = tkinter.Entry(win, textvariable=e)
entry.pack()

# e就代表输入框这个对象
# 设置值
e.set('请输入密码')
# 取值
print(e.get())
print(entry.get())

win.mainloop()
  • 实测两个 get() 拿到的是同一个值:

    1
    2
    e.get()      = '请输入密码'
    entry.get() = '请输入密码'
  • 建议把 tkinter.Variable() 换成 tkinter.StringVar()。Variable 是这一族类的基类,能跑,但它的 get() 返回的是 Tcl 层原样转换出来的东西(列表会变成元组),而 StringVar/IntVar/BooleanVar/DoubleVar 会帮你把类型定死。实测各类型的转换行为:

    1
    2
    3
    4
    5
    6
    StringVar().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
    3
    state='normal'   -> 可读写
    state='readonly' -> 只能读,程序里 insert 实测被忽略且不报错(内容保持原样)
    state='disabled' -> 同上,insert 不报错但写不进去
    • 「不报错但没写进去」比抛异常更难查,所以置灰输入框后要记得先把 state 改回 normal 再赋值。
  • 变量变化可以被监听(比到处手写刷新函数干净):

    1
    2
    3
    v = tkinter.StringVar()
    v.trace_add('write', lambda *args: print('变了', args))
    v.set('新值')
    1
    变了 ('PY_VAR1', '', 'write')
    • 回调固定收三个参数:变量在 Tcl 里的名字、空串、动作(write/read/unset)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import tkinter

# 创建主窗口
win = tkinter.Tk()
# 设置标题
win.title('Entry+Button')
# 设置大小和位置
win.geometry('400x400-200-200')


def showInfo():
print(entry.get())


entry = tkinter.Entry(win)
entry.pack()

button = tkinter.Button(win, width=10, text="显示输入", command=showInfo)
button.pack()

win.mainloop()
  • 点按钮就把输入框内容打印出来。这里 entry 是全局名字,回调里直接引用,是 Tkinter 入门最常见的写法;控件多了以后建议改成「回调里通过闭包或类属性拿控件」,不然变量名会互相覆盖。

Text控件

  • 不带滚动条:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import 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
      3
      text.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
    28
    import 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
      3
      40 行文本塞进 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
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
28
29
30
31
32
33
34
35
36
37
import tkinter

win = tkinter.Tk()
win.title('CheckButton')
win.geometry('400x400-200-200')


def update():
message = ''
if hobby1.get() == True:
message += 'money\n'
if hobby2.get() == True:
message += 'power\n'
if hobby3.get() == True:
message += 'people\n'

# 清除text中的所有内容
text.delete(0.0, tkinter.END)
text.insert(tkinter.INSERT, message)


# 要绑定的变量
hobby1 = tkinter.BooleanVar()
hobby2 = tkinter.BooleanVar()
hobby3 = tkinter.BooleanVar()
# 多选框
check1 = tkinter.Checkbutton(win, text='money', variable=hobby1, command=update)
check2 = tkinter.Checkbutton(win, text='power', variable=hobby2, command=update)
check3 = tkinter.Checkbutton(win, text='people', variable=hobby3, command=update)
check1.pack()
check2.pack()
check3.pack()

text = tkinter.Text(win, width=50, height=5)
text.pack()

win.mainloop()
  • 控件的类名是 Checkbutton(b 小写),原笔记的小标题写「CheckButton」只是称呼,写代码时大小写错了会 AttributeError。Radiobutton 同理。

  • 实测行为:

    1
    2
    3
    4
    BooleanVar() 默认 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import tkinter

win = tkinter.Tk()
win.title('RadioButton')
win.geometry('400x400-200-200')


def update():
print(r.get())


# 一组单选框要绑定同一个变量
r = tkinter.IntVar()

radio1 = tkinter.Radiobutton(win, text='one', value=1, variable=r, command=update)
radio2 = tkinter.Radiobutton(win, text='two', value=2, variable=r, command=update)
radio3 = tkinter.Radiobutton(win, text='three', value=3, variable=r, command=update)

radio1.pack()
radio2.pack()
radio3.pack()

win.mainloop()
  • 「一组共用一个变量」这句是关键,实测互斥生效:

    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
    4
    tkinter.SINGLE   = 'single'
    tkinter.BROWSE = 'browse'
    tkinter.MULTIPLE = 'multiple'
    tkinter.EXTENDED = 'extended'
    • SINGLE:只能选一个,不支持「按住拖过几项就连选」。
    • BROWSE(默认):和 SINGLE 类似,但按下后拖动鼠标可以改选,鼠标抬起才定下来,用来做菜单式选择最顺手。
    • MULTIPLE:点一下就切换那一项的选中状态,可以随意点选多项。
    • EXTENDED:支持 Shift 连选、Ctrl 点选,是最常用的多选模式。

Listbox控件上(BROWSE)

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import tkinter

win = tkinter.Tk()
win.title('Listbox-BROWSE')
win.geometry('400x400-200-200')

'''
列表框控件,可以包含一个或者多个文本框
作用:在listbox控件的小窗口显示一个字符串
'''

# 创建一个listbox, 添加几个元素
lb = tkinter.Listbox(win, selectmode=tkinter.BROWSE)
lb.pack()
for item in ['good', 'nice', 'handsome', 'vg', 'vn']:
# 按顺序添加
lb.insert(tkinter.END, item)

# 在开始添加
lb.insert(tkinter.ACTIVE, 'cool')

# 将列表当成一个元素添加的
lb.insert(tkinter.END, ['very good', 'very nice'])

# 删除 参数1为开始的索引,参数2为结束的索引,如果不指定参数2,只删除第一个索引处的内容
# lb.delete(1,3)
# lb.delete(1)

# 选中 参数1为开始的索引,参数2为结束的索引,如果不指定参数2,只选中第一个索引处的内容
lb.select_set(2, 5)
# lb.select_set(2)

# 取消
# lb.select_clear(2,4)
# lb.select_clear(3)

# 获取到列表中的元素的个数
# print(lb.size())

# 从列表中取值 参数1为开始的索引,参数2为结束的索引,如果不指定参数2,只获取第一个索引处的内容
# print(lb.get(2, 4))
# print(lb.get(2))

# 返回当前的索引项,不是item元素
print(lb.curselection())

# 判断一个选项是否被选中
print(lb.selection_includes(1))
print(lb.selection_includes(3))

win.mainloop()
  • 实测这一段的最终状态(把注释掉的调用逐个放开跑过):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    size = 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') 类型 tuple
    • insert(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
    2
    index('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
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
28
29
30
31
import tkinter

win = tkinter.Tk()
win.title('Listbox-SINGLE')
win.geometry('400x400-200-200')

# 绑定变量
lbv = tkinter.StringVar()
# 与BROWSE相似,但是不支持鼠标按下后移动选中位置
lb = tkinter.Listbox(win, selectmode=tkinter.SINGLE, listvariable=lbv)
lb.pack()
for item in ['good', 'nice', 'handsome', 'vg', 'vn']:
# 按顺序添加
lb.insert(tkinter.END, item)

# 打印当前列表中的选项
print(lbv.get())


# 设置选项
# lbv.set(('1', '2', '3'))


# 绑定事件
def myPrint(event):
print(lb.get(lb.curselection()))


lb.bind('<Double-Button-1>', myPrint)

win.mainloop()
  • listvariable 绑的变量存的是整个列表(不是选中项),实测:

    1
    2
    3
    4
    lbv.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
    3
    sel = lb.curselection()
    if sel:
    print(lb.get(sel[0]))

Listbox控件中下(EXTENDED)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import tkinter

win = tkinter.Tk()
win.title('Listbox-EXTENDED')
win.geometry('-200-200')

# EXTENDED 可以使listbox支持shift和control
# 按住shift,可以实现连选
# 按住control,可以实现多选
lb = tkinter.Listbox(win, selectmode=tkinter.EXTENDED)
for item in ['good', 'nice', 'handsome', 'vg', 'vn', 'good1', 'nice1', 'handsome1', 'vg1', 'vn1', 'good3', 'nice3',
'handsome3', 'vg3', 'vn3']:
lb.insert(tkinter.END, item)

# 滚动条
sc = tkinter.Scrollbar(win)
sc.pack(side=tkinter.RIGHT, fill=tkinter.Y)
lb.configure(yscrollcommand=sc.set)
lb.pack(side=tkinter.LEFT, fill=tkinter.BOTH)
# 额外给属性赋值
sc['command'] = lb.yview

win.mainloop()
  • 这段没调 mainloop() 前把 15 项全塞进去了,实测 size()=15、index('end')=15,yscrollcommand 和 command 两条联动都写上了(这里用的是 sc['command'] = lb.yview 这种下标写法,和 config(command=...) 等价)。
  • EXTENDED 模式下多选的结果一律从 curselection() 读,项数多时它是排序好的整数元组。

Listbox控件下(MULTIPLE)

1
2
3
4
5
6
7
8
9
10
11
12
13
import tkinter

win = tkinter.Tk()
win.title('Listbox-MULTIPLE')
win.geometry('400x400-200-200')

# MULTIPLE 支持多选
lb = tkinter.Listbox(win, selectmode=tkinter.MULTIPLE)
lb.pack()
for item in ['good', 'nice', 'handsome', 'vg', 'vn']:
lb.insert(tkinter.END, item)

win.mainloop()
  • 四种模式的代码只有 selectmode 一个词不同,其余全一样,实际项目里可以直接把模式做成参数。

Scale控件

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
28
import tkinter

win = tkinter.Tk()
win.title('Scale')
win.geometry('400x400-200-200')

'''
供用户通过拖拽指示器改变变量的值,可以水平,也可以竖直
tkinter.HORIZONTAL 水平
tkinter.VERTICAL 竖直
length 水平时表示宽度,竖直时表示高度
tickinterval 选择值将会为该值的倍数
'''
scale = tkinter.Scale(win, from_=0, to=100, orient=tkinter.HORIZONTAL, tickinterval=20, length=200)
scale.pack()

# 设置值
scale.set(20)


# 取值
def showNum():
print(scale.get())


tkinter.Button(win, text='取值', command=showNum).pack()

win.mainloop()
  • 注释里那句「tickinterval 选择值将会为该值的倍数」是错的,实测:

    1
    2
    3
    tickinterval=20 时 scale.set(37) -> get() = 37          # 不会吸附到 40
    再把 resolution=10 设上,set(37) -> get() = 40 # 吸附是 resolution 干的
    resolution 默认值 = 1.0
    • tickinterval 只决定刻度标签每隔多少显示一个数字,管的是「好不好看」;真正把值吸附到某个倍数的是 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
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
import tkinter

win = tkinter.Tk()
win.title('Spinbox')
win.geometry('400x400-200-200')


def update():
print(v.get())


# 绑定个变量
v = tkinter.StringVar()

# increment 步长 默认为1
# values 最好不要与from_=0, to=100和increment=2同时使用
# values=(0, 2, 4, 6, 8)
# command 只要值改变就会执行对应的方法
sp = tkinter.Spinbox(win, from_=0, to=100, increment=1, textvariable=v, command=update)
sp.pack()

# 赋值
v.set(20)
# 取值
print(v.get())

win.mainloop()
  • 实测:

    1
    2
    3
    4
    5
    6
    7
    from_=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。
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
28
29
30
31
32
33
34
35
import tkinter

win = tkinter.Tk()
win.title('Menu顶层菜单')
win.geometry('400x400-200-200')

# 菜单条
menubar = tkinter.Menu(win)
win.config(menu=menubar)


def func():
print('顶层菜单')


# 创建一个菜单选项
menu1 = tkinter.Menu(menubar, tearoff=False)
# 给菜单选项添加内容
for item in ['Python', 'C', 'C++', 'OC', 'Swift', 'C#', 'shell', 'Java', 'JS', 'PHP', '汇编', 'NodeJS', '退出']:
if item == '退出':
# 添加分隔线
menu1.add_separator()
menu1.add_command(label=item, command=win.quit)
else:
menu1.add_command(label=item, command=func)

# 向菜单条上添加菜单选项
menubar.add_cascade(label='语言', menu=menu1)

menu2 = tkinter.Menu(menubar, tearoff=False)
menu2.add_command(label='深色主题')
menu2.add_command(label='浅色主题')
menubar.add_cascade(label='主题', menu=menu2)

win.mainloop()
  • 结构是三层:菜单条(挂在窗口上)→ 级联菜单(add_cascade)→ 菜单项(add_command)。少了 win.config(menu=menubar) 这一步,菜单条不会出现。

  • 实测读数(简化成 4 项的版本):

    1
    2
    3
    4
    5
    6
    menubar.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++),不是分隔线后面那个。
  • 菜单项还有别的类型,实测可用的方法:add_command、add_checkbutton、add_radiobutton、add_cascade、add_separator,配合 entryconfigure/delete/insert 可以动态改。

  • 用 lambda 带参数时要闭包写法,command=lambda i=item: func(i);直接 command=func 会让所有项都调同一个无参函数(原笔记就是这样,所以点哪一项打印都一样)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import tkinter

win = tkinter.Tk()
win.title('Menu鼠标右键菜单')
win.geometry('400x400-200-200')

# 菜单条
menubar = tkinter.Menu(win)

# 菜单
menu = tkinter.Menu(menubar, tearoff=False)
for item in ["Python", "C", "C++", "OC", "Swift", "C#", "shell", "Java", "JS", "PHP", "汇编", "NodeJS", "退出"]:
menu.add_command(label=item)

menubar.add_cascade(label="语言", menu=menu)


def showMenu(event):
menubar.post(event.x_root, event.y_root)


win.bind("<Button-3>", showMenu)

win.mainloop()
  • 和顶层菜单唯一的区别是不要 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
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
import tkinter
from tkinter import ttk

win = tkinter.Tk()
win.title('Combobox')
win.geometry('400x400-200-200')

# 绑定变量
cv = tkinter.StringVar()
com = ttk.Combobox(win, textvariable=cv)
com.pack()
# 设置下拉数据
com['value'] = ('黑龙江', '吉林', '辽宁', '广州', '深圳', '北京', '上海')

# 设置默认值
com.current(5)


# 绑定事件
def func(event):
print(com.get())
print(cv.get())


com.bind('<<ComboboxSelected>>', func)

win.mainloop()
  • 实测读数:

    1
    2
    3
    4
    5
    current(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
    4
    from 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
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
import tkinter

win = tkinter.Tk()
win.title('Frame')
win.geometry('400x400-200-200')

'''
框架控件
作用:在屏幕上显示一个矩形区域,多作为容器控件
'''

frm = tkinter.Frame(win)
frm.pack()

# left
frm_l = tkinter.Frame(frm)
tkinter.Label(frm_l, text='左上', bg='pink').pack(side=tkinter.TOP)
tkinter.Label(frm_l, text='左下', bg='blue').pack(side=tkinter.TOP)
frm_l.pack(side=tkinter.LEFT)

# Right
frm_r = tkinter.Frame(frm)
tkinter.Label(frm_r, text='右上', bg='red').pack(side=tkinter.TOP)
tkinter.Label(frm_r, text='右下', bg='yellow').pack(side=tkinter.TOP)
frm_r.pack(side=tkinter.RIGHT)

win.mainloop()

Frame

  • 实测出来的坐标(400x300 窗口,update() 之后读):

    1
    2
    3
    frm    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
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
28
29
30
import tkinter
from tkinter import ttk

win = tkinter.Tk()
win.title('表格数据')
win.geometry('600x400-200-200')

# 表格
tree = ttk.Treeview(win)
tree.pack()

# 定义列
tree['columns'] = ('姓名', '年龄', '身高', '体重')
# 设置列,列还不显示
tree.column('姓名', width=100)
tree.column('年龄', width=100)
tree.column('身高', width=100)
tree.column('体重', width=100)

# 设置表头
tree.heading('姓名', text='姓名(name)')
tree.heading('年龄', text='年龄(age)')
tree.heading('身高', text='身高(height)')
tree.heading('体重', text='体重(weight)')

# 添加数据
tree.insert('', 0, text='line1', values=('陆彦旭', '28', '165', '80'))
tree.insert('', 1, text='line2', values=('范育宾', '29', '167', '70'))

win.mainloop()

表格数据

  • 四步是固定的:定列(columns)→ 设列宽(column)→ 设表头(heading)→ 塞数据(insert)。少第一步的话后两步会报「列不存在」。

  • 实测读数:

    1
    2
    3
    4
    5
    6
    7
    tree['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
2
3
4
iid = tree.insert('', tkinter.END, '自定义iid', text='带 iid 的行', values=('张三', 1, 2, 3))
print(iid, tree.exists(iid), tree.index(iid))
tree.set(iid, '年龄', '30')
tree.delete(iid)
1
2
3
custom True 2
改一个格 = 30
删除之后 exists = False
  • 建议主动给 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import tkinter
from tkinter import ttk

win = tkinter.Tk()
win.title('树状数据')
win.geometry('400x400-200-200')

tree = ttk.Treeview(win, height=15)
tree.pack()

# 添加一级树枝
treeF1 = tree.insert('', 0, '中国', text='中国CHI', values=('F1',))
treeF2 = tree.insert('', 1, '美国', text='美国USA', values=('F2',))
treeF3 = tree.insert('', 2, '英国', text='英国ENG', values=('F3',))

# 二级树枝
treeF1_1 = tree.insert(treeF1, 0, '黑龙江', text='中国黑龙江', values=('F1_1',))
treeF1_2 = tree.insert(treeF1, 1, '吉林', text='中国吉林', values=('F1_2',))
treeF1_3 = tree.insert(treeF1, 2, '辽宁', text='中国辽宁', values=('F1_3',))

# 三级树枝
treeF1_1_1 = tree.insert(treeF1_1, 0, '哈尔滨', text='黑龙江哈尔滨')
treeF1_1_2 = tree.insert(treeF1_1, 1, '五常', text='黑龙江五常')

win.mainloop()

树状数据

  • 树的层级就是靠 insert(父iid, 位置, iid, ...) 的第一个参数表达;子节点 iid 用中文也能当键,实测把 insert 的返回值当父节点传进去即可。

  • 实测读回来的结构:

    1
    2
    3
    4
    5
    6
    7
    insert 返回的 iid = '中国' '黑龙江'
    顶层 get_children('') = ('中国', '美国', '英国')
    get_children('中国') = ('黑龙江', '吉林', '辽宁')
    get_children('黑龙江') = ('哈尔滨', '五常')
    parent('哈尔滨') = '黑龙江' parent('中国') = ''(顶层的 parent 是空串)
    初始 item('中国','open') = 0(默认收起)
    item('中国', open=True) 之后 = 1
  • 展开/收起的正确写法只有这一种:

    1
    2
    tree.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import tkinter

win = tkinter.Tk()
win.title('绝对布局')
win.geometry('400x400-200-200')

label1 = tkinter.Label(win, text='good', bg='orange')
label2 = tkinter.Label(win, text='nice', bg='red')
label3 = tkinter.Label(win, text='cool', bg='pink')

# 绝对布局,窗口的变化对位置没有影响
label1.place(x=10, y=10)
label2.place(x=50, y=50)
label3.place(x=100, y=100)

win.mainloop()

绝对布局

  • 实测坐标和尺寸(update() 之后读):

    1
    2
    3
    4
    good  在 10,10   尺寸 38x23
    nice 在 50,50 尺寸 29x23
    cool 在 100,100 尺寸 31x23
    把窗口改成 600x500 之后:三个标签仍然在 10,10 / 50,50 / 100,100
    • 「窗口变化对位置没有影响」这句是对的,实测改大小后坐标一个都没动。
  • place 还有两种很有用的写法,实测:

    1
    2
    3
    relx=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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import tkinter

win = tkinter.Tk()
win.title('相对布局')
win.geometry('400x400-200-200')

label1 = tkinter.Label(win, text='good', bg='orange')
label2 = tkinter.Label(win, text='nice', bg='red')
label3 = tkinter.Label(win, text='handsome', bg='pink')
label4 = tkinter.Label(win, text='beautiful', bg='green')

# 相对布局,窗体改变对控件有影响
# tkinter.BOTH
label1.pack(fill=tkinter.Y, side=tkinter.LEFT)
label2.pack(fill=tkinter.Y, side=tkinter.RIGHT)
label3.pack(fill=tkinter.X, side=tkinter.TOP)
label4.pack(fill=tkinter.X, side=tkinter.BOTTOM)

win.mainloop()

相对布局

  • 实测两种窗口大小下的布局结果,能直接看出 fill 和 side 的语义:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    400x400 时:
    左 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import tkinter

win = tkinter.Tk()
win.title('表格布局')
win.geometry('400x400-200-200')

label1 = tkinter.Label(win, text='good', bg='orange', width=10)
label2 = tkinter.Label(win, text='wonderful', bg='red', width=10)
label3 = tkinter.Label(win, text='cool', bg='pink', width=10)
label4 = tkinter.Label(win, text='handsome', bg='yellow', width=10)

# 表格布局
label1.grid(row=0, column=0)
label2.grid(row=0, column=1)
label3.grid(row=1, column=0)
label4.grid(row=1, column=1)

win.mainloop()

表格布局

  • 实测坐标(width=10 的 Label 是 76 像素宽,所以第二列从 x=76 开始):

    1
    2
    3
    4
    5
    6
    good       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 一节)。
  • 三种布局的选择:绝大多数界面用 grid(表单、表格)或 pack(工具条、上下堆叠)就够了;只有需要精确像素位置或叠放时才用 place。

事件

事件

事件属性

  • 绑定事件的通用形式:

    1
    2
    3
    widget.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 菜单要用它;合成事件不传就是 -1
    num 鼠标按键编号 左 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
      2
      if event.char:          # 鼠标事件里 char 是 '??',非空,条件成立 -> 错判
      int(event.keycode) # 鼠标事件里 '??' 直接 ValueError
      • 安全写法是先看 event.type/绑定的事件序列,或者 isinstance(event.keycode, int) 再转。
      • 想读可能不存在的字段用 getattr(event, 'detail', None),它会接住 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
import tkinter

win = tkinter.Tk()
win.title('鼠标点击事件')
win.geometry('400x400-200-200')

'''
<Button-1> 鼠标左键
<Button-3> 鼠标右键
<Button-2> 鼠标中键
<Double-Button-1> 鼠标左键双击
<Double-Button-3> 鼠标右键双击
<Triple-Button-1> 鼠标左键三击
<Triple-Button-3> 鼠标右键三击
'''


def func(event):
print(event.x, event.y)


button = tkinter.Button(win, text='leftMouse button')
button.bind('<Triple-Button-1>', func)
button.pack()

win.mainloop()
  • 编号和按键的对应关系实测正确: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> 可靠。
  • 连击是累积的:同一位置连续发 4 次 <Button-1>,实测依次命中 click、double、triple、triple(第 4 次仍算三击)。

鼠标移动事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import tkinter

win = tkinter.Tk()
win.title('鼠标移动事件')
win.geometry('400x400-200-200')

'''
<B1-Motion> 左键移动
<B3-Motion> 右键移动
<B2-Motion> 中键移动
'''

label = tkinter.Label(win, text='鼠标移动事件', bg='red')
label.pack()


def func(event):
print(event.x, event.y)


label.bind('<B1-Motion>', func)

win.mainloop()
  • <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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import tkinter

win = tkinter.Tk()
win.title('鼠标释放事件')
win.geometry('400x400-200-200')

'''
<ButtonRelease-1> 释放鼠标左键
<ButtonRelease-3> 释放鼠标右键
<ButtonRelease-2> 释放鼠标中键
'''

label = tkinter.Label(win, text='鼠标释放事件', bg='red')
label.pack()


def func(event):
print(event.x, event.y)


label.bind('<ButtonRelease-1>', func)

win.mainloop()
  • 实测 release 的 x/y 是抬起处的坐标,和拖动的终点一致((25,35)),所以「拖出一个矩形再算面积」这类操作要绑它。

鼠标进入和离开事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import tkinter

win = tkinter.Tk()
win.title('鼠标进入和离开事件')
win.geometry('400x400-200-200')

'''
<Enter> 鼠标光标进入控件时触发
<Leave> 鼠标光标离开控件时触发
'''

label = tkinter.Label(win, text='鼠标进入和离开事件', bg='red')
label.pack()


def func(event):
print(event.x, event.y)


label.bind('<Leave>', func)

win.mainloop()
  • <Enter> 是鼠标进入,不是回车键! 回车是 <Return>(小键盘回车在 Windows 上叫 <KP_Enter>,实测合成它会报 no keycode for keysym "KP_Enter")。这是 Tkinter 最经典的命名坑,实测确认:

    1
    2
    3
    4
    绑 <Enter> 的控件收到的是鼠标 enter 事件,坐标是鼠标进入处的位置
    绑 <Return> 的窗口在合成事件时要先有焦点才收得到:
    有焦点之后 = [('Return', 'Return', 13, "'\\r'")]
    没焦点时 = []

按键事件

响应所有按键事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import tkinter

win = tkinter.Tk()
win.title('响应所有按键事件')
win.geometry('400x400-200-200')

'''
<Key> 响应所有的按键
'''


def func(event):
print('event.char =', event.char)
print('event.keycode =', event.keycode)


win.bind('<Key>', func)

win.mainloop()
  • <Key> 是「按下和抬起都收」的总称,只想要其中一个就绑 <KeyPress> 或 <KeyRelease>,实测顺序:

    1
    依次发 KeyPress、KeyRelease -> ['press', 'release']
  • 原笔记注释掉的「label.focus_set() 绑在控件上」那种写法也对,区别是绑在 win 上是整窗范围,绑在具体控件上要那个控件有焦点。实测焦点没设对时收不到键盘事件,focus_force() 比 focus_set() 更能真正把焦点抢过来。

  • 实测各字符的字段(合成事件里只给 keysym 就够,char 由 Tk 自己算):

    1
    2
    3
    4
    5
    char='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 才有名字。

响应特殊按键事件

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
28
29
30
31
32
import tkinter

win = tkinter.Tk()
win.title('响应特殊按键事件')
win.geometry('400x400-200-200')

'''
<Alt_L> 左Alt
<Alt_R> 右Alt
<Shift_L> 左shift
<Shift_R> 右shift
<Control_L> 左Control
<Control_R> 右Control
<F5> F5
<Return> 回车
<BackSpace> 退格
'''

label = tkinter.Label(win, text='响应特殊按键事件', bg='red')
# 设置焦点
label.focus_set()
label.pack()


def func(event):
print('event.char =', event.char)
print('event.keycode =', event.keycode)


label.bind('<BackSpace>', func)

win.mainloop()
  • 这些名字都是合法的 keysym,实测绑 <BackSpace> 能命中:('BackSpace', 8, "''")。
  • 有个反直觉的点:单独绑 <Shift_L> 时,实测合成事件收不到(修饰键会被当成 state 变化而不是按键事件),要靠 <Key> 配 event.state 判断,或者绑具体的组合键。
  • label.focus_set() 写在 label.pack() 之前也能跑(实测没报错),但习惯上先 pack 再 focus,某些平台在控件还没映射时设焦点会不生效。

指定按键事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import tkinter

win = tkinter.Tk()
win.title('指定按键事件')
win.geometry('400x400-200-200')


def func(event):
print('event.char =', event.char)
print('event.keycode =', event.keycode)


win.bind('a', func)

win.mainloop()
  • 事件序列的尖括号可以省,实测 win.bind('a', func) 正常工作并收到了事件。不过推荐写全 <a>,可读性好,也能避免和某些 Tcl 简写冲突。

组合按键事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import tkinter

win = tkinter.Tk()
win.title('组合按键事件')
win.geometry('400x400-200-200')

'''
<Control-Alt-c>
<Shift-Up>
<Control-p>
'''


def func(event):
print('event.char =', event.char)
print('event.keycode =', event.keycode)


win.bind('<Control-Alt-c>', func)

win.mainloop()
  • 写法是修饰键用 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
    2
    win.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
    2
    b.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
    2
    win.after(1000, win.destroy)   # 1 秒后自己关掉
    win.mainloop()
    • 实测这种写法窗口会正常出现并在 1 秒后销毁,mainloop() 随之返回。

参考