第 6 讲 | 主窗口框架:菜单栏、工具栏、状态栏
本讲目标:掌握 QMainWindow 的五大件,理解 QAction 这个设计的精妙之处,做出一个完整可用的记事本。
配套代码:
cases/c06_mainwindow_notepad.py
一、QWidget 和 QMainWindow 的区别
前面所有案例都继承QWidget,它就是个空白容器。但真实软件需要的是这样一套骨架:
┌─────────────────────────────────────────────┐ │ 文件(F) 编辑(E) 帮助(H) │ ← 菜单栏 菜单 ├─────────────────────────────────────────────┤ │ [新建][打开][保存] │ [撤销][复制][粘贴] │ ← 工具栏 ├──────────────┬──────────────────────────────┤ │ │ │ │ 停靠面板 │ 中央控件 │ ← 中央控件 + 停靠面板 │ (可拖走) │ (QTextEdit / 表格) │ │ │ │ ├──────────────┴──────────────────────────────┤ │ 就绪 行 1,列 1 │ ← 状态栏 └─────────────────────────────────────────────┘QMainWindow 就是为这个结构准备的:它内置了菜单栏、工具栏、状态栏、停靠区的"插槽",
你只要往里填东西,位置由 Qt 负责。
一句话选择标准:做工具类软件(有菜单、有多面板)用 QMainWindow;
做单一功能的对话框(就一个表单)用 QWidget 或 QDialog。
二、五个插槽的用法
classMyWindow(QMainWindow):def__init__(self):super().__init__()# ① 中央控件:一个窗口只能有一个,必须设self.editor=QTextEdit()self.setCentralWidget(self.editor)# ② 状态栏:没设的话,其他部件调用 statusBar() 时会自动创建self.statusBar().showMessage("就绪")# ③ 菜单栏:menuBar() 返回菜单栏对象,addMenu() 加菜单m_file=self.menuBar().addMenu("文件(&F)")m_file.addAction(self.act_new)# ④ 工具栏:可以加多个(第二行工具栏)tb=QToolBar("主工具栏",self)self.addToolBar(tb)# ⑤ 停靠面板:可拖动的浮动面板dock=QDockWidget("属性",self)dock.setWidget(QWidget())self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea,dock)菜单文字里的(&F)是助记符:显示成文件(F),按 Alt+F 能打开这个菜单。
三、QAction:这一讲最值得理解的设计
新手写菜单的第一反应是"每个菜单项建一个 QPushButton"——那就走远了。
Qt 的做法是 QAction。一个 QAction 描述的是一个动作:叫什么名字、什么图标、
什么快捷键、鼠标悬停显示什么提示、被触发时执行什么。然后:
# 创建一次self.act_save=QAction(icon,"保存",self)self.act_save.setShortcut(QKeySequence.StandardKey.Save)self.act_save.setStatusTip("保存文件(Ctrl+S)")self.act_save.triggered.connect(self.on_save)# 加到菜单(显示为菜单项)m_file.addAction(self.act_save)# 加到工具栏(显示为工具按钮)—— 同一个对象!tb.addAction(self.act_save)同一个 QAction 加到哪儿,就显示在哪儿。所以"菜单里的保存"和"工具栏的保存"
天然状态同步——禁用其中一个,另一个也自动变灰。这是 QAction 设计的核心价值。
如果不用 QAction,你得自己维护"菜单项和工具栏按钮要一起启用/一起禁用"的逻辑,
代码会脏得没法看。
标准快捷键
别用字符串"Ctrl+S",用QKeySequence.StandardKey:
QKeySequence.StandardKey.New# Ctrl+NQKeySequence.StandardKey.Open# Ctrl+OQKeySequence.StandardKey.Save# Ctrl+SQKeySequence.StandardKey.Copy# Ctrl+CQKeySequence.StandardKey.Paste# Ctrl+VQKeySequence.StandardKey.Undo# Ctrl+ZQKeySequence.StandardKey.Quit# Ctrl+Q好处是跨平台自动适配:Windows/Linux 是 Ctrl,macOS 上自动变成 Cmd 键。
写死字符串就没有这个效果。
状态提示会自动显示
给 QAction 设了setStatusTip()之后,鼠标滑过菜单项,提示文字自动出现在状态栏。
这个功能开箱即用,不用写一行额外代码。
四、把 QAction 的创建封装成方法
一个真实程序有十几个 QAction,每个都要设图标、快捷键、提示、连接槽,重复代码很多。
封装一下:
def_make_action(self,text,std_icon,shortcut,slot,tip)->QAction:"""创建 QAction 的标准姿势"""action=QAction(self.style().standardIcon(std_icon),text,self)ifshortcutisnotNone:action.setShortcut(shortcut)action.setStatusTip(tip)action.setToolTip(tip)action.triggered.connect(slot)returnaction# 用起来就是一行self.act_save=self._make_action("保存",QStyle.StandardPixmap.SP_DialogSaveButton,QKeySequence.StandardKey.Save,self.on_save,"保存文件(Ctrl+S)")图标从哪来?用 Qt 自带的QStyle.StandardPixmap标准图标,不需要任何图片资源:
QStyle.StandardPixmap.SP_FileIcon# 文件QStyle.StandardPixmap.SP_DialogOpenButton# 打开QStyle.StandardPixmap.SP_DialogSaveButton# 保存QStyle.StandardPixmap.SP_ArrowBack# 返回/撤销QStyle.StandardPixmap.SP_MessageBoxInformation# 信息自己项目有图标资源时,用QIcon("assets/save.png")。
五、状态栏的两个常用方式
# ① 临时消息:显示后会自动被下一条覆盖,也可以设超时自动清除self.statusBar().showMessage("已保存",3000)# 3 秒后自动消失# ② 常驻控件:放右边,不会被 showMessage 挤掉self.cursor_label=QLabel("行 1,列 1 ")self.statusBar().addPermanentWidget(self.cursor_label)这两个要配合用:左边用showMessage显示"正在处理…"这类临时状态,
右边用addPermanentWidget显示光标位置、编码、行数这类长期信息。
一个实用小功能——光标位置实时显示:
self.editor.cursorPositionChanged.connect(self._update_cursor_pos)def_update_cursor_pos(self)->None:cur=self.editor.textCursor()self.cursor_label.setText(f"行{cur.blockNumber()+1},列{cur.columnNumber()+1}")blockNumber()是从 0 开始的,所以要 +1。这类"差一"的细节不写出来,
读者自己试的时候会以为是 bug。
六、文件读写:三行搞定,但有坑
defon_open(self)->None:path,_=QFileDialog.getOpenFileName(self,"打开文件","","文本文件 (*.txt);;所有文件 (*)")ifnotpath:return# ★ 用户点了取消,必须检查try:content=Path(path).read_text(encoding="utf-8")exceptUnicodeDecodeError:QMessageBox.warning(self,"编码错误","该文件不是 UTF-8 编码。")returnself.editor.setPlainText(content)self._path=Path(path)self.setWindowTitle(f"记事本 -{self._path.name}")defon_save(self)->None:ifself._pathisNone:# 没保存过 → 先问路径path,_=QFileDialog.getSaveFileName(self,"保存文件","未命名.txt","文本文件 (*.txt)")ifnotpath:returnself._path=Path(path)self._path.write_text(self.editor.toPlainText(),encoding="utf-8")self.setWindowTitle(f"记事本 -{self._path.name}")self.statusBar().showMessage(f"已保存{self._path}",3000)四个必须处理的细节:
getOpenFileName返回的是元组(path, filter),要用逗号解包,别漏了- 用户点取消时
path是空字符串,必须if not path: return - 读文件要处理编码异常,
UnicodeDecodeError是最常见的报错 - 编码统一用
encoding="utf-8",Python 3 默认编码在不同系统上不一致,
不写清楚换台机器就乱码
七、一个真实软件该有的细节
记事本虽然简单,但下面是让它"像个正经软件"的几个细节:
① 窗口标题跟着文件变
self.setWindowTitle(f"记事本 -{self._path.name}")# 而不是固定的"记事本"② 关闭前询问未保存的修改
defcloseEvent(self,event):# noqa: N802 Qt 的回调命名风格ifself.editor.document().isModified():r=QMessageBox.question(self,"提示","内容还没保存,确定要退出吗?")ifr!=QMessageBox.StandardButton.Yes:event.ignore()# ★ 忽略关闭事件,窗口不关returnevent.accept()event.ignore()是这一节的关键:Qt 的关闭事件可以被"撤回"。
不调用event.ignore()而直接 return,窗口照样会关掉。
③ 保存后清除"已修改"标记
self.editor.document().setModified(False)八、完整代码
# -*- coding: utf-8 -*-"""第 6 讲 案例 06:主窗口框架 —— 记事本骨架 知识点(QMainWindow 的五大件): - menuBar() 菜单栏 - QToolBar 工具栏 - statusBar() 状态栏 - QAction 动作:菜单项、工具栏按钮、快捷键共用同一个对象 - QDockWidget 可停靠面板(本案例不用,第 12 讲数据库案例会用到) 顺带讲清一个高频疑问:为什么"复制/粘贴"在菜单和工具栏里能同时生效? —— 因为它们是同一个 QAction,把 action 加到哪儿就显示在哪儿。 """importsysfrompathlibimportPath sys.path.insert(0,str(Path(__file__).resolve().parent.parent))fromPyQt6.QtCoreimportQSize,Qt# noqa: E402fromPyQt6.QtGuiimportQAction,QKeySequence# noqa: E402fromPyQt6.QtWidgetsimport(QFileDialog,QMainWindow,QMessageBox,# noqa: E402QStatusBar,QStyle,QTextEdit,QToolBar)fromutils.envimportrun# noqa: E402classNotepadWindow(QMainWindow):"""记事本骨架:菜单栏 + 工具栏 + 状态栏 + 中央编辑区"""def__init__(self)->None:super().__init__()self.setWindowTitle("记事本 - 未命名")self.resize(760,520)# ---------- 中央控件 ----------self.editor=QTextEdit()self.editor.setPlaceholderText("在这里输入内容……")self.setCentralWidget(self.editor)# ---------- 状态栏 ----------self.setStatusBar(QStatusBar(self))self.statusBar().showMessage("就绪")# 右侧常驻标签(showMessage 的消息会被它挤到左边)self.cursor_label=self.statusBar().addPermanentWidget(self._make_label("行 1,列 1"))self.editor.cursorPositionChanged.connect(self._update_cursor_pos)# ---------- 动作(QAction):一次创建,多处复用 ----------self.act_new=self._make_action("新建",QStyle.StandardPixmap.SP_FileIcon,QKeySequence.StandardKey.New,self.on_new,"新建文件(Ctrl+N)")self.act_open=self._make_action("打开",QStyle.StandardPixmap.SP_DialogOpenButton,QKeySequence.StandardKey.Open,self.on_open,"打开文件(Ctrl+O)")self.act_save=self._make_action("保存",QStyle.StandardPixmap.SP_DialogSaveButton,QKeySequence.StandardKey.Save,self.on_save,"保存文件(Ctrl+S)")self.act_quit=self._make_action("退出",QStyle.StandardPixmap.SP_DialogCloseButton,QKeySequence.StandardKey.Quit,self.close,"退出程序(Ctrl+Q)")self.act_copy=self._make_action("复制",QStyle.StandardPixmap.SP_FileDialogDetailedView,QKeySequence.StandardKey.Copy,self.editor.copy,"复制(Ctrl+C)")self.act_paste=self._make_action("粘贴",QStyle.StandardPixmap.SP_FileDialogListView,QKeySequence.StandardKey.Paste,self.editor.paste,"粘贴(Ctrl+V)")self.act_undo=self._make_action("撤销",QStyle.StandardPixmap.SP_ArrowBack,QKeySequence.StandardKey.Undo,self.editor.undo,"撤销(Ctrl+Z)")self.act_about=self._make_action("关于",QStyle.StandardPixmap.SP_MessageBoxInformation,None,self.on_about,"关于本程序")# ---------- 菜单栏 ----------m_file=self.menuBar().addMenu("文件(&F)")m_file.addAction(self.act_new)m_file.addAction(self.act_open)m_file.addAction(self.act_save)m_file.addSeparator()# 分隔线m_file.addAction(self.act_quit)m_edit=self.menuBar().addMenu("编辑(&E)")m_edit.addAction(self.act_undo)m_edit.addSeparator()m_edit.addAction(self.act_copy)m_edit.addAction(self.act_paste)m_help=self.menuBar().addMenu("帮助(&H)")m_help.addAction(self.act_about)# ---------- 工具栏:直接复用上面同一批 QAction ----------tb=QToolBar("主工具栏",self)tb.setIconSize(QSize(18,18))tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)self.addToolBar(tb)tb.addAction(self.act_new)tb.addAction(self.act_open)tb.addAction(self.act_save)tb.addSeparator()tb.addAction(self.act_undo)tb.addAction(self.act_copy)tb.addAction(self.act_paste)self._path:Path|None=None# ---------------- 工具方法 ----------------def_make_label(self,text:str):fromPyQt6.QtWidgetsimportQLabelreturnQLabel(text+" ")def_make_action(self,text,std_icon,shortcut,slot,tip)->QAction:"""创建 QAction 的标准姿势:图标 + 快捷键 + 提示 + 连接槽"""action=QAction(self.style().standardIcon(std_icon),text,self)ifshortcutisnotNone:action.setShortcut(shortcut)action.setStatusTip(tip)action.setToolTip(tip)action.triggered.connect(slot)returnactiondef_update_cursor_pos(self)->None:cur=self.editor.textCursor()self.cursor_label.setText(f"行{cur.blockNumber()+1},列{cur.columnNumber()+1}")# ---------------- 槽函数 ----------------defon_new(self)->None:self.editor.clear()self._path=Noneself.setWindowTitle("记事本 - 未命名")self.statusBar().showMessage("已新建",2000)defon_open(self)->None:path,_=QFileDialog.getOpenFileName(self,"打开文件","","文本文件 (*.txt);;所有文件 (*)")ifnotpath:return# 用户点了取消,直接返回try:content=Path(path).read_text(encoding="utf-8")exceptUnicodeDecodeError:QMessageBox.warning(self,"编码错误","该文件不是 UTF-8 编码,请另存为 UTF-8 后重试。")returnself.editor.setPlainText(content)self._path=Path(path)self.setWindowTitle(f"记事本 -{self._path.name}")self.statusBar().showMessage(f"已打开{path}",3000)defon_save(self)->None:ifself._pathisNone:path,_=QFileDialog.getSaveFileName(self,"保存文件","未命名.txt","文本文件 (*.txt)")ifnotpath:returnself._path=Path(path)self._path.write_text(self.editor.toPlainText(),encoding="utf-8")self.setWindowTitle(f"记事本 -{self._path.name}")self.statusBar().showMessage(f"已保存{self._path}",3000)defon_about(self)->None:QMessageBox.about(self,"关于","记事本演示程序 v1.0\n\nPyQt6 主窗口框架示例。")if__name__=="__main__":sys.exit(run(NotepadWindow,shot="c06_notepad",width=760,height=520))九、小结
- QMainWindow 有五个插槽:中央控件、菜单栏、工具栏、状态栏、停靠面板
- QAction 是核心设计:一个动作对象,同时用在菜单、工具栏、快捷键、右键菜单里
- 快捷键用
QKeySequence.StandardKey,跨平台自动适配 - 文件操作四个必检项:解包元组、处理取消、处理编码异常、明确指定 utf-8
现在你的程序有完整的骨架了。但仔细看,它还有很多"此处应有内容"的地方——
点"关于"应该弹个提示框、点"新建"要确认一下、选错了要给个错误提示。
下一讲:对话框全家桶。消息框、输入框、文件选择框、自定义对话框,
一次讲完,并说清"模态"和"非模态"的区别,以及那个让新手迷惑的exec()返回值。
本讲是《PyQt6 客户端编程从入门到实战》专栏的第 6 讲。专栏共 22 讲、22 个可运行案例。