【LibreCAD 2D架构】从 addHistory 到 RS_UndoCycle:LibreCAD 里的两套 History 与撤销机制

🫧 励志不掉头发的内向程序员个人主页
✨️ 个人专栏: 《C++语言》《Linux学习》

🌅偶尔悲伤,偶尔被幸福所完善


👓️博主简介:

文章目录


前言

上一篇文章的最后,我们留下了一个问题。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_SetEndpointHA_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、undoredo 命令 画线过程中的回退、闭合、断开
数据是否还在 在,只翻转 FlagUndone 标志位 记录的是坐标与状态,不对应实体
何时真正删除 丢弃重做分支时,由 removeUndoable() 物理删除 不涉及

几个关键认知再强调一遍:

第一,撤销不是删除,是打标记。 changeUndoState() 翻转 FlagUndoneisVisible() 里检查这个标记,实体就"隐身"了。只有当重做分支被丢弃时,才真正删除数据,而那个函数的名字就叫 removeUndoable,注释里写着 unrecoverable。

第二,撤销是按周期分组的。 一个 RS_UndoCycle 里可以放很多实体,changeUndoState() 会一起翻转它们。这个分组决定了"用户按一次 Ctrl+Z 会退多少",是撤销系统里最需要拿捏的设计取舍。

第三,两套 History 不是一回事,但会互相调用。 Action 内部历史负责把交互状态退回去,真正把线收回去靠的是 RS_ActionEditUndo 和文档撤销。看到 graphicView->setCurrentAction( new RS_ActionEditUndo(true, ...)) 那一行的时候,这两套机制就正式握手了。

第四,一个看似简单的操作,背后要通知很多模块。 撤销一次,要处理可见性、选择状态、图块更新、重绘、界面按钮、修改标记。这也让我们更能理解为什么 LibreCAD 要把"文档撤销"和"交互历史"分成两层------如果混在一起,复杂度会迅速失控。


🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど

相关推荐
Escalating_xu2 小时前
【mmap 进程间通信】从共享内存到跨进程唤醒:用互斥锁、条件变量控制多个进程
java·linux·开发语言·jvm
`流年づ2 小时前
人工智能学习笔记 - 自动微分
人工智能·笔记·学习
走到天涯海角2 小时前
pinia和Vuex的区别以及使用pinia
开发语言·前端·javascript
泡海椒2 小时前
脚本包导入实战:JQuick-Java自定义包引入与别名配置教程
java·开发语言·python
fbbqt2 小时前
Python模块与包
开发语言·python
elseif1232 小时前
【双指针/二分】P1102 A-B 数对
c++·算法·二分·双指针
雪落漂泊3 小时前
C++智能指针
开发语言·jvm·c++
那年窗外下的雪.3 小时前
AIDC 学习日志|第 24 天|设备输出反推与 MAC Flapping 定位
网络协议·学习·tcp/ip·http·macos·tcpdump
minglie13 小时前
espidf驱动SPI ST7735S LCD
学习