PyQt6-第06讲-主窗口框架

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

四个必须处理的细节:

  1. getOpenFileName 返回的是元组 (path, filter),要用逗号解包,别漏了
  2. 用户点取消时 path 是空字符串 ,必须 if not path: return
  3. 读文件要处理编码异常 ,UnicodeDecodeError 是最常见的报错
  4. 编码统一用 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 个可运行案例。

相关推荐
ss.li1 小时前
rppg相关论文阅读笔记10.22
论文阅读·笔记·python
tryCbest1 小时前
Python之Flask项目部署到阿里云
python·阿里云·flask
liwulin05061 小时前
【PYTORCH】迷你DECODER-ONLY模型
pytorch·python·深度学习
全栈练习生1 小时前
Agent工程化三层架构
python·ai
高洁012 小时前
边缘AI与大模型轻量化:让智能跑进每一个终端
人工智能·python·深度学习·机器学习·迁移学习
MoRanzhi12033 小时前
第6篇(中)· 使用 PyQt5 实现 2048 高性能计算版:无头后台求解与三档运行模式
python·pyqt·数据持久化·多进程·进程间通讯·后台任务·断点续跑
Metaphor6925 小时前
Python 自动化办公:如何轻松在 PDF 中导入与导出表单数据
python·pdf·自动化
外收内放13 小时前
面向对象高级(实战案例)
开发语言·python·学习
专业程序开发源14 小时前
springboot欧缇蔓月子中心管理系统82697-计算机课程设计、毕业设计
java·spring boot·后端·python·spring·django·课程设计