第 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.editorQTextEdit()self.setCentralWidget(self.editor)# ② 状态栏没设的话其他部件调用 statusBar() 时会自动创建self.statusBar().showMessage(就绪)# ③ 菜单栏menuBar() 返回菜单栏对象addMenu() 加菜单m_fileself.menuBar().addMenu(文件(F))m_file.addAction(self.act_new)# ④ 工具栏可以加多个第二行工具栏tbQToolBar(主工具栏,self)self.addToolBar(tb)# ⑤ 停靠面板可拖动的浮动面板dockQDockWidget(属性,self)dock.setWidget(QWidget())self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea,dock)菜单文字里的(F)是助记符显示成文件(F)按 AltF 能打开这个菜单。三、QAction这一讲最值得理解的设计新手写菜单的第一反应是每个菜单项建一个 QPushButton——那就走远了。Qt 的做法是 QAction。一个 QAction 描述的是一个动作叫什么名字、什么图标、什么快捷键、鼠标悬停显示什么提示、被触发时执行什么。然后# 创建一次self.act_saveQAction(icon,保存,self)self.act_save.setShortcut(QKeySequence.StandardKey.Save)self.act_save.setStatusTip(保存文件CtrlS)self.act_save.triggered.connect(self.on_save)# 加到菜单显示为菜单项m_file.addAction(self.act_save)# 加到工具栏显示为工具按钮—— 同一个对象tb.addAction(self.act_save)同一个 QAction 加到哪儿就显示在哪儿。所以菜单里的保存和工具栏的保存天然状态同步——禁用其中一个另一个也自动变灰。这是 QAction 设计的核心价值。如果不用 QAction你得自己维护菜单项和工具栏按钮要一起启用/一起禁用的逻辑代码会脏得没法看。标准快捷键别用字符串CtrlS用QKeySequence.StandardKeyQKeySequence.StandardKey.New# CtrlNQKeySequence.StandardKey.Open# CtrlOQKeySequence.StandardKey.Save# CtrlSQKeySequence.StandardKey.Copy# CtrlCQKeySequence.StandardKey.Paste# CtrlVQKeySequence.StandardKey.Undo# CtrlZQKeySequence.StandardKey.Quit# CtrlQ好处是跨平台自动适配Windows/Linux 是 CtrlmacOS 上自动变成 Cmd 键。写死字符串就没有这个效果。状态提示会自动显示给 QAction 设了setStatusTip()之后鼠标滑过菜单项提示文字自动出现在状态栏。这个功能开箱即用不用写一行额外代码。四、把 QAction 的创建封装成方法一个真实程序有十几个 QAction每个都要设图标、快捷键、提示、连接槽重复代码很多。封装一下def_make_action(self,text,std_icon,shortcut,slot,tip)-QAction:创建 QAction 的标准姿势actionQAction(self.style().standardIcon(std_icon),text,self)ifshortcutisnotNone:action.setShortcut(shortcut)action.setStatusTip(tip)action.setToolTip(tip)action.triggered.connect(slot)returnaction# 用起来就是一行self.act_saveself._make_action(保存,QStyle.StandardPixmap.SP_DialogSaveButton,QKeySequence.StandardKey.Save,self.on_save,保存文件CtrlS)图标从哪来用 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_labelQLabel(行 1列 1 )self.statusBar().addPermanentWidget(self.cursor_label)这两个要配合用左边用showMessage显示正在处理…这类临时状态右边用addPermanentWidget显示光标位置、编码、行数这类长期信息。一个实用小功能——光标位置实时显示self.editor.cursorPositionChanged.connect(self._update_cursor_pos)def_update_cursor_pos(self)-None:curself.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:contentPath(path).read_text(encodingutf-8)exceptUnicodeDecodeError:QMessageBox.warning(self,编码错误,该文件不是 UTF-8 编码。)returnself.editor.setPlainText(content)self._pathPath(path)self.setWindowTitle(f记事本 -{self._path.name})defon_save(self)-None:ifself._pathisNone:# 没保存过 → 先问路径path,_QFileDialog.getSaveFileName(self,保存文件,未命名.txt,文本文件 (*.txt))ifnotpath:returnself._pathPath(path)self._path.write_text(self.editor.toPlainText(),encodingutf-8)self.setWindowTitle(f记事本 -{self._path.name})self.statusBar().showMessage(f已保存{self._path},3000)四个必须处理的细节getOpenFileName返回的是元组(path, filter)要用逗号解包别漏了用户点取消时path是空字符串必须if not path: return读文件要处理编码异常UnicodeDecodeError是最常见的报错编码统一用encodingutf-8Python 3 默认编码在不同系统上不一致不写清楚换台机器就乱码七、一个真实软件该有的细节记事本虽然简单但下面是让它像个正经软件的几个细节① 窗口标题跟着文件变self.setWindowTitle(f记事本 -{self._path.name})# 而不是固定的记事本② 关闭前询问未保存的修改defcloseEvent(self,event):# noqa: N802 Qt 的回调命名风格ifself.editor.document().isModified():rQMessageBox.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.editorQTextEdit()self.editor.setPlaceholderText(在这里输入内容……)self.setCentralWidget(self.editor)# ---------- 状态栏 ----------self.setStatusBar(QStatusBar(self))self.statusBar().showMessage(就绪)# 右侧常驻标签showMessage 的消息会被它挤到左边self.cursor_labelself.statusBar().addPermanentWidget(self._make_label(行 1列 1))self.editor.cursorPositionChanged.connect(self._update_cursor_pos)# ---------- 动作QAction一次创建多处复用 ----------self.act_newself._make_action(新建,QStyle.StandardPixmap.SP_FileIcon,QKeySequence.StandardKey.New,self.on_new,新建文件CtrlN)self.act_openself._make_action(打开,QStyle.StandardPixmap.SP_DialogOpenButton,QKeySequence.StandardKey.Open,self.on_open,打开文件CtrlO)self.act_saveself._make_action(保存,QStyle.StandardPixmap.SP_DialogSaveButton,QKeySequence.StandardKey.Save,self.on_save,保存文件CtrlS)self.act_quitself._make_action(退出,QStyle.StandardPixmap.SP_DialogCloseButton,QKeySequence.StandardKey.Quit,self.close,退出程序CtrlQ)self.act_copyself._make_action(复制,QStyle.StandardPixmap.SP_FileDialogDetailedView,QKeySequence.StandardKey.Copy,self.editor.copy,复制CtrlC)self.act_pasteself._make_action(粘贴,QStyle.StandardPixmap.SP_FileDialogListView,QKeySequence.StandardKey.Paste,self.editor.paste,粘贴CtrlV)self.act_undoself._make_action(撤销,QStyle.StandardPixmap.SP_ArrowBack,QKeySequence.StandardKey.Undo,self.editor.undo,撤销CtrlZ)self.act_aboutself._make_action(关于,QStyle.StandardPixmap.SP_MessageBoxInformation,None,self.on_about,关于本程序)# ---------- 菜单栏 ----------m_fileself.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_editself.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_helpself.menuBar().addMenu(帮助(H))m_help.addAction(self.act_about)# ---------- 工具栏直接复用上面同一批 QAction ----------tbQToolBar(主工具栏,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|NoneNone# ---------------- 工具方法 ----------------def_make_label(self,text:str):fromPyQt6.QtWidgetsimportQLabelreturnQLabel(text )def_make_action(self,text,std_icon,shortcut,slot,tip)-QAction:创建 QAction 的标准姿势图标 快捷键 提示 连接槽actionQAction(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:curself.editor.textCursor()self.cursor_label.setText(f行{cur.blockNumber()1}列{cur.columnNumber()1})# ---------------- 槽函数 ----------------defon_new(self)-None:self.editor.clear()self._pathNoneself.setWindowTitle(记事本 - 未命名)self.statusBar().showMessage(已新建,2000)defon_open(self)-None:path,_QFileDialog.getOpenFileName(self,打开文件,,文本文件 (*.txt);;所有文件 (*))ifnotpath:return# 用户点了取消直接返回try:contentPath(path).read_text(encodingutf-8)exceptUnicodeDecodeError:QMessageBox.warning(self,编码错误,该文件不是 UTF-8 编码请另存为 UTF-8 后重试。)returnself.editor.setPlainText(content)self._pathPath(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._pathPath(path)self._path.write_text(self.editor.toPlainText(),encodingutf-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,shotc06_notepad,width760,height520))九、小结QMainWindow 有五个插槽中央控件、菜单栏、工具栏、状态栏、停靠面板QAction 是核心设计一个动作对象同时用在菜单、工具栏、快捷键、右键菜单里快捷键用QKeySequence.StandardKey跨平台自动适配文件操作四个必检项解包元组、处理取消、处理编码异常、明确指定 utf-8现在你的程序有完整的骨架了。但仔细看它还有很多此处应有内容的地方——点关于应该弹个提示框、点新建要确认一下、选错了要给个错误提示。下一讲对话框全家桶。消息框、输入框、文件选择框、自定义对话框一次讲完并说清模态和非模态的区别以及那个让新手迷惑的exec()返回值。本讲是《PyQt6 客户端编程从入门到实战》专栏的第 6 讲。专栏共 22 讲、22 个可运行案例。