本书红线(贯穿全教程,回顾):
1. 每个界面都用class App:封装,__init__里建控件,方法即事件回调。
2. 所有控件示例一律写在类里,不用脱离类的扁平脚本。
3. 代码块带if __name__ == "__main__":守卫,可独立运行。
4. 星级:★ 了解 · ★★ 理解 · ★★★ 掌握 · ★★★★ 重点必会。
主线项目:图形界面通讯录管理器,随章节每章新增一个控件慢慢长大。
上节回顾(第 16 章):你学会了用系统原生对话框做提示、确认、选文件、取颜色,并掌握了每个函数的签名与返回值,还做到了"确认才真删、把文件读进 Text"。本章给通讯录装上菜单——专业的"文件/编辑"菜单栏、列表右键菜单、以及 Menubutton 下拉按钮。
本章目标:掌握 tkinter 的 tk.Menu(菜单栏 + 右键菜单)和 tk.Menubutton(下拉按钮)。重点做出"专业软件感"的菜单,并把菜单项挂到我们前面写好的方法上(打开/保存/退出/删除)。OOP 落点:菜单项 command= 指向 App 的方法,和按钮回调是同一套范式。
是什么:Menu 是 tkinter 的菜单控件。它能以两种形态出现:
Menubutton 则是"按钮里嵌一个下拉菜单",常放在工具栏。
适用场景:
📌 重要约束:Menu和Menubutton没有ttk版本,一律用tk.Menu/tk.Menubutton。这是 tkinter 的历史设计,无需纠结。
| 选项 | 作用 | 常用取值 |
|---|---|---|
tearoff | 是否允许"撕下菜单"(顶部虚线) | 0(不要,推荐)/ 1(默认,会多一条) |
parent | 依附的窗口 | self.root |
| 方法 | 作用 | 示例 |
|---|---|---|
add_command(label=, command=) | 加一个可点击项 | fmenu.add_command(label="保存", command=self.save) |
add_cascade(label=, menu=) | 加一个"级联"(子菜单入口) | menubar.add_cascade(label="文件", menu=fmenu) |
add_separator() | 加一条分隔线 | fmenu.add_separator() |
add_checkbutton(...) / add_radiobutton(...) | 加勾选项 / 单选项 | 进阶用 |
menubar = tk.Menu(self.root) # ① 建菜单栏
self.root.config(menu=menubar) # ② 关键:告诉窗口"用这个做菜单栏"
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="文件", menu=file_menu) # ③ 级联挂上去
file_menu.add_command(label="保存", command=self.save)⚠️ 漏掉 self.root.config(menu=menubar) 这一步,菜单栏根本不会显示,这是最常见的"啥也没出现"原因。
本书红线:示例一律写在类里。下面每个例子都完整可运行,并演示"菜单项挂方法"。
最典型的窗口菜单栏。点"文件 → 退出"关闭窗口;"帮助 → 关于"弹提示。
# 第17章 例17.1:窗口顶部菜单栏(文件/帮助)
import tkinter as tk
from tkinter import messagebox
class App:
def __init__(self, root):
self.root = root
self.root.title("通讯录管理器")
self.root.geometry("400x300")
# ① 创建菜单栏对象
menubar = tk.Menu(self.root)
# ② 关键:把菜单栏挂到窗口上,否则不显示
self.root.config(menu=menubar)
# ③ 文件子菜单
file_menu = tk.Menu(menubar, tearoff=0) # tearoff=0 去掉顶部虚线
file_menu.add_command(label="打开", command=self.open_file)
file_menu.add_command(label="保存", command=self.save)
file_menu.add_separator() # 分隔线
file_menu.add_command(label="退出", command=self.root.destroy)
# ④ 把"文件"子菜单级联到菜单栏
menubar.add_cascade(label="文件", menu=file_menu)
# ⑤ 帮助子菜单
help_menu = tk.Menu(menubar, tearoff=0)
help_menu.add_command(label="关于", command=self.about)
menubar.add_cascade(label="帮助", menu=help_menu)
tk.Label(self.root, text="(这里是通讯录主界面)", font=("微软雅黑", 12)).pack(pady=40)
def open_file(self):
messagebox.showinfo("打开", "执行打开逻辑(详见第16章)。")
def save(self):
messagebox.showinfo("保存", "已保存。")
def about(self):
messagebox.showinfo("关于", "通讯录管理器 v1.0")
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()逐行说明:
menubar = tk.Menu(self.root):菜单栏本身也是一个 Menu,它不直接放命令,而是放"级联入口"。self.root.config(menu=menubar):第②步是灵魂——窗口只有被 config(menu=...) 指定,菜单栏才会出现在顶部。file_menu = tk.Menu(menubar, tearoff=0):真正的子菜单。先建一个 Menu 往里加命令,再用 add_cascade 把它挂成"文件"下拉。file_menu.add_command(label="退出", command=self.root.destroy):command 这里直接用了 self.root.destroy(窗口的销毁方法),点退出就关窗——回调不限于自己写的方法,任何可调用对象都行。在 Listbox 上点右键,弹出"编辑 / 删除"。复用第 15 章的 <Button-3> + event.x_root/y_root。
# 第17章 例17.2:列表项右键弹出"编辑/删除"菜单
import tkinter as tk
from tkinter import messagebox
class App:
def __init__(self, root):
self.root = root
self.root.title("通讯录管理器 - 右键菜单")
self.listbox = tk.Listbox(self.root, height=8)
self.listbox.pack(padx=10, pady=10)
for n in ("张三", "李四", "王五"):
self.listbox.insert(tk.END, n)
# ① 建一个"弹出用"菜单(tearoff=0)
self.popup = tk.Menu(self.root, tearoff=0)
self.popup.add_command(label="编辑", command=self.edit)
self.popup.add_command(label="删除", command=self.delete)
# ② 右键绑定:用 lambda 吃掉 event 参数(这里其实 post 需要坐标,见下方)
self.listbox.bind("<Button-3>", self.show_popup)
def show_popup(self, event):
# event.x_root/.y_root 是屏幕坐标,post 必须用屏幕坐标
self.popup.post(event.x_root, event.y_root)
def edit(self):
sel = self.listbox.curselection()
if sel:
messagebox.showinfo("编辑", f"编辑 {self.listbox.get(sel[0])}")
def delete(self):
sel = self.listbox.curselection()
if sel and messagebox.askyesno("确认", "确定删除?"):
self.listbox.delete(sel[0])
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()逐行说明:
self.popup = tk.Menu(self.root, tearoff=0):弹出菜单平时"隐藏",只在 post 时显示,所以无需挂到窗口菜单栏。self.listbox.bind("<Button-3>", self.show_popup):右键触发 show_popup,和第 15 章完全一致。self.popup.post(event.x_root, event.y_root):必须用屏幕坐标 x_root/y_root(不是 x/y),否则菜单弹错位置。点"删除"会先 askyesno 确认(复用第 16 章对话框)。Menubutton 是"嵌了菜单的按钮",放在界面上做下拉选择很合适,比如"排序方式 ▾"。
# 第17章 例17.3:用 Menubutton 做"排序方式"下拉
import tkinter as tk
class App:
def __init__(self, root):
self.root = root
self.root.title("通讯录管理器 - Menubutton")
# ① 创建 Menubutton(注意是 tk.Menubutton,无 ttk 版)
self.mb = tk.Menubutton(self.root, text="排序方式 ▾", relief="raised")
self.mb.pack(padx=10, pady=10)
# ② 给它挂一个菜单
self.mb_menu = tk.Menu(self.mb, tearoff=0)
self.mb.config(menu=self.mb_menu) # 关键:把菜单绑到 Menubutton
self.mb_menu.add_command(label="按姓名", command=lambda: self.sort_by("姓名"))
self.mb_menu.add_command(label="按时间", command=lambda: self.sort_by("时间"))
self.label = tk.Label(self.root, text="当前排序:未选择", font=("微软雅黑", 12))
self.label.pack(padx=10, pady=10)
def sort_by(self, key):
self.label.config(text=f"当前排序:{key}")
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()逐行说明:
self.mb = tk.Menubutton(self.root, text="排序方式 ▾", relief="raised"):外观是一个带下拉箭头的按钮。self.mb.config(menu=self.mb_menu):Menubutton 用 config(menu=...) 把下拉菜单挂上去(和菜单栏 root.config(menu=...) 写法同源,但挂在按钮自己身上)。command=lambda: self.sort_by("姓名"):用 lambda 固定参数(第 15 章技巧),点击后把"姓名/时间"传给方法。把第 15、16 章串起来——菜单栏"文件"挂打开/保存/退出(打开用 filedialog 读进 Text,退出确认),列表右键"删除"带确认。完整可运行。
# 第17章 例17.4:通讯录完整菜单——文件菜单 + 列表右键删除
import tkinter as tk
from tkinter import messagebox, filedialog
class App:
def __init__(self, root):
self.root = root
self.root.title("通讯录管理器")
self.root.geometry("420x320")
# ===== 菜单栏 =====
menubar = tk.Menu(self.root)
self.root.config(menu=menubar) # 挂菜单栏到窗口
file_menu = tk.Menu(menubar, tearoff=0)
file_menu.add_command(label="打开", command=self.open_file)
file_menu.add_command(label="保存", command=self.save)
file_menu.add_separator()
file_menu.add_command(label="退出", command=self.quit_app)
menubar.add_cascade(label="文件", menu=file_menu)
# ===== 主体:列表 + 详情 Text =====
self.listbox = tk.Listbox(self.root, height=10, width=22)
self.listbox.pack(side=tk.LEFT, padx=10, pady=10)
for n in ("张三", "李四", "王五"):
self.listbox.insert(tk.END, n)
self.text = tk.Text(self.root, width=30, height=12)
self.text.pack(side=tk.LEFT, padx=10, pady=10)
# ===== 右键菜单 =====
self.popup = tk.Menu(self.root, tearoff=0)
self.popup.add_command(label="删除", command=self.delete_selected)
self.listbox.bind("<Button-3>", self.show_popup) # 右键弹菜单
# ---- 文件菜单回调 ----
def open_file(self):
path = filedialog.askopenfilename(
filetypes=[("文本文件", "*.txt")], parent=self.root)
if path == "": # 取消返回 ""
return
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.text.delete("1.0", tk.END)
self.text.insert(tk.END, content)
def save(self):
messagebox.showinfo("保存", "已保存通讯录。")
def quit_app(self):
if messagebox.askyesno("退出", "确定退出程序?"):
self.root.destroy() # 确认才关窗
# ---- 右键菜单回调 ----
def show_popup(self, event):
self.popup.post(event.x_root, event.y_root) # 必须用屏幕坐标
def delete_selected(self):
sel = self.listbox.curselection()
if not sel:
return
name = self.listbox.get(sel[0])
if messagebox.askyesno("确认删除", f"确定删除 {name} 吗?"):
self.listbox.delete(sel[0])
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()逐行说明(贯通前几章):
tk.Menu 建栏 → root.config(menu=...) 挂载 → add_cascade 挂子菜单 → add_command 挂项。每个项的 command 指向 App 方法。filedialog.askopenfilename + 读进 Text;"退出"用 askyesno 确认后才 self.root.destroy()——菜单项就是普通回调,和按钮完全同一套范式。bind("<Button-3>") + 第 16 章 askyesno 确认,删前必确认。add_command(label="保存", command=self.save) 和 Button(command=self.save) 本质是同一件事——把"用户操作"登记到类的方法上。菜单只是把这批回调组织成层次结构。self:菜单栏、弹出菜单、Menubutton 都建在 __init__ 里并挂到 self(如 self.popup、self.mb),方法通过 self 操作界面。__init__ 多 add_command 一行,无需改其它代码。一句话:"菜单只是回调的组织方式;菜单项背后,全是 App 的方法。"
root.config(menu=menubar):菜单栏建好了却完全不显示。记住——Menu 建完必须挂到窗口才出现。post() 必须用 event.x_root, event.y_root(屏幕坐标),用 event.x/.y 会弹到错误位置甚至看不见(第 15 章已强调)。tearoff=0:菜单顶部多出一条可"撕下"的虚线,丑且不必要。子菜单一律 tearoff=0。ttk.Menu / ttk.Menubutton:这两个控件没有 ttk 版本,写 ttk.Menu 会报 AttributeError。请一律用 tk.Menu / tk.Menubutton。config(menu=...):只建了 Menubutton 和 Menu 却没 self.mb.config(menu=self.mb_menu),点下去没反应。delete_selected 先用 curselection() 判空,否则对空选择 get 会报错。养成"菜单命令内部先校验状态"的习惯。show_popup 里记录当前目标。。showinfo)、"退出"(确认后 destroy)。验证菜单栏能正常显示。askyesno 确认)。Menubutton 做一个"字体大小 ▾"下拉,选项"小/中/大",选中后用 config 改变一个 Label 的字号(提示:self.label.config(font=("微软雅黑", size)))。self.root.config(menu=menubar) 这行删掉,运行时窗口会怎样?若把右键菜单的 post(event.x_root, event.y_root) 改成 post(event.x, event.y),点击右键后菜单会出现在哪里、有什么问题?(提示:前者菜单栏消失;后者菜单以"相对控件左上角"的坐标当作屏幕坐标去弹,会跑到窗口左上角甚至屏幕外,用户看不到。)下节预告:菜单让你的程序"像专业软件"。下一阶段我们将学习布局进阶 / 多窗口 / 数据持久化等,让通讯录真正可用、可保存。