2019 年原始导出只有 front matter,没有文章正文。后来的维护曾加入一个最小编辑器;本次 2026 版仍保持示例可学习,但不会在保存成功前截断旧文件,也不会无提示丢弃修改。
Table of Contents
这个示例保证什么
程序会:
- 明确把文件当作 UTF-8 文本;解码失败时报告错误,而不是猜测;
- 在取消“打开”或读取失败时保持当前文档不变;
- 跟踪
QTextDocument的修改状态; - 在“新建”“打开”或“关闭”将丢失编辑前,提供“保存”“丢弃”“取消”;
- 使用
QSaveFile,完整写入成功后才把临时文件提交为目标文件; - 让保存方法返回布尔结果,因此取消“另存为”也会取消请求它的破坏性动作。
它是教学用编辑器,不是成熟代码编辑器的替代品。限制在后文明确列出。
完整示例
import sys
from pathlib import Path
from typing import Optional
from PyQt5.QtCore import QIODevice, QSaveFile
from PyQt5.QtGui import QCloseEvent, QKeySequence
from PyQt5.QtWidgets import (
QAction,
QApplication,
QFileDialog,
QMainWindow,
QMessageBox,
QTextEdit,
)
class TextEditor(QMainWindow):
def __init__(self):
super().__init__()
self.current_path = None # type: Optional[Path]
self.editor = QTextEdit()
self.editor.setAcceptRichText(False)
self.setCentralWidget(self.editor)
self.editor.document().modificationChanged.connect(self.update_title)
self.build_menu()
self.resize(800, 600)
self.update_title()
def make_action(self, text, shortcut, slot):
action = QAction(text, self)
action.setShortcut(shortcut)
action.triggered.connect(slot)
return action
def build_menu(self):
file_menu = self.menuBar().addMenu("&File")
file_menu.addAction(
self.make_action("&New", QKeySequence.New, self.new_file)
)
file_menu.addAction(
self.make_action("&Open…", QKeySequence.Open, self.open_file)
)
file_menu.addAction(
self.make_action("&Save", QKeySequence.Save, self.save_file)
)
file_menu.addAction(
self.make_action("Save &As…", QKeySequence.SaveAs, self.save_file_as)
)
file_menu.addSeparator()
file_menu.addAction(
self.make_action("E&xit", QKeySequence.Quit, self.close)
)
def update_title(self):
name = self.current_path.name if self.current_path else "Untitled"
marker = "*" if self.editor.document().isModified() else ""
self.setWindowTitle(f"{marker}{name} — PyQt5 Text Editor")
def new_file(self):
if not self.maybe_save():
return
self.editor.clear()
self.current_path = None
self.editor.document().setModified(False)
self.update_title()
def open_file(self):
if not self.maybe_save():
return
file_name, _ = QFileDialog.getOpenFileName(
self,
"Open UTF-8 Text File",
"",
"Text Files (*.txt *.md *.py);;All Files (*)",
)
if not file_name:
return
path = Path(file_name)
try:
text = path.read_text(encoding="utf-8-sig")
except UnicodeError as error:
self.show_error("Open failed", f"The file is not valid UTF-8:n{error}")
return
except OSError as error:
self.show_error("Open failed", str(error))
return
self.editor.setPlainText(text)
self.current_path = path
self.editor.document().setModified(False)
self.update_title()
def save_file(self):
if self.current_path is None:
return self.save_file_as()
return self.write_file(self.current_path)
def save_file_as(self):
suggestion = str(self.current_path) if self.current_path else "untitled.txt"
file_name, _ = QFileDialog.getSaveFileName(
self,
"Save UTF-8 Text File",
suggestion,
"Text Files (*.txt);;Markdown Files (*.md);;Python Files (*.py);;All Files (*)",
)
if not file_name:
return False
return self.write_file(Path(file_name))
def write_file(self, path):
payload = self.editor.toPlainText().encode("utf-8")
output = QSaveFile(str(path))
if not output.open(QIODevice.WriteOnly):
self.show_error("Save failed", output.errorString())
return False
if output.write(payload) != len(payload):
message = output.errorString()
output.cancelWriting()
self.show_error("Save failed", message)
return False
if not output.commit():
self.show_error("Save failed", output.errorString())
return False
self.current_path = path
self.editor.document().setModified(False)
self.update_title()
return True
def maybe_save(self):
if not self.editor.document().isModified():
return True
choice = QMessageBox.warning(
self,
"Unsaved changes",
"Save changes before continuing?",
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
QMessageBox.Save,
)
if choice == QMessageBox.Save:
return self.save_file()
return choice == QMessageBox.Discard
def show_error(self, title, message):
QMessageBox.critical(self, title, message)
def closeEvent(self, event: QCloseEvent):
if self.maybe_save():
event.accept()
else:
event.ignore()
if __name__ == "__main__":
app = QApplication(sys.argv)
window = TextEditor()
window.show()
raise SystemExit(app.exec_())
为什么这些安全边界重要
读取成功后才改变编辑器状态
PyQt5 的静态文件对话框返回 (file_name, selected_filter) 元组。取消时路径为空,方法直接退出。程序先把文件读入临时 Python 字符串,只有解码成功后才修改 QTextEdit、current_path 和修改标记,因此读取失败不会替换当前缓冲区。
utf-8-sig 同时接受普通 UTF-8,并移除开头的 UTF-8 BOM;它不会猜测旧编码。编码选择器应作为独立功能实现,并测试往返一致性。
保存会提交一个完整替代文件
直接用 Python open(path, "w") 打开目标,会在完整文档写完之前截断旧文件。QSaveFile 先在目标目录写临时文件,再在 commit() 时替代目标。本例检查写入字节数和提交结果,并保留 Qt 默认的“不直接写入备用”策略:无法创建原子临时文件时明确失败,而不是悄悄降级为风险更高的直接覆盖。
破坏性动作取决于保存结果
QTextDocument.modificationChanged 负责更新标题标记。执行“新建”“打开”或“关闭”前,maybe_save() 提供三个不同结果。只有保存返回 True 才继续;取消“另存为”会返回 False,因此请求保存的破坏性动作也随之取消。只有明确选择“丢弃”才放弃编辑。
安装与运行
请使用与操作系统匹配的软件包路径。Raspberry Pi OS PyQt5 安装指南已经维护了 APT、虚拟环境、GUI 与离屏测试。其他平台应遵循 Riverbank 安装文档,不要使用 sudo pip。
把示例保存为 text_editor.py,再用已验证能导入 PyQt5 的解释器运行:
python3 text_editor.py
添加新功能前,请覆盖每条分支:
- 取消“打开”,确认缓冲区不变。
- 修改无标题缓冲区,选择“新建”→“保存”,再取消“另存为”;缓冲区必须保留。
- 修改后关闭,在警告框选择“取消”;窗口必须继续存在。
- 打开非 UTF-8 字节;旧缓冲区必须保留,并显示错误。
- 保存到不可写位置;修改标记必须保留。
- 成功保存、重新打开,并比较 UTF-8 字节。
必须明确的限制
QTextEdit.toPlainText()是文本模型,不是逐字节保存编辑器。本例写 UTF-8,不保留原 BOM、旧编码或精确换行约定。- 大文件一次性载入内存,可能让 GUI 无响应。
- 符号链接、权限、所有者、外部修改、并发写入、备份与版本历史都需要明确产品策略。
- 原子替代受目标文件系统与目录权限影响。本例拒绝 Qt 的直接写入备用,让无法建立原子保存的错误可见。
- 文件名过滤器只便于导航,不会让不可信内容变安全。本程序只显示纯文本,不执行打开的文件。
2019 年原始导出
归档中的 2019 Markdown 在 front matter 后立即结束,没有原始正文、代码、引文或来源链接可以保留。上面的教程是后来加入、并明确维护的内容。
