📚 目录 第 17 章 菜单:Menu 菜单栏 / 右键菜单 / Menubutton

第 17 章 菜单:Menu 菜单栏 / 右键菜单 / Menubutton

本书红线(贯穿全教程,回顾):
1. 每个界面都用 class App: 封装,__init__ 里建控件,方法即事件回调。
2. 所有控件示例一律写在类里,不用脱离类的扁平脚本。
3. 代码块带 if __name__ == "__main__": 守卫,可独立运行。
4. 星级:★ 了解 · ★★ 理解 · ★★★ 掌握 · ★★★★ 重点必会。

主线项目:图形界面通讯录管理器,随章节每章新增一个控件慢慢长大。

上节回顾(第 16 章):你学会了用系统原生对话框做提示、确认、选文件、取颜色,并掌握了每个函数的签名与返回值,还做到了"确认才真删、把文件读进 Text"。本章给通讯录装上菜单——专业的"文件/编辑"菜单栏、列表右键菜单、以及 Menubutton 下拉按钮。

本章目标:掌握 tkinter 的 tk.Menu(菜单栏 + 右键菜单)和 tk.Menubutton(下拉按钮)。重点做出"专业软件感"的菜单,并把菜单项挂到我们前面写好的方法上(打开/保存/退出/删除)。OOP 落点:菜单项 command= 指向 App 的方法,和按钮回调是同一套范式。


17.1 是什么 / 适用场景 ★

是什么:Menu 是 tkinter 的菜单控件。它能以两种形态出现:

Menubutton 则是"按钮里嵌一个下拉菜单",常放在工具栏。

适用场景:

📌 重要约束:Menu 和 Menubutton 没有 ttk 版本,一律用 tk.Menu / tk.Menubutton。这是 tkinter 的历史设计,无需纠结。

17.2 构造选项与常用方法(对照官方手册)★★

17.2.1 创建菜单对象

选项作用常用取值
tearoff是否允许"撕下菜单"(顶部虚线)0(不要,推荐)/ 1(默认,会多一条)
parent依附的窗口self.root

17.2.2 Menu 常用方法(往菜单里加东西)

方法作用示例
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(...)加勾选项 / 单选项进阶用

17.2.3 把菜单栏挂到窗口

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.3 详细例子(循序渐进)★★★

本书红线:示例一律写在类里。下面每个例子都完整可运行,并演示"菜单项挂方法"。

例 17.1 菜单栏:做"文件 / 帮助"菜单 ★★★

最典型的窗口菜单栏。点"文件 → 退出"关闭窗口;"帮助 → 关于"弹提示。

# 第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()

逐行说明:

例 17.2 右键弹出菜单(结合第 15 章 bind)★★★

在 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()

逐行说明:

例 17.3 Menubutton 下拉按钮 ★★

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()

逐行说明:

例 17.4 综合实战:菜单栏(打开/保存/退出)+ 列表右键删除 ★★★★

把第 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()

逐行说明(贯通前几章):


17.4 OOP 落点 ★★

一句话:"菜单只是回调的组织方式;菜单项背后,全是 App 的方法。"

17.5 注意事项 / 易错清单 ★★


17.6 本章练习

  1. 写一个程序:做"文件"菜单栏,含"新建"(弹 showinfo)、"退出"(确认后 destroy)。验证菜单栏能正常显示。
  2. 在练习 1 基础上,给窗口加一个 Listbox,并绑定右键菜单"删除"(删除前用 askyesno 确认)。
  3. 用 Menubutton 做一个"字体大小 ▾"下拉,选项"小/中/大",选中后用 config 改变一个 Label 的字号(提示:self.label.config(font=("微软雅黑", size)))。
  4. 思考题:例 17.4 中如果把 self.root.config(menu=menubar) 这行删掉,运行时窗口会怎样?若把右键菜单的 post(event.x_root, event.y_root) 改成 post(event.x, event.y),点击右键后菜单会出现在哪里、有什么问题?(提示:前者菜单栏消失;后者菜单以"相对控件左上角"的坐标当作屏幕坐标去弹,会跑到窗口左上角甚至屏幕外,用户看不到。)
下节预告:菜单让你的程序"像专业软件"。下一阶段我们将学习布局进阶 / 多窗口 / 数据持久化等,让通讯录真正可用、可保存。