
🫧 励志不掉头发的内向程序员 :个人主页
✨️ 个人专栏: 《C++语言》《Linux学习》
🌅偶尔悲伤,偶尔被幸福所完善
👓️博主简介:

文章目录
- 前言
- 一、先从用户能看到的现象说起
- 二、文档级撤销:谁拥有撤销栈?
-
- [2.1、RS_Document 的双重继承](#2.1、RS_Document 的双重继承)
- 2.2、撤销栈长什么样
- 三、撤销不是删除,而是打标记
-
- [3.1、RS_Undoable 的 changeUndoState](#3.1、RS_Undoable 的 changeUndoState)
- 3.2、撤掉之后,实体还要做什么
- [四、撤销三步曲:startUndoCycle / addUndoable / endUndoCycle](#四、撤销三步曲:startUndoCycle / addUndoable / endUndoCycle)
- 五、RS_UndoCycle:为什么一次撤销能撤销一整组
- [六、第二套 History:Action 内部的交互历史](#六、第二套 History:Action 内部的交互历史)
-
- [6.1、四个 HistoryAction](#6.1、四个 HistoryAction)
- 6.2、addHistory:和文本编辑器的撤销缓冲一个道理
- [6.3、Action 自己的 undo 与 redo](#6.3、Action 自己的 undo 与 redo)
- 6.4、那一行关键的代码
- [七、两套 History 到底怎么配合](#七、两套 History 到底怎么配合)
- [八、Ctrl+Z 的完整链路](#八、Ctrl+Z 的完整链路)
-
- [8.1、从菜单到 Action](#8.1、从菜单到 Action)
- [8.2、RS_ActionEditUndo 的实现](#8.2、RS_ActionEditUndo 的实现)
- 8.3、撤销之后要做的五件事
- 8.4、撤销按钮为什么会自己变灰
- 总结
前言
上一篇文章的最后,我们留下了一个问题。RS_ActionDrawLine::trigger() 里有这么三行:
cpp
// update undo list
if (document) {
document->startUndoCycle();
document->addUndoable(line);
document->endUndoCycle();
}
而第六篇里,我们贴过另一段看着很像的代码:
cpp
addHistory( HA_SetEndpoint, pPoints->data.startpoint, mouse, pPoints->startOffset);
一个是 addUndoable,一个是 addHistory;一个挂在 document 上,一个挂在 pPoints 上。名字都带着"历史"的味道,但它们真的是一回事吗?
先给结论:不是一回事,而且它们各自解决的是完全不同层面的问题。
但更有意思的是,这两套 History 并不是各干各的------它们之间有一个明确的交汇点,而这个交汇点正好解释了一个很多人在用 LibreCAD 时都遇到过的疑惑:为什么画线画到一半,右键一下就能把刚画的那条线撤掉?
这一篇我们就把这两套 History 完整拆开,顺便看看 LibreCAD 的撤销系统到底是怎么设计的。

一、先从用户能看到的现象说起
在动手看源码之前,我们先把 LibreCAD 里跟"撤销"有关的几个操作列一列,因为它们的表现实在太像了:
第一个,画线画到一半,在命令行里输入 undo。 刚刚画的那条线消失了,光标回到上一个端点,画线工具还开着。


第二个,直接按 Ctrl+Z。 上一次绘制的图形消失,但绘图工具可能会被切换掉。


第三个,在画线过程中按右键。 根据当前状态不同,有时是"取消整个操作",有时是"结束当前这一组线"。


三种操作看起来都在"往回退",但如果它们真是一套逻辑,源码里就不需要出现两个都以 History 命名的东西了。
所以我们的任务很明确:先搞清楚文档级的撤销(也就是 Ctrl+Z 那条路),再回过头看 Action 内部那套 History,最后找到它们的交汇点。
二、文档级撤销:谁拥有撤销栈?
2.1、RS_Document 的双重继承
我们在上一篇里提到过,RS_ActionInterface 的构造函数里有一句:
cpp
// document pointer will be used for undo / redo
document = container.getDocument();
注释写得很明确:document 这个指针就是留给撤销用的。那我们就去看看 RS_Document 到底是个什么东西。
cpp
class RS_Document : public RS_EntityContainer, public RS_Undo {
public:
// ...
};
看到这个继承列表,一切就很清楚了:一张图纸既是一个实体容器,同时也是一个撤销栈。
这也是面向对象设计里很典型的一种手法------"是一张图纸"和"能撤销"是两种完全不同的能力,所以分成两个基类分别继承,而不是硬塞进一个类里。对比一下我们就更能体会了:RS_Graphic(真正的图纸)和 RS_Block(图块)都是 RS_Document 的子类,所以它们天生都具备撤销能力。这也是为什么你在图块编辑环境里同样可以按 Ctrl+Z。
2.2、撤销栈长什么样
我们再来看看 RS_Undo 的成员:
cpp
class RS_Undo {
// ...
private:
//! List of undo list items. every item is something that can be undone.
std::vector<std::shared_ptr<RS_UndoCycle>> undoList;
/**
* Index that points to the current position in the undo list.
* The item it points on will be undone the next time undo is called.
* The item after will be redone (if there is an item) when redo
* is called.
*/
int undoPointer = -1;
/**
* Current undo cycle.
*/
std::shared_ptr<RS_UndoCycle> currentCycle {nullptr};
int refCount {0}; ///< reference counter for nested start/end calls
};
一共四个成员,但已经能看出整个设计思路了:
undoList 是一个存放"撤销周期"的数组。注意它装的不是实体,而是 RS_UndoCycle(撤销周期),这一点很关键,后面会展开。
undoPointer 是一个游标。源码注释写得非常清楚:它指向的那一项,就是下一次调用 undo() 时会被撤销的东西;它后面那一项,就是下一次 redo() 会恢复的东西。
currentCycle 是当前正在收集的撤销周期。
refCount 是引用计数,用来处理 startUndoCycle() / endUndoCycle() 的嵌套调用。

看懂了这张图,undo() 和 redo() 就变得极其简单:
cpp
bool RS_Undo::undo() {
if (undoPointer < 0) return false;
std::shared_ptr<RS_UndoCycle> uc = undoList[undoPointer--];
setGUIButtons();
uc->changeUndoState();
return true;
}
bool RS_Undo::redo() {
if (undoPointer+1 < int(undoList.size())) {
std::shared_ptr<RS_UndoCycle> uc = undoList[++undoPointer];
setGUIButtons();
uc->changeUndoState();
return true;
}
return false;
}
undo() 就是"游标左移一格",redo() 就是"游标右移一格"。真正的撤销动作只有一句:
cpp
uc->changeUndoState();
请注意这一句------撤销不是删除,而是"翻转状态"。 这是理解整套机制的关键,我们下面马上展开。
三、撤销不是删除,而是打标记
3.1、RS_Undoable 的 changeUndoState
changeUndoState() 的实现在 RS_Undoable 里:
cpp
/**
* The undoable thing gets activated if it was undone and
* deactivated otherwise.
*/
void RS_Undoable::changeUndoState() {
toggleFlag(RS2::FlagUndone);
undoStateChanged(isUndone());
}
/**
* Is this entity in the Undo memory and not active?
*/
bool RS_Undoable::isUndone() const {
return getFlag(RS2::FlagUndone);
}
整个撤销操作,本质上就是翻转一个名为 FlagUndone 的标志位。
这跟我们直觉里的"撤销 = 把线删掉"完全不一样。LibreCAD 的做法是:线还在内存里,只是被标记成"已撤销"状态。所以你按下 Ctrl+Z 之后,数据并没有真正消失,只是被藏起来了。
那"藏起来"这个动作在哪里发生的呢?我们来看实体的可见性判断:
cpp
bool RS_Entity::isVisible() const{
if (!getFlag(RS2::FlagVisible)) {
return false;
}
if (isUndone()) {
return false;
}
// ...
}
而"是否已撤销"这个判断还考虑了自己的父容器:
cpp
/**
* @return true if this entity or any parent entities are undone.
*/
bool RS_Entity::isUndone() const {
if (!parent) {
return RS_Undoable::isUndone();
}
else {
return RS_Undoable::isUndone() || parent->isUndone();
}
}
注意那个 || parent->isUndone()------只要父容器被撤销了,子实体就算没被标记,也一样不可见。
这解决了容器类实体的连带问题。比如一个图块引用(后面我们会专门讲块),它本身就是一个容器,里面装着很多实体。撤销这个图块时,只需要把图块自己标记成 undone,里面的所有实体就自动"隐身"了,不需要挨个标记。

3.2、撤掉之后,实体还要做什么
光把标志位翻转还不够。我们在 RS_Entity 里还能看到一个被重写的钩子函数:
cpp
/**
* Called when the undo state changed.
*
* @param undone true: entity has become invisible.
* false: entity has become visible.
*/
void RS_Entity::undoStateChanged(bool undone)
{
Q_UNUSED( undone);
setSelected(false);
update();
}
两件事:
第一,setSelected(false)。撤销一条已经被选中的线,要顺手把它从选择集里踢出去,否则就会出现"一条看不见的线还处于选中状态"这种尴尬情况。
第二,update()。这是个虚函数,默认是空的,注释写着 "Can be implemented by child classes to update the entities temporary subentities"。最典型的实现者就是 RS_Insert(图块引用)------它需要根据块定义重新生成自己的子实体。所以恢复一条图块引用时,靠的就是这个 update()。
可见性、选择状态、子实体重建,三件事合在一起,才是一次完整的"撤销"。
四、撤销三步曲:startUndoCycle / addUndoable / endUndoCycle
现在我们可以回到 trigger() 里那三行了。它们为什么必须是三行,而不是一个 addUndoable() 就够?
4.1、startUndoCycle:开一个新的"周期"
cpp
void RS_Undo::startUndoCycle()
{
if (1 < ++refCount) {
// only the first fresh top call starts a new cycle
return;
}
size_t removePointer {static_cast<size_t>(undoPointer + 1)};
// if there are undo cycles behind undoPointer
// remove obsolete entities and undoCycles
if (undoList.size() > removePointer) {
// ... 收集并删除 undoPointer 之后的实体和周期 ...
}
// alloc new undoCycle
currentCycle = std::make_shared<RS_UndoCycle>();
}
这里有两个设计点值得单独讲。
第一个是 refCount。 开头的 if (1 < ++refCount) return; 意味着:如果有人在一次撤销周期里又嵌套调用了一次 startUndoCycle(),那么只有最外层那次会真正新建周期,内层的调用会被"吸收"掉。相应地,endUndoCycle() 也要等到最外层结束才真正收尾。
这个设计是为了让"组合操作"只产生一步撤销。比如某个操作内部又调用了另一个也会登记撤销的函数,用户按一次 Ctrl+Z,不应该只撤销一半。
第二个是那段"清理 obsolete"的代码。 它做的事情是:如果游标后面还有内容(也就是之前撤销过、现在正处于"可重做"状态的周期),那就把这些周期连同其中的实体一起清掉。
这个逻辑其实大家在文本编辑器里天天见:你先撤销了几步,然后又开始输入新内容,那么原来的重做分支就没了。 LibreCAD 的撤销栈遵循完全一样的语义。
而"清掉"具体是怎么清的呢?注意循环里调用的那个函数:
cpp
for (auto it = obsolete.begin(); it != obsolete.end(); ++it) {
if (keep.end() == std::find( keep.begin(), keep.end(), *it)) {
removeUndoable( *it);
}
}
removeUndoable() 在 RS_Undo 里是个纯虚函数,由子类实现:
cpp
/**
* Must be overwritten by the implementing class and delete
* the given Undoable (unrecoverable). This method is called
* for Undoables that are no longer in the undo buffer.
*/
virtual void removeUndoable(RS_Undoable* u) = 0;
注释里那个词很扎眼:unrecoverable(不可恢复)。
而 RS_Document 的实现是这样的:
cpp
virtual void removeUndoable(RS_Undoable* u) {
if (u && u->undoRtti()==RS2::UndoableEntity && u->isUndone()) {
removeEntity(static_cast<RS_Entity*>(u));
}
}
注意判断条件里的 u->isUndone():只有已经被标记为"已撤销"的实体才会被真正删除。也就是说,实体被"撤销"的时候只是隐身,等到重做分支被丢弃的那一刻,才真正从内存里消失。
到这里我们就能总结出这套撤销机制的两级设计了:
日常撤销 = 翻转标志位,代价极小,随时可以恢复。
丢弃重做分支 = 真正删除数据,不可恢复。
4.2、addUndoable:往周期里塞东西
cpp
void RS_Undo::addUndoable(RS_Undoable* u) {
if( nullptr == currentCycle) {
RS_DEBUG->print( RS_Debug::D_CRITICAL, "RS_Undo::%s(): invalid currentCycle, possibly missing startUndoCycle()", __func__);
return;
}
currentCycle->addUndoable(u);
}
这个函数短得有点可爱,但那条 D_CRITICAL 级别的日志很重要。它其实是在提醒开发者:如果你没先调用 startUndoCycle(),那么这次 addUndoable() 会被静默丢弃,只在调试输出里留一条严重日志。
这也是为什么 trigger() 里那三行必须是三行------少一行,撤销就会莫名其妙地失效。
4.3、endUndoCycle:收尾与"文件被修改"
cpp
void RS_Undo::endUndoCycle()
{
if (0 < refCount) {
// compensate nested calls of start-/endUndoCycle()
if( 0 < --refCount) {
// not the final nested call, nothing to do yet
return;
}
}
else {
RS_DEBUG->print( RS_Debug::D_WARNING, "Warning: RS_Undo::endUndoCycle() called without previous startUndoCycle() %d", refCount);
return;
}
if (hasUndoable()) {
// only keep the undoCycle, when it contains undoables
addUndoCycle(currentCycle);
}
setGUIButtons();
currentCycle = nullptr; // invalidate currentCycle for next startUndoCycle()
}
几个细节:
只有周期里真的装了东西,才会被压进撤销栈。 一个空周期是不留痕迹的------所以你画线画到一半取消,不会在撤销栈里留下一个"空操作"。
收尾之后会调用 setGUIButtons(),也就是刷新界面上的撤销/重做按钮状态。我们在第八节会看到它的实现。
currentCycle 被置空 ,防止下一次忘了 startUndoCycle() 时误用旧周期。
另外,RS_Document 还重写了这个函数,加了一件事:
cpp
/**
* Overwritten to set modified flag when undo cycle finished with undoable(s).
*/
void RS_Document::endUndoCycle()
{
if (hasUndoable()) {
setModified(true);
}
RS_Undo::endUndoCycle();
}
这就是**"文件被修改"标记的来源**。你画一条线,标题栏上就出现了那个表示未保存的小星号------它就是在这里被设置的。看到这个细节,应该会有种"原来在这儿"的感觉。
五、RS_UndoCycle:为什么一次撤销能撤销一整组
前面反复提到"撤销周期"这个词,现在我们来看看它是什么:
cpp
class RS_UndoCycle {
void addUndoable(RS_Undoable* u);
void removeUndoable(RS_Undoable* u);
void changeUndoState();
// ...
};
它的实现同样简单:
cpp
/**
* Adds an Undoable to this Undo Cycle. Every Cycle can contain one or
* more Undoables.
*/
void RS_UndoCycle::addUndoable(RS_Undoable* u) {
if (!u)
return;
undoables.insert(u);
}
void RS_UndoCycle::changeUndoState()
{
for (RS_Undoable* u: undoables)
u->changeUndoState();
}
关键就在 changeUndoState() 里的那个循环:周期里所有实体的状态会被一起翻转。
这就是为什么"一次撤销"可以撤销一整组操作。举个典型的例子:你做了一个"移动"操作,移动了一百条线。这一百条线的位移在用户看来是"一步操作",那么它们就应该在同一个撤销周期里。用户按一次 Ctrl+Z,一百条线一起回到原位。
如果每个实体单独占一个周期,那用户就得按一百次 Ctrl+Z。所以周期的划分,本质上就是"向用户承诺的撤销粒度"------这是设计撤销系统时最需要拿捏的地方。

六、第二套 History:Action 内部的交互历史
文档级撤销讲完了,现在回到开头那个问题:pPoints->history 又是什么?
6.1、四个 HistoryAction
我们回到 RS_ActionDrawLine,它内部定义了一整套自己的历史:
cpp
/// History Actions
enum HistoryAction {
HA_SetStartpoint, ///< Setting the startpoint
HA_SetEndpoint, ///< Setting the endpoint
HA_Close, ///< Close group of lines
HA_Next ///< Start new group of lines
};
四个取值,把注释翻译过来就是:
HA_SetStartpoint:设置了起点;
HA_SetEndpoint:设置了终点;
HA_Close:闭合这组线;
HA_Next:开始新的一组线。
看到后两个,性质就很清楚了:这套 History 记录的不是"文档发生了什么",而是"用户操作到了哪一步"。 它服务于画线工具内的"回退一步""闭合""断开重新开始"这几个交互动作。
6.2、addHistory:和文本编辑器的撤销缓冲一个道理
cpp
void RS_ActionDrawLine::addHistory(RS_ActionDrawLine::HistoryAction a, const RS_Vector& p, const RS_Vector& c, const int s)
{
if (pPoints->historyIndex < -1) {
pPoints->historyIndex = -1;
}
pPoints->history.erase(pPoints->history.begin() + pPoints->historyIndex + 1, pPoints->history.end());
pPoints->history.push_back( History( a, p, c, s));
pPoints->historyIndex = static_cast<int>(pPoints->history.size() - 1);
}
中间那句 erase 是不是特别眼熟?它就是"把游标后面的内容全删掉,再压入新的记录"------和文本编辑器里撤销之后再输入新字符的处理方式一模一样 ,也和前面 startUndoCycle() 里清理重做分支的逻辑如出一辙。
两套 History 虽然服务于不同层面,但底层的"撤销栈"思想是共通的。
6.3、Action 自己的 undo 与 redo
这套历史怎么用?看 undo():
cpp
void RS_ActionDrawLine::undo()
{
if (0 <= pPoints->historyIndex) {
History h( pPoints->history.at( pPoints->index()));
--pPoints->historyIndex;
deletePreview();
graphicView->moveRelativeZero(h.prevPt);
switch (h.histAct) {
case HA_SetStartpoint:
setStatus(SetStartpoint);
break;
case HA_SetEndpoint:
case HA_Close:
graphicView->setCurrentAction( new RS_ActionEditUndo(true, *container, *graphicView));
pPoints->data.startpoint = h.prevPt;
setStatus(SetEndpoint);
break;
case HA_Next:
pPoints->data.startpoint = h.prevPt;
setStatus(SetEndpoint);
break;
}
// ...
}
}
这段代码信息量很大,我们一层一层看:
它先取出当前那条历史记录,把游标往回退一格,删掉预览,再把"相对零点"移回上一个点;
然后根据历史的类型,把画线工具的状态退回到对应的阶段------这才是"回退一步"在交互层面的真实含义;
最后还要从新的历史位置重新取一次 startOffset,因为连续线段的分组偏移量变了。
6.4、那一行关键的代码
请注意 HA_SetEndpoint 和 HA_Close 这两个分支里的这一行:
cpp
graphicView->setCurrentAction( new RS_ActionEditUndo(true, *container, *graphicView));
它在这里创建了一个"撤销"的 Action。
RS_ActionEditUndo 正是 Ctrl+Z 走的那个类,构造函数的第一个参数 true 表示执行 undo,false 表示 redo。
也就是说:当用户在画线过程中执行"回退一步",Action 的内部历史只负责把交互状态 退回去,而那条已经画到图纸上的线,是由 RS_ActionEditUndo 通过文档级撤销真正收回去的。
这就是两套 History 的交汇点。
七、两套 History 到底怎么配合
到这里,我们可以把整个配合关系理清楚了。
在画线过程中,用户能触发的"回退"类操作有两处。
第一处是命令行。 RS_ActionDrawLine::commandEvent() 里监听了三个命令:
cpp
case SetEndpoint:
if (checkCommand( "close", c)) {
close();
e->accept();
updateMouseButtonHints();
return;
}
if (checkCommand( "undo", c)) {
undo();
e->accept();
updateMouseButtonHints();
return;
}
break;
}
if (checkCommand( "redo", c)) {
redo();
e->accept();
updateMouseButtonHints();
}
第二处是命令提示。 getAvailableCommands() 会根据当前状态,动态告诉用户"现在能输哪些命令":
cpp
QStringList RS_ActionDrawLine::getAvailableCommands()
{
QStringList cmd;
if (pPoints->index() + 1 < pPoints->history.size()) {
cmd += command("redo");
}
switch (getStatus()) {
case SetStartpoint:
break;
case SetEndpoint:
if (pPoints->historyIndex >= 1) {
cmd += command("undo");
}
// ...
注意第一个判断:只有在"历史里还有可以重做的东西"时,redo 才会出现在可用命令里。这跟文本编辑器里"重做按钮只有在撤销过之后才亮起"是完全一样的逻辑,只不过 LibreCAD 把它做成了命令提示。

所以我们可以把开头的结论说得更准确一点了:
pPoints->history 管的是"用户操作走到哪一步了",它决定回退之后画线工具应该处于什么状态;RS_Undo 管的是"文档被改成了什么样",它决定哪条线该不该出现在图纸上。
两者分工明确,但在"撤销一条已经画出来的线"这个动作上,前者会调用后者------交互回退负责导演,文档撤销负责执行。
顺便说一句,这也解释了为什么在画线过程中按右键回退时,工具不会退出,只是退回到上一个点:因为整个过程的目的是"继续画",而不是"撤销这次操作"。
八、Ctrl+Z 的完整链路
最后我们把 Ctrl+Z 这条路也走一遍,看看它和上面有什么不同。
8.1、从菜单到 Action
和第四篇讲过的流程一样,Ctrl+Z 也是通过 Action 体系走的。QG_ActionHandler 里对应的分支是这样的:
cpp
case RS2::ActionEditUndo:
a = new RS_ActionEditUndo(true, *document, *view);
break;
case RS2::ActionEditRedo:
a = new RS_ActionEditUndo(false, *document, *view);
break;
注意这里:撤销和重做用的是同一个类 ,只是构造函数的参数不同。这又是一个"一个类两种模式"的典型写法,和 zoomIn / zoomOut 的关系很像。
8.2、RS_ActionEditUndo 的实现
这个 Action 短得惊人:
cpp
void RS_ActionEditUndo::init(int status) {
RS_ActionInterface::init(status);
trigger();
}
void RS_ActionEditUndo::trigger()
{
if (!graphic)
{
qWarning("undo: graphic is null");
return;
}
if (undo) {
if(!document->undo())
RS_DIALOGFACTORY->commandMessage(tr("Nothing to undo!"));
} else {
if(!document->redo())
RS_DIALOGFACTORY->commandMessage(tr("Nothing to redo!"));
}
graphic->addBlockNotification();
graphic->setModified(true);
document->updateInserts();
graphicView->redraw(RS2::RedrawDrawing);
finish(false);
RS_DIALOGFACTORY->updateSelectionWidget(container->countSelected(),
container->totalSelectedLength());
}
先看 init():它调用父类的 init() 之后立刻调用了 trigger()。
这是一个很有意思的写法。撤销这种操作是"瞬时"的------它不需要用户再点第二个点,也不需要采集任何参数,一次点击就等于一次完成。所以它把"启动"和"完成"塞在了一起。
再提醒一下上一篇文章的结论:finish(false) 那个参数是 updateTB,传 false 表示不去更新工具栏状态------因为这类 Action 从来不需要"当前工具按钮保持按下"这种状态。
8.3、撤销之后要做的五件事
trigger() 里除去撤销本身,还做了五件收尾工作,值得逐一看看:
graphic->addBlockNotification():通知图块表结构发生了变更;
graphic->setModified(true):把文件标记为"已修改",标题栏上的星号又来了;
document->updateInserts():让所有图块引用重新更新------因为被撤销的可能正是某个图块,它的子实体需要重新生成(呼应第三节那个 update());
graphicView->redraw(RS2::RedrawDrawing):请求重绘图纸层。这里用的是 RedrawDrawing 而不是 RedrawAll,因为网格和叠加层都没变,没必要一起重画;
RS_DIALOGFACTORY->updateSelectionWidget(...):刷新选中信息面板。因为撤销可能会把选中的实体藏起来,面板上的"已选中 N 个实体"就得跟着变。
一次看起来简单的撤销,背后要通知这么多模块。 这也从侧面说明了为什么撤销要"打标记"而不是"删数据"------真要涉及删除、重建、通知,代价会大得多。
8.4、撤销按钮为什么会自己变灰
还记得前面 undo() / redo() 里都调用了一个 setGUIButtons() 吗?它的实现是这样的:
cpp
void RS_Undo::setGUIButtons() const
{
auto appWin = QC_ApplicationWindow::getAppWindow();
if (!appWin) return;
appWin->setRedoEnable(undoList.size() > 0 &&
undoPointer+1 < int(undoList.size()));
appWin->setUndoEnable(undoList.size() > 0 && undoPointer >= 0);
}
两个判断条件正好对应我们第二节那张图:
撤销按钮能不能点,取决于游标是不是还在有效范围内 (undoPointer >= 0);
重做按钮能不能点,取决于游标后面还有没有内容 (undoPointer + 1 < undoList.size())。
撤销栈空了,撤销按钮就变灰;撤销到最后一步、后面没有可重做的东西了,重做按钮就变灰。这就是界面上那些按钮状态的来源。
顺带说一句,这里通过 QC_ApplicationWindow::getAppWindow() 拿到主窗口,是个全局访问点。好处是 RS_Undo 不用持有界面指针,坏处是把底层库和界面层耦合在了一起。这算是这个工程里少数不太"干净"的地方,读源码时能注意到这类取舍,也是一种收获。
总结
这一篇我们从一段看着很像的代码出发,拆开了 LibreCAD 里的两套 History。现在可以把结论整理成一张表:
| 对比项 | 文档级撤销(RS_Undo) | Action 内部历史(pPoints->history) |
|---|---|---|
| 归属 | RS_Document(继承 RS_Undo) |
RS_ActionDrawLine 自己的 Points 结构 |
| 记录内容 | 文档发生了什么变更 | 用户交互走到哪一步 |
| 单位 | RS_UndoCycle(可以包含多个实体) |
一条 History 记录 |
| 触发方式 | Ctrl+Z / Ctrl+Y、undo、redo 命令 |
画线过程中的回退、闭合、断开 |
| 数据是否还在 | 在,只翻转 FlagUndone 标志位 |
记录的是坐标与状态,不对应实体 |
| 何时真正删除 | 丢弃重做分支时,由 removeUndoable() 物理删除 |
不涉及 |
几个关键认知再强调一遍:
第一,撤销不是删除,是打标记。 changeUndoState() 翻转 FlagUndone,isVisible() 里检查这个标记,实体就"隐身"了。只有当重做分支被丢弃时,才真正删除数据,而那个函数的名字就叫 removeUndoable,注释里写着 unrecoverable。
第二,撤销是按周期分组的。 一个 RS_UndoCycle 里可以放很多实体,changeUndoState() 会一起翻转它们。这个分组决定了"用户按一次 Ctrl+Z 会退多少",是撤销系统里最需要拿捏的设计取舍。
第三,两套 History 不是一回事,但会互相调用。 Action 内部历史负责把交互状态退回去,真正把线收回去靠的是 RS_ActionEditUndo 和文档撤销。看到 graphicView->setCurrentAction( new RS_ActionEditUndo(true, ...)) 那一行的时候,这两套机制就正式握手了。
第四,一个看似简单的操作,背后要通知很多模块。 撤销一次,要处理可见性、选择状态、图块更新、重绘、界面按钮、修改标记。这也让我们更能理解为什么 LibreCAD 要把"文档撤销"和"交互历史"分成两层------如果混在一起,复杂度会迅速失控。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど
