开启跨平台 UI 之旅
- 前言
-
- [1 多标签页基础:QTabWidget 上手](#1 多标签页基础:QTabWidget 上手)
-
- [1.1 启用标签页](#1.1 启用标签页)
- [1.2 新建标签页:on_action_N_triggered()](#1.2 新建标签页:on_action_N_triggered())
- [1.3 启动时自动创建](#1.3 启动时自动创建)
- [2 文件操作:打开、保存、另存为](#2 文件操作:打开、保存、另存为)
-
- [2.1 打开文件(Ctrl+O)](#2.1 打开文件(Ctrl+O))
- [2.2 保存文件(Ctrl+S)](#2.2 保存文件(Ctrl+S))
- [2.3 另存为(Ctrl+Shift+S)](#2.3 另存为(Ctrl+Shift+S))
- [2.4 保存流程总结](#2.4 保存流程总结)
- [3 未保存提示:星号驱动引擎](#3 未保存提示:星号驱动引擎)
-
- [3.1 modificationChanged 信号驱动](#3.1 modificationChanged 信号驱动)
- [3.2 工作流程](#3.2 工作流程)
- [3.3 关闭标签页时的未保存警告](#3.3 关闭标签页时的未保存警告)
- [3.4 窗口关闭时的全局检查](#3.4 窗口关闭时的全局检查)
- [3.5 标签栏右键菜单:关闭全部/关闭其他](#3.5 标签栏右键菜单:关闭全部/关闭其他)
- [4 拖拽文件打开 + 最近文件列表](#4 拖拽文件打开 + 最近文件列表)
- [5 eventFilter 实战:标签栏中键关闭](#5 eventFilter 实战:标签栏中键关闭)
-
- [5.1 安装事件过滤器](#5.1 安装事件过滤器)
- [5.2 拦截中键点击](#5.2 拦截中键点击)
- [5.3 eventFilter 完整架构回顾](#5.3 eventFilter 完整架构回顾)
- 本篇总结
- 下一篇预告
前言
上一篇我们搭好了编辑器的"骨架"------无边框窗口、自定义标题栏、状态栏。但现在的窗口还是个空壳,连一个文本编辑区域都没有。
这篇开始往里面填内容。我们要实现的是编辑器最核心的功能------多标签页 + 文件操作。打开 VS Code,你可以同时编辑十几个文件,每个文件占一个标签页,点击切换,关闭时还会提醒你"文件还没保存"。这些功能背后的原理,今天全部拆解。
跟着写完这篇,你将收获:
- 多标签页编辑器 :
QTabWidget实现 VS Code 风格的多文件标签页 - 文件操作:新建、打开、保存、另存为,完整的文件读写流程
- 未保存提示 :基于
modificationChanged信号的星号驱动引擎 - 拖拽文件打开:从资源管理器直接拖文件进编辑器
- 最近文件列表 :用
QSettings持久化,重启软件还记得你打开过什么 - 标签栏右键菜单:中键关闭、右键关闭全部/关闭其他
1 多标签页基础:QTabWidget 上手
1.1 启用标签页
上一篇的构造函数里,我们已经写了这行:
cpp
ui->tabWidget->setTabsClosable(true); // 每个标签页显示关闭按钮(×)
ui->tabWidget->setMovable(true); // 标签页支持拖拽排序
QTabWidget 是 Qt 自带的多标签容器。setTabsClosable(true) 让每个标签页右上角出现一个关闭按钮,setMovable(true) 让你可以拖拽标签页调整顺序------这两个属性一开,就有了 VS Code 标签栏的雏形。
1.2 新建标签页:on_action_N_triggered()
每次新建标签页,本质上是创建一个全新的 QTextEdit 编辑器,塞进 QTabWidget。这是整个编辑器最核心的"生产工厂":
cpp
void MainWindow::on_action_N_triggered()
{
// 1. 创建 QTextEdit 编辑器
QTextEdit *newTextEdit = new QTextEdit(this);
// 2. 设置等宽字体(程序员必备)
QFont codeFont("Consolas", 11);
codeFont.setStyleHint(QFont::Monospace);
newTextEdit->setFont(codeFont);
newTextEdit->setTabStopDistance(QFontMetricsF(codeFont).horizontalAdvance(' ') * 4);
// 3. 左右留白(给行号和缩略图腾位置)
QTextDocument *doc = newTextEdit->document();
QTextFrameFormat fmt = doc->rootFrame()->frameFormat();
fmt.setLeftMargin(45); // 左边 45px 留给行号
fmt.setRightMargin(100); // 右边 100px 留给缩略图
doc->rootFrame()->setFrameFormat(fmt);
// 4. 创建行号组件并关联
LineNumberWidget *lineWidget = new LineNumberWidget(newTextEdit);
lineWidget->setObjectName("lineWidget");
lineWidget->resize(45, newTextEdit->height());
lineWidget->show();
// 5. 添加到标签页,标题为"未命名"
int newIndex = ui->tabWidget->addTab(newTextEdit, "未命名");
ui->tabWidget->setCurrentIndex(newIndex);
// ... 后续还有语法高亮、光标追踪等逻辑(下篇会详细讲)
}
每次调用 on_action_N_triggered() 就会新增一个标签页。程序启动时也会自动调用一次,确保打开软件就有至少一个空白编辑区。
1.3 启动时自动创建
cpp
// 构造函数末尾
on_action_N_triggered(); // 启动时自动新建一个空白标签页
这行保证了用户打开软件时不会面对一个空窗口。
2 文件操作:打开、保存、另存为
标签页有了,但还不能读写文件。这一节实现完整的文件读写流程。
2.1 打开文件(Ctrl+O)
核心流程:弹出文件选择对话框 -> 读取文件内容 -> 塞进当前标签页 -> 记录文件路径:
cpp
void MainWindow::on_action_O_triggered()
{
// 1. 防御:如果标签页全关了,自动新建一个
QTextEdit *currentEdit = qobject_cast<QTextEdit*>(ui->tabWidget->currentWidget());
if (!currentEdit) {
on_action_N_triggered();
currentEdit = qobject_cast<QTextEdit*>(ui->tabWidget->currentWidget());
}
// 2. 弹出系统文件选择对话框
QString fileName = QFileDialog::getOpenFileName(
this, "打开文件", "",
"文本文件 (*.txt);;所有文件 (*)"
);
if (fileName.isEmpty()) return;
// 3. 读取文件内容
QFile file(fileName);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
QMessageBox::warning(this, "警告", "抱歉,无法读取文件!");
return;
}
// 4. 塞进编辑器(先 blockSignals 防止触发不必要的信号)
QTextStream in(&file);
currentEdit->blockSignals(true);
currentEdit->setPlainText(in.readAll());
currentEdit->blockSignals(false);
// 5. 重新撑开左右留白
QTextFrameFormat fmt2 = currentEdit->document()->rootFrame()->frameFormat();
fmt2.setLeftMargin(45);
fmt2.setRightMargin(100);
currentEdit->document()->rootFrame()->setFrameFormat(fmt2);
// 6. 核心:打钢印 + 洗脑状态
currentEdit->setProperty("filePath", fileName); // 记住文件路径
currentEdit->document()->setModified(false); // 标记为"未修改"
currentEdit->document()->clearUndoRedoStacks(); // 清空撤销栈
// 7. 更新标签页标题
QString tabName = QFileInfo(fileName).fileName();
ui->tabWidget->setTabText(ui->tabWidget->currentIndex(), tabName);
// 8. 加入最近文件列表
saveRecentFile(fileName);
}
几个关键设计:
blockSignals(true/false):在设置文本内容前后屏蔽信号,防止modificationChanged误触发导致标题闪星号setProperty("filePath", fileName):给每个 QTextEdit 打上"文件路径钢印",后续保存时直接取这个值,不用每次都弹对话框问用户setModified(false):读取完文件后手动重置修改状态,避免刚打开就显示星号
2.2 保存文件(Ctrl+S)
保存逻辑比打开更简单:取文件路径 -> 有路径直接写,没路径弹另存为对话框:
cpp
void MainWindow::on_action_S_triggered()
{
QTextEdit *currentEdit = qobject_cast<QTextEdit*>(ui->tabWidget->currentWidget());
if (!currentEdit) return;
QString filePath = currentEdit->property("filePath").toString();
// 如果没有文件路径(新建的文件还没保存过),弹另存为对话框
if (filePath.isEmpty()) {
filePath = QFileDialog::getSaveFileName(
this, "保存文件", "",
"C/C++ 源代码 (*.c *.cpp);;文本文件 (*.txt);;所有文件 (*)"
);
if (filePath.isEmpty()) return;
currentEdit->setProperty("filePath", filePath);
}
// 写入文件
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(this, "警告", "抱歉,无法保存文件!");
return;
}
QTextStream out(&file);
out << currentEdit->toPlainText();
file.close();
// 核心防线:打钢印,再洗脑状态
currentEdit->document()->setModified(false); // 标记为"已保存"
// 更新标签页标题(去掉星号)
QString tabName = QFileInfo(filePath).fileName();
ui->tabWidget->setTabText(ui->tabWidget->currentIndex(), tabName);
// 加入最近文件列表
saveRecentFile(filePath);
}
2.3 另存为(Ctrl+Shift+S)
另存为的逻辑就是"无条件弹对话框让你选路径":
cpp
QAction *saveAsAction = new QAction("另存为...", this);
saveAsAction->setShortcut(QKeySequence("Ctrl+Shift+S"));
connect(saveAsAction, &QAction::triggered, this, [=]() {
QTextEdit *currentEdit = qobject_cast<QTextEdit*>(ui->tabWidget->currentWidget());
if (!currentEdit) return;
QString filePath = QFileDialog::getSaveFileName(
this, "另存为", "",
"C++ 源文件 (*.cpp);;C 头文件 (*.h);;所有文件 (*.*)"
);
if (filePath.isEmpty()) return;
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(this, "警告", "无法写入该文件,请检查权限。");
return;
}
QTextStream out(&file);
out << currentEdit->toPlainText();
file.close();
// 更新钢印和标题
currentEdit->setProperty("filePath", filePath);
currentEdit->document()->setModified(false);
int curIndex = ui->tabWidget->currentIndex();
if (curIndex >= 0) {
ui->tabWidget->setTabText(curIndex, QFileInfo(filePath).fileName());
}
saveRecentFile(filePath);
});
2.4 保存流程总结
无论是保存还是另存为,最后都要做三件事:
setProperty("filePath", path)------ 打上文件路径钢印setModified(false)------ 洗脑为"未修改"状态saveRecentFile(path)------ 加入最近文件列表
保存操作的完整流程:
#mermaid-svg-r2jaX5YsiGOx2uno{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-r2jaX5YsiGOx2uno .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-r2jaX5YsiGOx2uno .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-r2jaX5YsiGOx2uno .error-icon{fill:#552222;}#mermaid-svg-r2jaX5YsiGOx2uno .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-r2jaX5YsiGOx2uno .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-r2jaX5YsiGOx2uno .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-r2jaX5YsiGOx2uno .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-r2jaX5YsiGOx2uno .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-r2jaX5YsiGOx2uno .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-r2jaX5YsiGOx2uno .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-r2jaX5YsiGOx2uno .marker{fill:#333333;stroke:#333333;}#mermaid-svg-r2jaX5YsiGOx2uno .marker.cross{stroke:#333333;}#mermaid-svg-r2jaX5YsiGOx2uno svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-r2jaX5YsiGOx2uno p{margin:0;}#mermaid-svg-r2jaX5YsiGOx2uno .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-r2jaX5YsiGOx2uno .cluster-label text{fill:#333;}#mermaid-svg-r2jaX5YsiGOx2uno .cluster-label span{color:#333;}#mermaid-svg-r2jaX5YsiGOx2uno .cluster-label span p{background-color:transparent;}#mermaid-svg-r2jaX5YsiGOx2uno .label text,#mermaid-svg-r2jaX5YsiGOx2uno span{fill:#333;color:#333;}#mermaid-svg-r2jaX5YsiGOx2uno .node rect,#mermaid-svg-r2jaX5YsiGOx2uno .node circle,#mermaid-svg-r2jaX5YsiGOx2uno .node ellipse,#mermaid-svg-r2jaX5YsiGOx2uno .node polygon,#mermaid-svg-r2jaX5YsiGOx2uno .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-r2jaX5YsiGOx2uno .rough-node .label text,#mermaid-svg-r2jaX5YsiGOx2uno .node .label text,#mermaid-svg-r2jaX5YsiGOx2uno .image-shape .label,#mermaid-svg-r2jaX5YsiGOx2uno .icon-shape .label{text-anchor:middle;}#mermaid-svg-r2jaX5YsiGOx2uno .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-r2jaX5YsiGOx2uno .rough-node .label,#mermaid-svg-r2jaX5YsiGOx2uno .node .label,#mermaid-svg-r2jaX5YsiGOx2uno .image-shape .label,#mermaid-svg-r2jaX5YsiGOx2uno .icon-shape .label{text-align:center;}#mermaid-svg-r2jaX5YsiGOx2uno .node.clickable{cursor:pointer;}#mermaid-svg-r2jaX5YsiGOx2uno .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-r2jaX5YsiGOx2uno .arrowheadPath{fill:#333333;}#mermaid-svg-r2jaX5YsiGOx2uno .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-r2jaX5YsiGOx2uno .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-r2jaX5YsiGOx2uno .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r2jaX5YsiGOx2uno .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-r2jaX5YsiGOx2uno .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r2jaX5YsiGOx2uno .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-r2jaX5YsiGOx2uno .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-r2jaX5YsiGOx2uno .cluster text{fill:#333;}#mermaid-svg-r2jaX5YsiGOx2uno .cluster span{color:#333;}#mermaid-svg-r2jaX5YsiGOx2uno div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-r2jaX5YsiGOx2uno .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-r2jaX5YsiGOx2uno rect.text{fill:none;stroke-width:0;}#mermaid-svg-r2jaX5YsiGOx2uno .icon-shape,#mermaid-svg-r2jaX5YsiGOx2uno .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r2jaX5YsiGOx2uno .icon-shape p,#mermaid-svg-r2jaX5YsiGOx2uno .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-r2jaX5YsiGOx2uno .icon-shape .label rect,#mermaid-svg-r2jaX5YsiGOx2uno .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r2jaX5YsiGOx2uno .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-r2jaX5YsiGOx2uno .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-r2jaX5YsiGOx2uno :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 有(已保存过的文件)
没有(新建的文件)
是
否
用户按 Ctrl+S
当前标签页有
filePath 钢印?
直接写入文件
弹出 QFileDialog
让用户选路径
用户点了
取消?
return,什么都不做
拿到 filePath
setProperty 打钢印
QFile 打开 + QTextStream 写入
setModified(false)
洗脑为未修改
setTabText
标题去掉星号
saveRecentFile
加入最近文件列表
💡 "钢印 + 洗脑"是我对这套机制的比喻。
setProperty就像给编辑器打上了一个不可磨灭的钢印------"你属于哪个文件"。setModified(false)就像给编辑器洗了个脑------"你现在是干净的,没有未保存的修改"。后面会看到,这两个操作是消灭星号 Bug 的关键。
3 未保存提示:星号驱动引擎
编辑器最烦人的 Bug 之一就是 星号 Bug------文件明明改了但标签页不显示星号,或者文件已经保存了星号还在。这里用一个极其优雅的方案彻底消灭它。
3.1 modificationChanged 信号驱动
核心思路:不手动拼接星号,让 Qt 自己告诉我们什么时候该加星号、什么时候该去掉星号。
每个 QTextDocument 都有一个 modificationChanged 信号------当你编辑内容时它发出 true,当你调用 setModified(false) 时它发出 false。我们在新建标签页时挂上监听器:
cpp
// 在 on_action_N_triggered() 内部
connect(newTextEdit->document(), &QTextDocument::modificationChanged,
this, [=](bool modified) {
int index = ui->tabWidget->indexOf(newTextEdit);
if (index == -1) return; // 标签页已被删除,直接忽略
// 唯一的真理来源:先查绝对路径,再判脏数据
QString path = newTextEdit->property("filePath").toString();
QString cleanTitle = path.isEmpty() ? "未命名" : QFileInfo(path).fileName();
// 一句话搞定:modified=true 加星号,modified=false 去星号
ui->tabWidget->setTabText(index, modified ? (cleanTitle + " *") : cleanTitle);
});
3.2 工作流程
这套机制的工作流程是这样的:
#mermaid-svg-FM5vDCcAwyh6TZH7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FM5vDCcAwyh6TZH7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FM5vDCcAwyh6TZH7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FM5vDCcAwyh6TZH7 .error-icon{fill:#552222;}#mermaid-svg-FM5vDCcAwyh6TZH7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FM5vDCcAwyh6TZH7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FM5vDCcAwyh6TZH7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FM5vDCcAwyh6TZH7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FM5vDCcAwyh6TZH7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FM5vDCcAwyh6TZH7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FM5vDCcAwyh6TZH7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FM5vDCcAwyh6TZH7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FM5vDCcAwyh6TZH7 .marker.cross{stroke:#333333;}#mermaid-svg-FM5vDCcAwyh6TZH7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FM5vDCcAwyh6TZH7 p{margin:0;}#mermaid-svg-FM5vDCcAwyh6TZH7 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-FM5vDCcAwyh6TZH7 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-FM5vDCcAwyh6TZH7 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-FM5vDCcAwyh6TZH7 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-FM5vDCcAwyh6TZH7 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-FM5vDCcAwyh6TZH7 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-FM5vDCcAwyh6TZH7 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-FM5vDCcAwyh6TZH7 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-FM5vDCcAwyh6TZH7 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-FM5vDCcAwyh6TZH7 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-FM5vDCcAwyh6TZH7 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-FM5vDCcAwyh6TZH7 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-FM5vDCcAwyh6TZH7 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-FM5vDCcAwyh6TZH7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FM5vDCcAwyh6TZH7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FM5vDCcAwyh6TZH7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FM5vDCcAwyh6TZH7 .edgeLabel .label text{fill:#333;}#mermaid-svg-FM5vDCcAwyh6TZH7 .label div .edgeLabel{color:#333;}#mermaid-svg-FM5vDCcAwyh6TZH7 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-FM5vDCcAwyh6TZH7 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-FM5vDCcAwyh6TZH7 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-FM5vDCcAwyh6TZH7 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-FM5vDCcAwyh6TZH7 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-FM5vDCcAwyh6TZH7 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FM5vDCcAwyh6TZH7 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FM5vDCcAwyh6TZH7 #statediagram-barbEnd{fill:#333333;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FM5vDCcAwyh6TZH7 .cluster-label,#mermaid-svg-FM5vDCcAwyh6TZH7 .nodeLabel{color:#131300;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-FM5vDCcAwyh6TZH7 .note-edge{stroke-dasharray:5;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-note text{fill:black;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram-note .nodeLabel{color:black;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagram .edgeLabel{color:red;}#mermaid-svg-FM5vDCcAwyh6TZH7 #dependencyStart,#mermaid-svg-FM5vDCcAwyh6TZH7 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-FM5vDCcAwyh6TZH7 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FM5vDCcAwyh6TZH7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} setModified(false)
继续编辑
新建标签页 / setModified(false)
用户打字
modificationChanged(true)
用户保存
setModified(false)
未修改
标题 = "文件名"
无星号
已修改
标题 = "文件名 *"
显示星号
完全 状态驱动,不依赖任何手动字符串操作。无论是打字、撤销、粘贴、删除,只要文档状态变了,标题就会自动更新。
3.3 关闭标签页时的未保存警告
关闭标签页时检查标题是否以 * 结尾:
cpp
void MainWindow::on_tabWidget_tabCloseRequested(int index)
{
// 1. 获取标签页标题
QString tabTitle = ui->tabWidget->tabText(index);
// 2. 如果标题以 * 结尾,说明有未保存的修改
if (tabTitle.endsWith("*")) {
QMessageBox::StandardButton reply;
reply = QMessageBox::warning(
this, "未保存提示",
"当前标签页 [" + tabTitle + "] 还有未保存的修改!\n"
"强行关闭将丢失这些数据,确定要关闭吗?",
QMessageBox::Yes | QMessageBox::No
);
if (reply == QMessageBox::No) return; // 用户取消关闭
}
// 3. 执行销毁
QWidget *tabToDelete = ui->tabWidget->widget(index);
ui->tabWidget->removeTab(index);
delete tabToDelete; // 释放内存,防止泄漏
}
💡 为什么用
tabTitle.endsWith("*")而不是document()->isModified()? 两者在逻辑上是等价的(星号就是modificationChanged驱动的),但用字符串判断更直观------"标题带星号就问,不带就不问"。而且即使未来星号逻辑被其他代码干扰,只要标题里有星号就一定会弹警告,双重保险。
3.4 窗口关闭时的全局检查
关闭整个软件时,要遍历所有标签页检查是否有未保存的修改:
cpp
void MainWindow::closeEvent(QCloseEvent *event)
{
bool hasUnsaved = false;
for (int i = 0; i < ui->tabWidget->count(); ++i) {
if (ui->tabWidget->tabText(i).endsWith("*")) {
hasUnsaved = true;
break;
}
}
if (hasUnsaved) {
QMessageBox::StandardButton reply;
reply = QMessageBox::warning(
this, "危险操作",
"您还有未保存的文件!\n直接退出软件将永久丢失这些修改,确认要强行退出吗?",
QMessageBox::Yes | QMessageBox::No
);
if (reply == QMessageBox::No) {
event->ignore(); // 取消关闭
return;
}
}
event->accept(); // 允许关闭
}
3.5 标签栏右键菜单:关闭全部/关闭其他
在 eventFilter 中拦截标签栏的右键点击事件,弹出上下文菜单:
cpp
if (me->button() == Qt::RightButton) {
int index = ui->tabWidget->tabBar()->tabAt(me->pos());
if (index >= 0) {
QMenu contextMenu(this);
QAction *closeAct = contextMenu.addAction("关闭当前标签页");
QAction *closeOthersAct = contextMenu.addAction("关闭其他标签页");
QAction *closeAllAct = contextMenu.addAction("关闭所有标签页");
connect(closeAct, &QAction::triggered, this, [=]() {
closeTabSafely(index);
});
connect(closeOthersAct, &QAction::triggered, this, [=]() {
for (int i = ui->tabWidget->count() - 1; i >= 0; --i)
if (i != index) closeTabSafely(i);
});
connect(closeAllAct, &QAction::triggered, this, [=]() {
for (int i = ui->tabWidget->count() - 1; i >= 0; --i)
closeTabSafely(i);
});
contextMenu.exec(me->globalPosition().toPoint());
return true;
}
}
注意遍历顺序是 从后往前 (count()-1 到 0),因为删除标签页后索引会变化,从后往前删不会影响尚未遍历的索引。
closeTabSafely() 是一个封装好的安全关闭函数,内部会检查星号并弹警告:
cpp
void MainWindow::closeTabSafely(int index)
{
if (index < 0 || index >= ui->tabWidget->count()) return;
QString tabTitle = ui->tabWidget->tabText(index);
if (tabTitle.endsWith("*")) {
QMessageBox::StandardButton reply;
reply = QMessageBox::warning(this, "未保存提示",
"标签页 [" + tabTitle + "] 有未保存的修改!\n确定要关闭吗?",
QMessageBox::Yes | QMessageBox::No);
if (reply == QMessageBox::No) return;
}
QWidget *tabToDelete = ui->tabWidget->widget(index);
ui->tabWidget->removeTab(index);
delete tabToDelete;
}
💡 为什么要封装成
closeTabSafely()? 因为"关闭标签页并检查星号"这个逻辑在三个地方都要用------单个关闭、关闭其他、关闭全部。如果不封装,每个地方都要写一遍重复代码。封装后只需调用closeTabSafely(i)就行,DRY 原则[1](#1)。
4 拖拽文件打开 + 最近文件列表
4.1 拖拽打开文件
VS Code 支持直接把文件从资源管理器拖进去打开。这个功能实现起来比想象中简单------Qt 内置了完整的拖拽事件支持。
第一步:启用拖拽接收
cpp
// 构造函数中
this->setAcceptDrops(true);
第二步:处理拖入事件(决定是否接受拖拽)
cpp
void MainWindow::dragEnterEvent(QDragEnterEvent *event)
{
// 只接受包含文件 URL 的拖拽
if (event->mimeData()->hasUrls()) {
event->acceptProposedAction(); // 接受拖拽
}
}
第三步:处理放下事件(真正读取文件)
cpp
void MainWindow::dropEvent(QDropEvent *event)
{
const QList<QUrl> urls = event->mimeData()->urls();
for (const QUrl &url : urls) {
if (!url.isLocalFile()) continue; // 跳过非本地文件(如网页链接)
QString filePath = url.toLocalFile();
QFileInfo fi(filePath);
if (!fi.isFile()) continue; // 跳过文件夹
// 安全拦截:拒绝可执行文件和超大文件
QString ext = fi.suffix().toLower();
if (ext == "exe" || ext == "o" || ext == "dll" || ext == "bin" || ext == "ico") continue;
if (fi.size() > 2 * 1024 * 1024) continue; // 超过 2MB 的文件不自动打开
// 读取文件内容(复用打开文件的逻辑)
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) continue;
on_action_N_triggered(); // 新建一个标签页
QTextEdit *currentEdit = qobject_cast<QTextEdit*>(ui->tabWidget->currentWidget());
if (!currentEdit) { file.close(); continue; }
QTextStream in(&file);
currentEdit->blockSignals(true);
currentEdit->setPlainText(in.readAll());
currentEdit->blockSignals(false);
// 重新撑开留白
QTextFrameFormat fmt2 = currentEdit->document()->rootFrame()->frameFormat();
fmt2.setLeftMargin(45);
fmt2.setRightMargin(100);
currentEdit->document()->rootFrame()->setFrameFormat(fmt2);
currentEdit->document()->contentsChange(0, 0, currentEdit->toPlainText().length());
// 打钢印 + 洗脑
currentEdit->setProperty("filePath", filePath);
currentEdit->document()->setModified(false);
currentEdit->document()->clearUndoRedoStacks();
file.close();
saveRecentFile(filePath);
}
}
这里做了几个安全防护:
| 防护 | 原因 |
|---|---|
!url.isLocalFile() |
过滤网页链接、网络路径等非本地文件 |
!fi.isFile() |
过滤文件夹,防止误操作 |
| `ext == "exe" | |
fi.size() > 2MB |
拒绝超大文件,防止卡死 |
拖拽打开的完整流程:
#mermaid-svg-VSz9cj66x2AW67qG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VSz9cj66x2AW67qG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VSz9cj66x2AW67qG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VSz9cj66x2AW67qG .error-icon{fill:#552222;}#mermaid-svg-VSz9cj66x2AW67qG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VSz9cj66x2AW67qG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VSz9cj66x2AW67qG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VSz9cj66x2AW67qG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VSz9cj66x2AW67qG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VSz9cj66x2AW67qG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VSz9cj66x2AW67qG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VSz9cj66x2AW67qG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VSz9cj66x2AW67qG .marker.cross{stroke:#333333;}#mermaid-svg-VSz9cj66x2AW67qG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VSz9cj66x2AW67qG p{margin:0;}#mermaid-svg-VSz9cj66x2AW67qG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VSz9cj66x2AW67qG .cluster-label text{fill:#333;}#mermaid-svg-VSz9cj66x2AW67qG .cluster-label span{color:#333;}#mermaid-svg-VSz9cj66x2AW67qG .cluster-label span p{background-color:transparent;}#mermaid-svg-VSz9cj66x2AW67qG .label text,#mermaid-svg-VSz9cj66x2AW67qG span{fill:#333;color:#333;}#mermaid-svg-VSz9cj66x2AW67qG .node rect,#mermaid-svg-VSz9cj66x2AW67qG .node circle,#mermaid-svg-VSz9cj66x2AW67qG .node ellipse,#mermaid-svg-VSz9cj66x2AW67qG .node polygon,#mermaid-svg-VSz9cj66x2AW67qG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VSz9cj66x2AW67qG .rough-node .label text,#mermaid-svg-VSz9cj66x2AW67qG .node .label text,#mermaid-svg-VSz9cj66x2AW67qG .image-shape .label,#mermaid-svg-VSz9cj66x2AW67qG .icon-shape .label{text-anchor:middle;}#mermaid-svg-VSz9cj66x2AW67qG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VSz9cj66x2AW67qG .rough-node .label,#mermaid-svg-VSz9cj66x2AW67qG .node .label,#mermaid-svg-VSz9cj66x2AW67qG .image-shape .label,#mermaid-svg-VSz9cj66x2AW67qG .icon-shape .label{text-align:center;}#mermaid-svg-VSz9cj66x2AW67qG .node.clickable{cursor:pointer;}#mermaid-svg-VSz9cj66x2AW67qG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VSz9cj66x2AW67qG .arrowheadPath{fill:#333333;}#mermaid-svg-VSz9cj66x2AW67qG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VSz9cj66x2AW67qG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VSz9cj66x2AW67qG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VSz9cj66x2AW67qG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VSz9cj66x2AW67qG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VSz9cj66x2AW67qG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VSz9cj66x2AW67qG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VSz9cj66x2AW67qG .cluster text{fill:#333;}#mermaid-svg-VSz9cj66x2AW67qG .cluster span{color:#333;}#mermaid-svg-VSz9cj66x2AW67qG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VSz9cj66x2AW67qG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VSz9cj66x2AW67qG rect.text{fill:none;stroke-width:0;}#mermaid-svg-VSz9cj66x2AW67qG .icon-shape,#mermaid-svg-VSz9cj66x2AW67qG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VSz9cj66x2AW67qG .icon-shape p,#mermaid-svg-VSz9cj66x2AW67qG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VSz9cj66x2AW67qG .icon-shape .label rect,#mermaid-svg-VSz9cj66x2AW67qG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VSz9cj66x2AW67qG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VSz9cj66x2AW67qG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VSz9cj66x2AW67qG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
否
是
否
是
用户拖拽文件到窗口
dragEnterEvent
检查 hasUrls
包含 URL?
拒绝拖拽
acceptProposedAction
接受拖拽
dropEvent 触发
遍历每个 URL
isLocalFile?
isFile?
且不是exe/dll?
size < 2MB?
on_action_N_triggered
新建标签页
读取文件内容
塞进 QTextEdit
setProperty 打钢印
setModified 洗脑
saveRecentFile
加入最近列表
4.2 最近文件列表
"最近打开文件"是所有编辑器的标配。Qt 提供了 QSettings 类来存储用户偏好设置,非常适合保存这种小量数据。
初始化与加载
cpp
// 构造函数中
appSettings = new QSettings("CodeEditor", "MyNotepad_pro", this);
loadRecentFiles();
QSettings 会自动把数据存在系统注册表或配置文件中,重启软件也不会丢失。
加载最近文件列表
cpp
void MainWindow::loadRecentFiles()
{
if (!appSettings) return;
recentFiles = appSettings->value("recentFiles").toStringList();
recentFiles.removeAll(""); // 清除空字符串
// 去重(保留最新的一次)
QSet<QString> seen;
QStringList unique;
for (const QString &f : std::as_const(recentFiles)) {
if (!seen.contains(f)) {
seen.insert(f);
unique.append(f);
}
}
recentFiles = unique.mid(0, 10); // 最多保留 10 个
}
保存最近文件
每次打开或保存文件时调用:
cpp
void MainWindow::saveRecentFile(const QString &filePath)
{
if (!appSettings || filePath.isEmpty()) return;
recentFiles.removeAll(filePath); // 先移除旧记录(避免重复)
recentFiles.prepend(filePath); // 插到最前面(最新的在最上面)
recentFiles = recentFiles.mid(0, 10); // 最多保留 10 个
appSettings->setValue("recentFiles", recentFiles); // 写入配置
buildRecentFilesMenu(); // 重建菜单
}
构建最近文件菜单
cpp
void MainWindow::buildRecentFilesMenu()
{
if (!recentFilesMenu) return;
recentFilesMenu->clear();
if (recentFiles.isEmpty()) {
QAction *empty = recentFilesMenu->addAction("(无最近文件)");
empty->setEnabled(false); // 灰色不可点击
return;
}
for (int i = 0; i < recentFiles.size(); ++i) {
QString display = QFileInfo(recentFiles[i]).fileName() + " " + recentFiles[i];
QAction *act = recentFilesMenu->addAction(display);
act->setData(recentFiles[i]); // 把完整路径藏在 data 里
connect(act, &QAction::triggered, this, [=]() {
QString path = act->data().toString();
if (!QFile::exists(path)) {
QMessageBox::warning(this, "文件不存在",
"该文件已被移动或删除:" + path);
recentFiles.removeAll(path);
appSettings->setValue("recentFiles", recentFiles);
buildRecentFilesMenu(); // 重建菜单,去掉这条
return;
}
// 复用打开文件的逻辑
QFile file(path);
if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
on_action_N_triggered();
QTextEdit *currentEdit = qobject_cast<QTextEdit*>(
ui->tabWidget->currentWidget());
if (currentEdit) {
QTextStream in(&file);
currentEdit->blockSignals(true);
currentEdit->setPlainText(in.readAll());
currentEdit->blockSignals(false);
currentEdit->setProperty("filePath", path);
currentEdit->document()->setModified(false);
}
file.close();
}
});
}
recentFilesMenu->addSeparator();
QAction *clearAct = recentFilesMenu->addAction("清除最近文件列表");
connect(clearAct, &QAction::triggered, this, [=]() {
recentFiles.clear();
appSettings->setValue("recentFiles", recentFiles);
buildRecentFilesMenu();
});
}
💡 文件不存在时怎么办? 用户可能把文件重命名或删除了,但最近文件列表里还留着。点击时先用
QFile::exists()检查,不存在就弹提示并自动从列表中清除,避免用户反复踩坑。
最近文件列表的数据流:
#mermaid-svg-4lZGKDjzYzLTNpED{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4lZGKDjzYzLTNpED .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4lZGKDjzYzLTNpED .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4lZGKDjzYzLTNpED .error-icon{fill:#552222;}#mermaid-svg-4lZGKDjzYzLTNpED .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4lZGKDjzYzLTNpED .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4lZGKDjzYzLTNpED .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4lZGKDjzYzLTNpED .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4lZGKDjzYzLTNpED .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4lZGKDjzYzLTNpED .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4lZGKDjzYzLTNpED .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4lZGKDjzYzLTNpED .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4lZGKDjzYzLTNpED .marker.cross{stroke:#333333;}#mermaid-svg-4lZGKDjzYzLTNpED svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4lZGKDjzYzLTNpED p{margin:0;}#mermaid-svg-4lZGKDjzYzLTNpED .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4lZGKDjzYzLTNpED .cluster-label text{fill:#333;}#mermaid-svg-4lZGKDjzYzLTNpED .cluster-label span{color:#333;}#mermaid-svg-4lZGKDjzYzLTNpED .cluster-label span p{background-color:transparent;}#mermaid-svg-4lZGKDjzYzLTNpED .label text,#mermaid-svg-4lZGKDjzYzLTNpED span{fill:#333;color:#333;}#mermaid-svg-4lZGKDjzYzLTNpED .node rect,#mermaid-svg-4lZGKDjzYzLTNpED .node circle,#mermaid-svg-4lZGKDjzYzLTNpED .node ellipse,#mermaid-svg-4lZGKDjzYzLTNpED .node polygon,#mermaid-svg-4lZGKDjzYzLTNpED .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4lZGKDjzYzLTNpED .rough-node .label text,#mermaid-svg-4lZGKDjzYzLTNpED .node .label text,#mermaid-svg-4lZGKDjzYzLTNpED .image-shape .label,#mermaid-svg-4lZGKDjzYzLTNpED .icon-shape .label{text-anchor:middle;}#mermaid-svg-4lZGKDjzYzLTNpED .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4lZGKDjzYzLTNpED .rough-node .label,#mermaid-svg-4lZGKDjzYzLTNpED .node .label,#mermaid-svg-4lZGKDjzYzLTNpED .image-shape .label,#mermaid-svg-4lZGKDjzYzLTNpED .icon-shape .label{text-align:center;}#mermaid-svg-4lZGKDjzYzLTNpED .node.clickable{cursor:pointer;}#mermaid-svg-4lZGKDjzYzLTNpED .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4lZGKDjzYzLTNpED .arrowheadPath{fill:#333333;}#mermaid-svg-4lZGKDjzYzLTNpED .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4lZGKDjzYzLTNpED .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4lZGKDjzYzLTNpED .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4lZGKDjzYzLTNpED .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4lZGKDjzYzLTNpED .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4lZGKDjzYzLTNpED .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4lZGKDjzYzLTNpED .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4lZGKDjzYzLTNpED .cluster text{fill:#333;}#mermaid-svg-4lZGKDjzYzLTNpED .cluster span{color:#333;}#mermaid-svg-4lZGKDjzYzLTNpED div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4lZGKDjzYzLTNpED .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4lZGKDjzYzLTNpED rect.text{fill:none;stroke-width:0;}#mermaid-svg-4lZGKDjzYzLTNpED .icon-shape,#mermaid-svg-4lZGKDjzYzLTNpED .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4lZGKDjzYzLTNpED .icon-shape p,#mermaid-svg-4lZGKDjzYzLTNpED .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4lZGKDjzYzLTNpED .icon-shape .label rect,#mermaid-svg-4lZGKDjzYzLTNpED .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4lZGKDjzYzLTNpED .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4lZGKDjzYzLTNpED .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4lZGKDjzYzLTNpED :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 菜单
内存
QSettings (系统配置)
loadRecentFiles()
启动时加载
buildRecentFilesMenu()
重建菜单
用户点击某条
是
否
saveRecentFile()
每次打开/保存时
recentFiles
QStringList
recentFiles 成员变量
最多10条
最近文件菜单
QMenu
文件存在?
新建标签页
读取内容
弹警告
从列表删除
5 eventFilter 实战:标签栏中键关闭
上一篇我们初步了解了 eventFilter 的用法。这一节用它来实现一个 VS Code 的经典交互------鼠标中键点击标签页直接关闭。
5.1 安装事件过滤器
在构造函数中,给标签栏安装事件过滤器:
cpp
ui->tabWidget->tabBar()->installEventFilter(this);
5.2 拦截中键点击
在 eventFilter() 中添加拦截逻辑:
cpp
// 拦截标签栏的鼠标点击事件
if (obj == ui->tabWidget->tabBar() && event->type() == QEvent::MouseButtonPress) {
QMouseEvent *me = static_cast<QMouseEvent*>(event);
// 中键点击:直接关闭标签页
if (me->button() == Qt::MiddleButton) {
int index = ui->tabWidget->tabBar()->tabAt(me->pos());
if (index >= 0) {
on_tabWidget_tabCloseRequested(index); // 复用关闭逻辑(含星号检查)
return true;
}
}
// 右键点击:弹出上下文菜单(第3节已讲)
if (me->button() == Qt::RightButton) {
// ... 右键菜单代码 ...
}
}
这个功能完美复用了 on_tabWidget_tabCloseRequested(index),所以星号检查、内存释放等逻辑一个都没少。
5.3 eventFilter 完整架构回顾
到目前为止,我们的 eventFilter 已经承担了多种职责:
eventFilter(obj, event)
├── obj == tabBar && MouseButtonPress
│ ├── MiddleButton → 关闭标签页
│ └── RightButton → 右键菜单(关闭全部/其他)
├── obj == cursorLabel && MouseButtonPress → 跳转到行
├── obj == zoomLabel && MouseButtonPress → 重置缩放
└── 其他 → 交给父类处理
后续还会加入更多拦截:
- 终端编辑器的键盘交互(Tab 补全、Ctrl+C 中断、历史命令回溯)
- 编辑器的智能代码补全(成对符号、缩进、代码片段展开)
💡 为什么把所有交互都塞进
eventFilter? 这是一种"中央事件路由器"的设计模式。与其让每个 widget 各自处理事件,不如统一在一个地方做分发。好处是逻辑集中、易于维护,坏处是函数会越来越长。对于单文件项目来说,这种取舍是值得的。
eventFilter 在本篇中的完整架构:
#mermaid-svg-3cPCk4aUKvG10zP7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3cPCk4aUKvG10zP7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3cPCk4aUKvG10zP7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3cPCk4aUKvG10zP7 .error-icon{fill:#552222;}#mermaid-svg-3cPCk4aUKvG10zP7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3cPCk4aUKvG10zP7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3cPCk4aUKvG10zP7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3cPCk4aUKvG10zP7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3cPCk4aUKvG10zP7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3cPCk4aUKvG10zP7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3cPCk4aUKvG10zP7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3cPCk4aUKvG10zP7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3cPCk4aUKvG10zP7 .marker.cross{stroke:#333333;}#mermaid-svg-3cPCk4aUKvG10zP7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3cPCk4aUKvG10zP7 p{margin:0;}#mermaid-svg-3cPCk4aUKvG10zP7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3cPCk4aUKvG10zP7 .cluster-label text{fill:#333;}#mermaid-svg-3cPCk4aUKvG10zP7 .cluster-label span{color:#333;}#mermaid-svg-3cPCk4aUKvG10zP7 .cluster-label span p{background-color:transparent;}#mermaid-svg-3cPCk4aUKvG10zP7 .label text,#mermaid-svg-3cPCk4aUKvG10zP7 span{fill:#333;color:#333;}#mermaid-svg-3cPCk4aUKvG10zP7 .node rect,#mermaid-svg-3cPCk4aUKvG10zP7 .node circle,#mermaid-svg-3cPCk4aUKvG10zP7 .node ellipse,#mermaid-svg-3cPCk4aUKvG10zP7 .node polygon,#mermaid-svg-3cPCk4aUKvG10zP7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3cPCk4aUKvG10zP7 .rough-node .label text,#mermaid-svg-3cPCk4aUKvG10zP7 .node .label text,#mermaid-svg-3cPCk4aUKvG10zP7 .image-shape .label,#mermaid-svg-3cPCk4aUKvG10zP7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-3cPCk4aUKvG10zP7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3cPCk4aUKvG10zP7 .rough-node .label,#mermaid-svg-3cPCk4aUKvG10zP7 .node .label,#mermaid-svg-3cPCk4aUKvG10zP7 .image-shape .label,#mermaid-svg-3cPCk4aUKvG10zP7 .icon-shape .label{text-align:center;}#mermaid-svg-3cPCk4aUKvG10zP7 .node.clickable{cursor:pointer;}#mermaid-svg-3cPCk4aUKvG10zP7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3cPCk4aUKvG10zP7 .arrowheadPath{fill:#333333;}#mermaid-svg-3cPCk4aUKvG10zP7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3cPCk4aUKvG10zP7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3cPCk4aUKvG10zP7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3cPCk4aUKvG10zP7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3cPCk4aUKvG10zP7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3cPCk4aUKvG10zP7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3cPCk4aUKvG10zP7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3cPCk4aUKvG10zP7 .cluster text{fill:#333;}#mermaid-svg-3cPCk4aUKvG10zP7 .cluster span{color:#333;}#mermaid-svg-3cPCk4aUKvG10zP7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3cPCk4aUKvG10zP7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3cPCk4aUKvG10zP7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-3cPCk4aUKvG10zP7 .icon-shape,#mermaid-svg-3cPCk4aUKvG10zP7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3cPCk4aUKvG10zP7 .icon-shape p,#mermaid-svg-3cPCk4aUKvG10zP7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3cPCk4aUKvG10zP7 .icon-shape .label rect,#mermaid-svg-3cPCk4aUKvG10zP7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3cPCk4aUKvG10zP7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3cPCk4aUKvG10zP7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3cPCk4aUKvG10zP7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} installEventFilter
obj == tabBar
MiddleButton
obj == tabBar
RightButton
obj == cursorLabel
MouseButtonPress
obj == zoomLabel
MouseButtonPress
obj == terminalEdit
KeyPress
obj == QTextEdit
KeyPress
其他
QObject 收到事件
MainWindow::eventFilter()
obj 类型 + 事件类型
关闭标签页
右键菜单
关闭全部/其他
跳转到行
重置缩放
终端键盘交互
智能代码补全
return eventFilter
交给父类
return true
本篇总结
多标签页编辑器的完整功能已经实现。回顾一下:
| 功能 | 核心技术 | 关键 API |
|---|---|---|
| 多标签页 | QTabWidget | addTab(), setCurrentIndex() |
| 文件打开 | 文件对话框 + QTextStream | QFileDialog::getOpenFileName() |
| 文件保存 | 钢印 + 洗脑模式 | setProperty(), setModified() |
| 另存为 | 无条件弹对话框 | QFileDialog::getSaveFileName() |
| 未保存提示 | modificationChanged 信号驱动 | endsWith("*") |
| 标签页关闭 | 安全关闭封装 | closeTabSafely() |
| 拖拽打开 | MIME 数据解析 | dragEnterEvent(), dropEvent() |
| 最近文件 | QSettings 持久化 | appSettings->setValue() |
| 中键关闭 | eventFilter 拦截 | Qt::MiddleButton |
本篇核心代码量约 400 行,覆盖了编辑器 80% 的文件操作功能。
下一篇预告
编辑器能读写文件了,但还是个"纯文本显示器"------没有行号,没有语法高亮,没有缩略图。第 4 篇将实现编辑器的视觉增强:
- 行号显示:QPainter 手绘行号面板,滚动同步
- 断点管理:点击行号区域添加/移除红色断点(GDB 调试预留)
- 异步语法高亮:QtConcurrent 后台线程解析 + 防抖 + VS Code Dark+ 配色
- 括号配对高亮:光标移到括号处自动高亮匹配的另一个括号
- 当前行高亮:浅灰色背景标记当前编辑行
敬请期待!
脚注
- DRY 原则(Don't Repeat Yourself)是软件工程的基本原则之一,意思是"不要重复自己"------同一段逻辑不应该在多处重复出现。封装成函数后,修改只需改一处,所有调用点自动生效。在 Qt 开发中,善用 lambda 和信号槽可以大幅减少重复代码。 ↩︎