第 6 讲 | 主窗口框架:菜单栏、工具栏、状态栏
本讲目标:掌握 QMainWindow 的五大件,理解 QAction 这个设计的精妙之处,做出一个完整可用的记事本。
配套代码 :
cases/c06_mainwindow_notepad.py
一、QWidget 和 QMainWindow 的区别
前面所有案例都继承 QWidget,它就是个空白容器。但真实软件需要的是这样一套骨架:
┌─────────────────────────────────────────────┐
│ 文件(F) 编辑(E) 帮助(H) │ ← 菜单栏 菜单
├─────────────────────────────────────────────┤
│ [新建][打开][保存] │ [撤销][复制][粘贴] │ ← 工具栏
├──────────────┬──────────────────────────────┤
│ │ │
│ 停靠面板 │ 中央控件 │ ← 中央控件 + 停靠面板
│ (可拖走) │ (QTextEdit / 表格) │
│ │ │
├──────────────┴──────────────────────────────┤
│ 就绪 行 1,列 1 │ ← 状态栏
└─────────────────────────────────────────────┘
QMainWindow 就是为这个结构准备的 :它内置了菜单栏、工具栏、状态栏、停靠区的"插槽",
你只要往里填东西,位置由 Qt 负责。
一句话选择标准:做工具类软件(有菜单、有多面板)用 QMainWindow;
做单一功能的对话框(就一个表单)用 QWidget 或 QDialog。
二、五个插槽的用法
python
class MyWindow(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 描述的是一个动作 :叫什么名字、什么图标、
什么快捷键、鼠标悬停显示什么提示、被触发时执行什么。然后:
python
# 创建一次
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:
python
QKeySequence.StandardKey.New # Ctrl+N
QKeySequence.StandardKey.Open # Ctrl+O
QKeySequence.StandardKey.Save # Ctrl+S
QKeySequence.StandardKey.Copy # Ctrl+C
QKeySequence.StandardKey.Paste # Ctrl+V
QKeySequence.StandardKey.Undo # Ctrl+Z
QKeySequence.StandardKey.Quit # Ctrl+Q
好处是跨平台自动适配 :Windows/Linux 是 Ctrl,macOS 上自动变成 Cmd 键。
写死字符串就没有这个效果。
状态提示会自动显示
给 QAction 设了 setStatusTip() 之后,鼠标滑过菜单项,提示文字自动出现在状态栏 。
这个功能开箱即用,不用写一行额外代码。
四、把 QAction 的创建封装成方法
一个真实程序有十几个 QAction,每个都要设图标、快捷键、提示、连接槽,重复代码很多。
封装一下:
python
def _make_action(self, text, std_icon, shortcut, slot, tip) -> QAction:
"""创建 QAction 的标准姿势"""
action = QAction(self.style().standardIcon(std_icon), text, self)
if shortcut is not None:
action.setShortcut(shortcut)
action.setStatusTip(tip)
action.setToolTip(tip)
action.triggered.connect(slot)
return action
# 用起来就是一行
self.act_save = self._make_action(
"保存", QStyle.StandardPixmap.SP_DialogSaveButton,
QKeySequence.StandardKey.Save, self.on_save, "保存文件(Ctrl+S)")
图标从哪来? 用 Qt 自带的 QStyle.StandardPixmap 标准图标,不需要任何图片资源:
python
QStyle.StandardPixmap.SP_FileIcon # 文件
QStyle.StandardPixmap.SP_DialogOpenButton # 打开
QStyle.StandardPixmap.SP_DialogSaveButton # 保存
QStyle.StandardPixmap.SP_ArrowBack # 返回/撤销
QStyle.StandardPixmap.SP_MessageBoxInformation # 信息
自己项目有图标资源时,用 QIcon("assets/save.png")。
五、状态栏的两个常用方式
python
# ① 临时消息:显示后会自动被下一条覆盖,也可以设超时自动清除
self.statusBar().showMessage("已保存", 3000) # 3 秒后自动消失
# ② 常驻控件:放右边,不会被 showMessage 挤掉
self.cursor_label = QLabel("行 1,列 1 ")
self.statusBar().addPermanentWidget(self.cursor_label)
这两个要配合用 :左边用 showMessage 显示"正在处理..."这类临时状态,
右边用 addPermanentWidget 显示光标位置、编码、行数这类长期信息。
一个实用小功能------光标位置实时显示:
python
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。

六、文件读写:三行搞定,但有坑
python
def on_open(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self, "打开文件", "", "文本文件 (*.txt);;所有文件 (*)")
if not path:
return # ★ 用户点了取消,必须检查
try:
content = Path(path).read_text(encoding="utf-8")
except UnicodeDecodeError:
QMessageBox.warning(self, "编码错误", "该文件不是 UTF-8 编码。")
return
self.editor.setPlainText(content)
self._path = Path(path)
self.setWindowTitle(f"记事本 - {self._path.name}")
def on_save(self) -> None:
if self._path is None: # 没保存过 → 先问路径
path, _ = QFileDialog.getSaveFileName(self, "保存文件", "未命名.txt", "文本文件 (*.txt)")
if not path:
return
self._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 默认编码在不同系统上不一致,
不写清楚换台机器就乱码
七、一个真实软件该有的细节
记事本虽然简单,但下面是让它"像个正经软件"的几个细节:
① 窗口标题跟着文件变
python
self.setWindowTitle(f"记事本 - {self._path.name}") # 而不是固定的"记事本"
② 关闭前询问未保存的修改
python
def closeEvent(self, event): # noqa: N802 Qt 的回调命名风格
if self.editor.document().isModified():
r = QMessageBox.question(self, "提示", "内容还没保存,确定要退出吗?")
if r != QMessageBox.StandardButton.Yes:
event.ignore() # ★ 忽略关闭事件,窗口不关
return
event.accept()
event.ignore() 是这一节的关键 :Qt 的关闭事件可以被"撤回"。
不调用 event.ignore() 而直接 return,窗口照样会关掉。
③ 保存后清除"已修改"标记
python
self.editor.document().setModified(False)
八、完整代码
python
# -*- coding: utf-8 -*-
"""第 6 讲 案例 06:主窗口框架 ------ 记事本骨架
知识点(QMainWindow 的五大件):
- menuBar() 菜单栏
- QToolBar 工具栏
- statusBar() 状态栏
- QAction 动作:菜单项、工具栏按钮、快捷键共用同一个对象
- QDockWidget 可停靠面板(本案例不用,第 12 讲数据库案例会用到)
顺带讲清一个高频疑问:为什么"复制/粘贴"在菜单和工具栏里能同时生效?
------ 因为它们是同一个 QAction,把 action 加到哪儿就显示在哪儿。
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from PyQt6.QtCore import QSize, Qt # noqa: E402
from PyQt6.QtGui import QAction, QKeySequence # noqa: E402
from PyQt6.QtWidgets import (QFileDialog, QMainWindow, QMessageBox, # noqa: E402
QStatusBar, QStyle, QTextEdit, QToolBar)
from utils.env import run # noqa: E402
class NotepadWindow(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):
from PyQt6.QtWidgets import QLabel
return QLabel(text + " ")
def _make_action(self, text, std_icon, shortcut, slot, tip) -> QAction:
"""创建 QAction 的标准姿势:图标 + 快捷键 + 提示 + 连接槽"""
action = QAction(self.style().standardIcon(std_icon), text, self)
if shortcut is not None:
action.setShortcut(shortcut)
action.setStatusTip(tip)
action.setToolTip(tip)
action.triggered.connect(slot)
return action
def _update_cursor_pos(self) -> None:
cur = self.editor.textCursor()
self.cursor_label.setText(f"行 {cur.blockNumber() + 1},列 {cur.columnNumber() + 1} ")
# ---------------- 槽函数 ----------------
def on_new(self) -> None:
self.editor.clear()
self._path = None
self.setWindowTitle("记事本 - 未命名")
self.statusBar().showMessage("已新建", 2000)
def on_open(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self, "打开文件", "", "文本文件 (*.txt);;所有文件 (*)")
if not path:
return # 用户点了取消,直接返回
try:
content = Path(path).read_text(encoding="utf-8")
except UnicodeDecodeError:
QMessageBox.warning(self, "编码错误", "该文件不是 UTF-8 编码,请另存为 UTF-8 后重试。")
return
self.editor.setPlainText(content)
self._path = Path(path)
self.setWindowTitle(f"记事本 - {self._path.name}")
self.statusBar().showMessage(f"已打开 {path}", 3000)
def on_save(self) -> None:
if self._path is None:
path, _ = QFileDialog.getSaveFileName(self, "保存文件", "未命名.txt", "文本文件 (*.txt)")
if not path:
return
self._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)
def on_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 个可运行案例。