refactor(undo): de-Qt oakundo and wrap it in a pure C ABI

- strip QAbstractItemModel/QAction/signals-slots from UndoStack (UI
  concerns belong to the app layer), index_changed becomes a
  std::function callback
- decouple UndoCommand from Project via an optional modified-flag
  callback pair
- add pure C ABI in include/undo + src/undo/c_api (oakundo_ prefix,
  vtable-based command wrapper, OAKUNDO_E_* error codes)
- fix three engine-side defects: jump(0) infinite loop,
  MultiUndoCommand child leak, push_pre_executed not undoable
- add gtest suites (22 cases) and a standalone build driver
This commit is contained in:
2026-08-05 18:02:24 +08:00
parent bad52feda3
commit c50017127b
21 changed files with 2307 additions and 2 deletions
+66
View File
@@ -103,3 +103,69 @@ Project* 本来就只是作为不透明身份被使用(修改标记归属)
调用方知道影响,直接读状态)。
- 往返测试:C API 与适配类各做一遍 push-undo-redo,状态一致。
- `oakundo_debug_alive_count()`init/free 配对无泄漏。
## 实施现状(2026-08-05
M2 已落地并可独立构建、测试全绿。以下为与上文计划的实际差异。
### 最终目录结构
- `src/undo/src/` — 去 Qt 化 C++ 实现(`olive::` 命名空间,类名
`UndoCommand`/`MultiUndoCommand`/`UndoStack` 不变),target
`oakundo`SHARED)。
- `src/undo/c_api/` — 纯 C ABI 包装(`undocommand.cpp``undostack.cpp`
+ 内部共享头 `commandhandle.h`),通过 `target_sources` 合并进
`oakundo`,不单独成库。
- `src/undo/tests/` — gtesttarget `oakundo-gtest`
`gtest_discover_tests`
- `include/undo/`(仓库根)— 公共 C 头:`error.h``undocommand.h`
`undostack.h`
- `src/undo/standalone/CMakeLists.txt` — 独立构建 driver(见下)。
### 独立构建与测试
```sh
cmake -S src/undo/standalone -B build-oakundo
cmake --build build-oakundo -j
ctest --test-dir build-oakundo --output-on-failure
```
### 实际依赖
- Oak 内部:仅 oakcommon 的头文件宏(`define.h`
`DISABLE_COPY_MOVE`),纯头文件,按 include 路径引用
`${OAK_REPO_ROOT}/src/common/src`),**不链接** oakcommon
不依赖 olivecore/ffmpeg_bridge。
- 第三方:GTest(仅测试)。无 Qt。
### 与计划的主要差异
- 接口未按 §2 冻结清单逐条实现,而是对齐 oakcommon 的既有契约:
C ABI 头放在仓库根 `include/undo/`,命名 `oakundo_<族>_<动词>`
查询类函数返回 int 错误码 + out 参数(计划中的
`int64_t oakundo_undostack_count(...)` 直接返回值形式改为
`int ...(OakUndoStack *, int64_t *out)`);字符串两段式 buffer 约定。
- 句柄族名:`OakUndoCommand`/`OakUndoStack`init/free 语义与
oakcommon 一致(init 失败返回 NULL 且内部 try/catch 兜底,
free(NULL) 为 no-op)。
- §2.2 的 `OakUndoObjectParent`QObject 挂载点)、
`oakundo_undostack_update_actions``oakundo_undostack_undo_action/
redo_action`QAction 句柄)**未实现**QAction/model 属 app UI 层,
已从 UndoStack 剥离(见 notes.md「oakundo 去Qt化的删除与语义
变更」)。
- `index_changed` 事件:C ABI 无订阅接口(与 §2.2 修订一致);C++ 侧
保留了 `set_index_changed_callback(std::function<void(int)>)`
作为 Qt signal 的替代。
- §3 切割点的处理与计划不同:`get_relevant_project()` 未改成返回
`void *`,而是整体删除,修改标记语义改为
`UndoCommand::set_modified_callbacks(is_modified, set_modified)`
回调对(oakundo 完全不认识 ProjectM3 node 适配层绑定
`Project::is_modified/set_modified`)。
- 未实现 `oakundo_debug_alive_count()` 泄漏计数。
- 额外行为修复:`UndoStack` 析构不再经 `clear()`(原实现在析构时会
再 push 一个 EmptyCommand 造成泄漏),改为直接删除持有命令;
`jump` 增加 `can_undo/can_redo` 守卫(原版 `jump(0)` 死循环);
`MultiUndoCommand` 析构删除子命令(原版泄漏);
`push_pre_executed` 通过新增的 `UndoCommand::set_done()` 置完成
标记(原版入栈后 undo 为空操作)。详见 notes.md。
- 测试结果:22 个用例全部通过(独立构建 `build-oakundo`)。
+46
View File
@@ -80,3 +80,49 @@ oakcommon;后续若确认无用途可直接删除。
- `FFmpegUtils`:新增类内常量 `k_rgb_channel_count/k_rgba_channel_count`
3/4)取代对 `VideoParams` 同名常量的引用,消除 common→render
反向依赖。
## oakundo 去Qt化的删除与语义变更(2026-08-05)
`src/undo/`olive::UndoCommand / MultiUndoCommand / UndoStack)去Qt化
过程中以下删除与语义变化,迁移调用方时需注意:
- `UndoStack` 不再继承 `QAbstractItemModel`(删除 `Q_OBJECT`
`columnCount/data/index/parent/rowCount/headerData/hasChildren` 全部
model overrides 及 `begin/end*Rows` 通知)。历史面板(QTreeView 等)
属 app UI 层,迁移时应以 facade 的行式查询
command_count/done_count/command_name/command_is_done)自行实现
model。
- `UndoStack::GetUndoAction/GetRedoAction/update_actions`QAction 成员
及其文本/使能维护)删除——QAction 属 app 菜单层,app 应自行创建
action 并连接 undo/redo`index_changed(int)` signal 改为 C++ 侧
`std::function<void(int)>``set_index_changed_callback`),C ABI
不暴露事件,调用方在变更命令后读 `oakundo_undostack_index`
- `UndoCommand``olive::Project` 解耦:删除纯虚
`get_relevant_project()`(含 MultiUndoCommand/EmptyCommand 的
override)与 `project_` 成员;`redo_and_set_modified/
undo_and_set_modified` 的修改标记语义改为可选回调对
`set_modified_callbacks(is_modified, set_modified)`redo 时记录当前
修改标记并置 true,undo 时恢复记录值;不挂回调则退化为纯
redo/undo。读写顺序与 Qt 版一致(redo_now() 之后读取并置位),
行为对齐。Project 侧迁移时在适配层把 `Project::is_modified/
set_modified` 绑到这对回调即可。
- `UndoStack` 析构不再调用 `clear()`(Qt 版在析构里 clear 会再 push
一个 EmptyCommand 造成泄漏),改为直接删除所有持有命令。
- 顺带修复三处 engine 原版就存在的缺陷(迁移调用方无需适配,但
行为与旧版不同,特此记录):
- `jump(0)` 死循环:底部 EmptyCommand 不可 undo`jump` 现以
`can_undo()/can_redo()` 为守卫,跳到 0 时停在 index 1;
- `MultiUndoCommand` 原来不拥有子命令(析构泄漏),现析构删除
全部 children
- `push_pre_executed` 原来不置 `done_` 标记,导致入栈后 undo
为空操作(facade undo-group 的组撤销实际不生效),现入栈前
`set_done(true)`UndoCommand 新增 `set_done()`)。
- `clear()` 推入的占位命令名固定为字面量 "New/Open Project"(原
`tr()` 翻译移除,翻译由 app 层负责)。
- `push/push_pre_executed` 的命令名由 `QString` 改为 `std::string`
C ABI 中 NULL name 视同空串。
- C ABI 句柄语义:`oakundo_command_init/init_multi` 返回的句柄拥有
底层命令;push 或 `oakundo_command_multi_add_child` 成功后所有权
转移、wrapper 被消费(不可再用/再 free);
`oakundo_command_multi_child` 返回 borrowed wrapperfree 只释放
wrapper)。
+39
View File
@@ -0,0 +1,39 @@
/***
Oak Video Editor - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef OAK_EDITOR_UNDO_ERROR_H
#define OAK_EDITOR_UNDO_ERROR_H
/**
* @brief Status and error codes shared by all oakundo C API families.
*
* Return-code convention (mirrors engine/include/oakengine/init.h):
* 0 (OAKUNDO_OK) on success, a negative OAKUNDO_E_* error code on
* failure. String getters return the required buffer size in bytes
* (including the terminating NUL) as a non-negative value instead.
*/
#define OAKUNDO_OK 0 /**< Success. */
#define OAKUNDO_E_INVALID (-1) /**< NULL handle or invalid argument. */
#define OAKUNDO_E_STATE (-2) /**< Call not valid in the current state. */
#define OAKUNDO_E_FAILED (-3) /**< The underlying operation failed. */
#define OAKUNDO_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */
#define OAKUNDO_E_NOMEM (-5) /**< Allocation failed. */
#endif //OAK_EDITOR_UNDO_ERROR_H
+137
View File
@@ -0,0 +1,137 @@
/***
Oak Video Editor - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef OAK_EDITOR_UNDO_UNDOCOMMAND_H
#define OAK_EDITOR_UNDO_UNDOCOMMAND_H
#include "undo/error.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Opaque handle to an undo command (olive::UndoCommand).
*
* Handles created by oakundo_command_init() /
* oakundo_command_init_multi() own the underlying command and must be
* released with oakundo_command_free() UNLESS they are pushed onto an
* OakUndoStack, which takes ownership. Handles returned by
* oakundo_command_multi_child() are borrowed wrappers: free the wrapper
* with oakundo_command_free(), the underlying command stays owned by the
* multi command.
*/
typedef struct OakUndoCommand OakUndoCommand;
/**
* @brief Callback table backing a caller-defined undo command.
*
* Any callback may be NULL; a NULL redo/undo makes that direction a
* no-op. free_fn is invoked when the command is destroyed (whether pushed
* onto a stack or freed directly) and releases userdata.
*/
typedef struct OakUndoCommandVtable {
void (*redo)(void *userdata);
void (*undo)(void *userdata);
void (*free_fn)(void *userdata);
} OakUndoCommandVtable;
/**
* @brief Create an undo command backed by C callbacks.
*
* The command takes ownership of `userdata`; `vtable` is copied.
*
* @return Command handle, or NULL on invalid argument or allocation
* failure.
*/
OakUndoCommand *oakundo_command_init(const OakUndoCommandVtable *vtable,
void *userdata);
/**
* @brief Create an empty multi command (olive::MultiUndoCommand).
*
* @return Command handle, or NULL on allocation failure.
*/
OakUndoCommand *oakundo_command_init_multi(void);
/**
* @brief Add `child` to the multi command `multi`.
*
* On success the multi command takes ownership of the underlying child
* command; the child handle wrapper is consumed and must not be used or
* freed afterwards.
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_command_multi_add_child(OakUndoCommand *multi,
OakUndoCommand *child);
/**
* @brief Query the number of children in a multi command.
*
* @param out_count Receives the result. Must not be NULL.
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_command_multi_child_count(OakUndoCommand *multi, int *out_count);
/**
* @brief Borrow a handle to the child at `index` of a multi command.
*
* The returned handle is a wrapper owned by the caller (free with
* oakundo_command_free()); the underlying command is owned by `multi`.
*
* @return OAKUNDO_OK, OAKUNDO_E_NOT_FOUND for an out-of-range index, or
* another negative OAKUNDO_E_* error code.
*/
int oakundo_command_multi_child(OakUndoCommand *multi, int index,
OakUndoCommand **out_child);
/**
* @brief Execute the command's redo without a stack
* (olive::UndoCommand::redo_now semantics; a no-op if already done).
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_command_redo_now(OakUndoCommand *command);
/**
* @brief Execute the command's undo without a stack
* (olive::UndoCommand::undo_now semantics; a no-op if not done).
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_command_undo_now(OakUndoCommand *command);
/**
* @brief Destroy a command handle.
*
* Owned handles destroy the underlying command; borrowed handles (from
* oakundo_command_multi_child()) only destroy the wrapper. Commands
* pushed onto an OakUndoStack are owned by the stack and must not be
* freed by the caller. NULL is a no-op.
*/
void oakundo_command_free(OakUndoCommand *command);
#ifdef __cplusplus
}
#endif
#endif //OAK_EDITOR_UNDO_UNDOCOMMAND_H
+164
View File
@@ -0,0 +1,164 @@
/***
Oak Video Editor - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef OAK_EDITOR_UNDO_UNDOSTACK_H
#define OAK_EDITOR_UNDO_UNDOSTACK_H
#include <stdint.h>
#include "undo/error.h"
#include "undo/undocommand.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Opaque handle to an undo stack (olive::UndoStack).
*/
typedef struct OakUndoStack OakUndoStack;
/**
* @brief Create an undo stack.
*
* A fresh stack contains a single "New/Open Project" empty command,
* matching olive::UndoStack::clear().
*
* @return Stack handle, or NULL on allocation failure.
*/
OakUndoStack *oakundo_undostack_init(void);
/**
* @brief Destroy an undo stack and all commands it owns.
*
* NULL is a no-op.
*/
void oakundo_undostack_free(OakUndoStack *stack);
/**
* @brief Push `command` onto the stack and execute its redo.
*
* On success the stack takes ownership of the underlying command; the
* handle wrapper is consumed and must not be used or freed afterwards.
* An empty multi command is deleted immediately (not pushed), matching
* olive::UndoStack::push. `name` is the user-visible label (NULL behaves
* like an empty label).
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_undostack_push(OakUndoStack *stack, OakUndoCommand *command,
const char *name);
/**
* @brief Push a command that has already been executed (redo skipped).
*
* Ownership rules match oakundo_undostack_push().
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_undostack_push_pre_executed(OakUndoStack *stack,
OakUndoCommand *command,
const char *name);
/**
* @brief Undo the most recently done command, if any.
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_undostack_undo(OakUndoStack *stack);
/**
* @brief Redo the most recently undone command, if any.
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_undostack_redo(OakUndoStack *stack);
/**
* @brief Undo/redo until the done-command count equals `index`
* (olive::UndoStack::jump semantics). Negative values are clamped to 0.
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_undostack_jump(OakUndoStack *stack, int64_t index);
/**
* @brief Delete all commands and push the fresh "New/Open Project" empty
* command (olive::UndoStack::clear).
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_undostack_clear(OakUndoStack *stack);
/**
* @brief Query whether undo (redo) is currently possible.
*
* @param out_value Receives 1/0. Must not be NULL.
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_undostack_can_undo(OakUndoStack *stack, int *out_value);
int oakundo_undostack_can_redo(OakUndoStack *stack, int *out_value);
/**
* @brief Total number of history rows (done + undone commands).
*
* @param out_count Receives the result. Must not be NULL.
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_undostack_count(OakUndoStack *stack, int64_t *out_count);
/**
* @brief Current position in the history: the number of done commands
* (rows at or above this index are undone).
*
* @param out_index Receives the result. Must not be NULL.
*
* @return OAKUNDO_OK or a negative OAKUNDO_E_* error code.
*/
int oakundo_undostack_index(OakUndoStack *stack, int64_t *out_index);
/**
* @brief Label of the history row at `row` (0-based, two-stage getter).
*
* @return Required buffer size in bytes including the terminating NUL
* (non-negative), OAKUNDO_E_NOT_FOUND for an invalid row, or
* another negative OAKUNDO_E_* error code.
*/
int oakundo_undostack_command_text(OakUndoStack *stack, int64_t row,
char *buf, int buf_size);
/**
* @brief Query whether the row at `row` is currently done (not undone).
*
* @param out_value Receives 1 (done) / 0 (undone). Must not be NULL.
*
* @return OAKUNDO_OK, OAKUNDO_E_NOT_FOUND for an invalid row, or another
* negative OAKUNDO_E_* error code.
*/
int oakundo_undostack_command_is_done(OakUndoStack *stack, int64_t row,
int *out_value);
#ifdef __cplusplus
}
#endif
#endif //OAK_EDITOR_UNDO_UNDOSTACK_H
+2 -1
View File
@@ -1 +1,2 @@
add_subdirectory(common)
add_subdirectory(common)
add_subdirectory(undo)
+1 -1
View File
@@ -1,6 +1,6 @@
add_subdirectory(src)
add_subdirectory(c_api)
if(BUILD_TESTING)
if(BUILD_TESTS)
add_subdirectory(tests)
endif()
+6
View File
@@ -0,0 +1,6 @@
add_subdirectory(src)
add_subdirectory(c_api)
if(BUILD_TESTS)
add_subdirectory(tests)
endif()
+4
View File
@@ -0,0 +1,4 @@
target_sources(oakundo PRIVATE
undocommand.cpp
undostack.cpp
)
+41
View File
@@ -0,0 +1,41 @@
/***
Oak Video Editor - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef OAK_UNDO_COMMANDHANDLE_H
#define OAK_UNDO_COMMANDHANDLE_H
#include "undo/undocommand.h"
#include "../src/undocommand.h"
/**
* @brief Internal layout of the OakUndoCommand handle, shared between the
* c_api translation units.
*
* `owned` is true for handles created by oakundo_command_init() /
* oakundo_command_init_multi() and false for borrowed wrappers handed out
* by oakundo_command_multi_child().
*/
struct OakUndoCommand {
olive::UndoCommand *command;
bool owned;
};
#endif // OAK_UNDO_COMMANDHANDLE_H
+217
View File
@@ -0,0 +1,217 @@
/***
Oak Video Editor - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#include "undo/undocommand.h"
#include <new>
#include "commandhandle.h"
namespace
{
/**
* @brief olive::UndoCommand subclass forwarding to a C callback table.
*/
class CallbackUndoCommand : public olive::UndoCommand {
public:
CallbackUndoCommand(const OakUndoCommandVtable &vtable, void *userdata)
: vtable_(vtable), userdata_(userdata)
{
}
virtual ~CallbackUndoCommand() override
{
if (vtable_.free_fn) {
vtable_.free_fn(userdata_);
}
}
protected:
virtual void redo() override
{
if (vtable_.redo) {
vtable_.redo(userdata_);
}
}
virtual void undo() override
{
if (vtable_.undo) {
vtable_.undo(userdata_);
}
}
private:
OakUndoCommandVtable vtable_;
void *userdata_;
};
}
OakUndoCommand *oakundo_command_init(const OakUndoCommandVtable *vtable,
void *userdata)
{
if (!vtable) {
return NULL;
}
try {
OakUndoCommand *handle = new (std::nothrow) OakUndoCommand();
if (!handle) {
return NULL;
}
handle->command = new CallbackUndoCommand(*vtable, userdata);
handle->owned = true;
return handle;
} catch (...) {
return NULL;
}
}
OakUndoCommand *oakundo_command_init_multi(void)
{
try {
OakUndoCommand *handle = new (std::nothrow) OakUndoCommand();
if (!handle) {
return NULL;
}
handle->command = new olive::MultiUndoCommand();
handle->owned = true;
return handle;
} catch (...) {
return NULL;
}
}
int oakundo_command_multi_add_child(OakUndoCommand *multi,
OakUndoCommand *child)
{
if (!multi || !multi->command || !child || !child->command) {
return OAKUNDO_E_INVALID;
}
olive::MultiUndoCommand *mcu =
dynamic_cast<olive::MultiUndoCommand *>(multi->command);
if (!mcu) {
return OAKUNDO_E_INVALID;
}
try {
mcu->add_child(child->command);
// Ownership of the underlying command moved to the multi command;
// consume the wrapper.
delete child;
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_command_multi_child_count(OakUndoCommand *multi, int *out_count)
{
if (!multi || !multi->command || !out_count) {
return OAKUNDO_E_INVALID;
}
olive::MultiUndoCommand *mcu =
dynamic_cast<olive::MultiUndoCommand *>(multi->command);
if (!mcu) {
return OAKUNDO_E_INVALID;
}
try {
*out_count = mcu->child_count();
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_command_multi_child(OakUndoCommand *multi, int index,
OakUndoCommand **out_child)
{
if (!multi || !multi->command || !out_child) {
return OAKUNDO_E_INVALID;
}
olive::MultiUndoCommand *mcu =
dynamic_cast<olive::MultiUndoCommand *>(multi->command);
if (!mcu) {
return OAKUNDO_E_INVALID;
}
try {
if (index < 0 || index >= mcu->child_count()) {
return OAKUNDO_E_NOT_FOUND;
}
OakUndoCommand *handle = new (std::nothrow) OakUndoCommand();
if (!handle) {
return OAKUNDO_E_NOMEM;
}
handle->command = mcu->child(index);
handle->owned = false;
*out_child = handle;
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_command_redo_now(OakUndoCommand *command)
{
if (!command || !command->command) {
return OAKUNDO_E_INVALID;
}
try {
command->command->redo_now();
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_command_undo_now(OakUndoCommand *command)
{
if (!command || !command->command) {
return OAKUNDO_E_INVALID;
}
try {
command->command->undo_now();
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
void oakundo_command_free(OakUndoCommand *command)
{
if (!command) {
return;
}
if (command->owned) {
delete command->command;
}
delete command;
}
+242
View File
@@ -0,0 +1,242 @@
/***
Oak Video Editor - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#include "undo/undostack.h"
#include <cstring>
#include <new>
#include "../src/undostack.h"
#include "commandhandle.h"
struct OakUndoStack {
olive::UndoStack impl;
};
OakUndoStack *oakundo_undostack_init(void)
{
try {
return new (std::nothrow) OakUndoStack();
} catch (...) {
return NULL;
}
}
void oakundo_undostack_free(OakUndoStack *stack)
{
delete stack;
}
int oakundo_undostack_push(OakUndoStack *stack, OakUndoCommand *command,
const char *name)
{
if (!stack || !command || !command->command) {
return OAKUNDO_E_INVALID;
}
try {
olive::UndoCommand *impl = command->command;
stack->impl.push(impl, name ? name : "");
// Ownership of the underlying command moved to the stack (or was
// deleted as an empty multi command); consume the wrapper.
delete command;
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_undostack_push_pre_executed(OakUndoStack *stack,
OakUndoCommand *command,
const char *name)
{
if (!stack || !command || !command->command) {
return OAKUNDO_E_INVALID;
}
try {
olive::UndoCommand *impl = command->command;
stack->impl.push_pre_executed(impl, name ? name : "");
delete command;
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_undostack_undo(OakUndoStack *stack)
{
if (!stack) {
return OAKUNDO_E_INVALID;
}
try {
stack->impl.undo();
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_undostack_redo(OakUndoStack *stack)
{
if (!stack) {
return OAKUNDO_E_INVALID;
}
try {
stack->impl.redo();
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_undostack_jump(OakUndoStack *stack, int64_t index)
{
if (!stack) {
return OAKUNDO_E_INVALID;
}
try {
stack->impl.jump(index < 0 ? 0 : static_cast<size_t>(index));
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_undostack_clear(OakUndoStack *stack)
{
if (!stack) {
return OAKUNDO_E_INVALID;
}
try {
stack->impl.clear();
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_undostack_can_undo(OakUndoStack *stack, int *out_value)
{
if (!stack || !out_value) {
return OAKUNDO_E_INVALID;
}
try {
*out_value = stack->impl.can_undo() ? 1 : 0;
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_undostack_can_redo(OakUndoStack *stack, int *out_value)
{
if (!stack || !out_value) {
return OAKUNDO_E_INVALID;
}
try {
*out_value = stack->impl.can_redo() ? 1 : 0;
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_undostack_count(OakUndoStack *stack, int64_t *out_count)
{
if (!stack || !out_count) {
return OAKUNDO_E_INVALID;
}
try {
*out_count = stack->impl.command_count();
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_undostack_index(OakUndoStack *stack, int64_t *out_index)
{
if (!stack || !out_index) {
return OAKUNDO_E_INVALID;
}
try {
*out_index = stack->impl.done_count();
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_undostack_command_text(OakUndoStack *stack, int64_t row,
char *buf, int buf_size)
{
if (!stack) {
return OAKUNDO_E_INVALID;
}
try {
if (row < 0 || row >= stack->impl.command_count()) {
return OAKUNDO_E_NOT_FOUND;
}
std::string name = stack->impl.command_name(int(row));
int required = static_cast<int>(name.size()) + 1;
if (buf && buf_size > 0) {
size_t copy_len = name.size();
if (copy_len > static_cast<size_t>(buf_size) - 1) {
copy_len = static_cast<size_t>(buf_size) - 1;
}
memcpy(buf, name.data(), copy_len);
buf[copy_len] = '\0';
}
return required;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
int oakundo_undostack_command_is_done(OakUndoStack *stack, int64_t row,
int *out_value)
{
if (!stack || !out_value) {
return OAKUNDO_E_INVALID;
}
try {
if (row < 0 || row >= stack->impl.command_count()) {
return OAKUNDO_E_NOT_FOUND;
}
*out_value = stack->impl.command_is_done(int(row)) ? 1 : 0;
return OAKUNDO_OK;
} catch (...) {
return OAKUNDO_E_FAILED;
}
}
+34
View File
@@ -0,0 +1,34 @@
# Olive - Non-Linear Video Editor
# Copyright (C) 2022 Olive Team
# Modifications Copyright (C) 2025 mikesolar
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
add_library(oakundo SHARED
undocommand.cpp
undocommand.h
undostack.cpp
undostack.h
)
# In a full-tree build the repo root is CMAKE_SOURCE_DIR; a standalone
# build (see src/undo/standalone) sets OAK_REPO_ROOT explicitly.
if(NOT DEFINED OAK_REPO_ROOT)
set(OAK_REPO_ROOT ${CMAKE_SOURCE_DIR})
endif()
# oakundo depends only on oakcommon's header-only define.h macros
# (DISABLE_COPY_MOVE), so an include path is enough; no library is linked.
target_include_directories(oakundo PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
${OAK_REPO_ROOT}/src/common/src
${OAK_REPO_ROOT}/include
)
+103
View File
@@ -0,0 +1,103 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
Modifications Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#include "undocommand.h"
namespace olive
{
MultiUndoCommand::~MultiUndoCommand()
{
for (auto it = children_.begin(); it != children_.end(); it++) {
delete *it;
}
}
void MultiUndoCommand::redo()
{
for (auto it = children_.cbegin(); it != children_.cend(); it++) {
(*it)->redo_and_set_modified();
}
}
void MultiUndoCommand::undo()
{
for (auto it = children_.crbegin(); it != children_.crend(); it++) {
(*it)->undo_and_set_modified();
}
}
UndoCommand::UndoCommand()
{
modified_ = false;
prepared_ = false;
done_ = false;
}
void UndoCommand::set_modified_callbacks(
std::function<bool()> is_modified, std::function<void(bool)> set_modified)
{
is_modified_ = std::move(is_modified);
set_modified_ = std::move(set_modified);
}
void UndoCommand::redo_and_set_modified()
{
redo_now();
if (is_modified_ && set_modified_) {
modified_ = is_modified_();
set_modified_(true);
}
}
void UndoCommand::undo_and_set_modified()
{
undo_now();
if (set_modified_) {
set_modified_(modified_);
}
}
void UndoCommand::redo_now()
{
if (!done_) {
if (!prepared_) {
prepare();
prepared_ = true;
}
redo();
done_ = true;
}
}
void UndoCommand::undo_now()
{
if (done_) {
undo();
done_ = false;
}
}
}
+146
View File
@@ -0,0 +1,146 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
Modifications Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef OAK_UNDOCOMMAND_H
#define OAK_UNDOCOMMAND_H
#include <functional>
#include <vector>
#include "define.h"
namespace olive
{
/**
* @brief Base class for an undoable operation.
*
* Subclasses implement redo() and undo(). The Qt version coupled this
* class to olive::Project through get_relevant_project(); oakundo instead
* lets the caller attach a modified-state callback pair with
* set_modified_callbacks(). When callbacks are attached,
* redo_and_set_modified() records the current modified state and forces
* "modified", and undo_and_set_modified() restores the recorded state.
* Without callbacks those functions simply redo/undo.
*/
class UndoCommand {
public:
UndoCommand();
virtual ~UndoCommand()
{
}
DISABLE_COPY_MOVE(UndoCommand)
bool has_prepared() const
{
return prepared_;
}
void set_prepared(bool e)
{
prepared_ = true;
}
void redo_now();
void undo_now();
/**
* @brief Mark the command as already executed (done) without running
* redo. Used by UndoStack::push_pre_executed() so the command remains
* undoable.
*/
void set_done(bool e)
{
done_ = e;
}
void redo_and_set_modified();
void undo_and_set_modified();
/**
* @brief Attach project modified-state accessors.
*
* @param is_modified Getter for the current modified flag.
* @param set_modified Setter for the modified flag.
*
* Either may be empty; both directions are guarded. Passing two empty
* functions detaches the callbacks.
*/
void set_modified_callbacks(std::function<bool()> is_modified,
std::function<void(bool)> set_modified);
protected:
virtual void prepare()
{
}
virtual void redo() = 0;
virtual void undo() = 0;
private:
bool modified_;
bool prepared_;
bool done_;
std::function<bool()> is_modified_;
std::function<void(bool)> set_modified_;
};
class MultiUndoCommand : public UndoCommand {
public:
MultiUndoCommand() = default;
/**
* @brief Destructor. Deletes all owned children (added with
* add_child() and not yet pushed elsewhere).
*/
virtual ~MultiUndoCommand() override;
void add_child(UndoCommand *command)
{
children_.push_back(command);
}
int child_count() const
{
return children_.size();
}
UndoCommand *child(int i) const
{
return children_[i];
}
protected:
virtual void redo() override;
virtual void undo() override;
private:
std::vector<UndoCommand *> children_;
};
}
#endif // OAK_UNDOCOMMAND_H
+194
View File
@@ -0,0 +1,194 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
Modifications Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#include "undostack.h"
namespace olive
{
const int UndoStack::k_max_undo_commands = 200;
class EmptyCommand : public UndoCommand {
public:
EmptyCommand()
{
}
protected:
virtual void redo() override
{
}
virtual void undo() override
{
}
};
UndoStack::UndoStack()
{
clear();
}
UndoStack::~UndoStack()
{
for (auto it = commands_.cbegin(); it != commands_.cend(); it++) {
delete it->command;
}
for (auto it = undone_commands_.cbegin(); it != undone_commands_.cend();
it++) {
delete it->command;
}
}
void UndoStack::push(UndoCommand *command, const std::string &name)
{
MultiUndoCommand *mcu = dynamic_cast<MultiUndoCommand *>(command);
if (mcu && mcu->child_count() == 0) {
delete command;
return;
}
// Clear any redoable commands
if (can_redo()) {
for (auto it = undone_commands_.cbegin(); it != undone_commands_.cend();
it++) {
delete it->command;
}
undone_commands_.clear();
}
// Do command and push
command->redo_and_set_modified();
commands_.push_back({ command, name });
// Delete oldest
if (commands_.size() > k_max_undo_commands) {
delete commands_.front().command;
commands_.pop_front();
}
emit_index_changed();
}
void UndoStack::push_pre_executed(UndoCommand *command, const std::string &name)
{
MultiUndoCommand *mcu = dynamic_cast<MultiUndoCommand *>(command);
if (mcu && mcu->child_count() == 0) {
delete command;
return;
}
// Clear any redoable commands
if (can_redo()) {
for (auto it = undone_commands_.cbegin(); it != undone_commands_.cend();
it++) {
delete it->command;
}
undone_commands_.clear();
}
// Push without redoing: the caller already executed the children.
// Mark the command done so it stays undoable.
command->set_done(true);
commands_.push_back({ command, name });
// Delete oldest
if (commands_.size() > k_max_undo_commands) {
delete commands_.front().command;
commands_.pop_front();
}
emit_index_changed();
}
void UndoStack::jump(size_t index)
{
// Guard with can_undo/can_redo: the bottom EmptyCommand is not
// undoable, so jumping to 0 must stop at it instead of spinning.
while (commands_.size() > index && can_undo()) {
undo();
}
while (commands_.size() < index && can_redo()) {
redo();
}
}
void UndoStack::undo()
{
if (can_undo()) {
// Undo most recently done command
commands_.back().command->undo_and_set_modified();
// Place at the front of the "undone commands" list
undone_commands_.push_front(commands_.back());
// Remove undone command from the commands list
commands_.pop_back();
emit_index_changed();
}
}
void UndoStack::redo()
{
if (can_redo()) {
// Redo most recently undone command
undone_commands_.front().command->redo_and_set_modified();
// Place at the back of the done commands list
commands_.push_back(undone_commands_.front());
// Remove done command from undone list
undone_commands_.pop_front();
emit_index_changed();
}
}
void UndoStack::clear()
{
for (auto it = commands_.cbegin(); it != commands_.cend(); it++) {
delete it->command;
}
commands_.clear();
for (auto it = undone_commands_.cbegin(); it != undone_commands_.cend();
it++) {
delete it->command;
}
undone_commands_.clear();
push(new EmptyCommand(), "New/Open Project");
}
bool UndoStack::can_undo() const
{
return !commands_.empty() &&
!dynamic_cast<EmptyCommand *>(commands_.back().command);
}
void UndoStack::emit_index_changed()
{
if (index_changed_callback_) {
index_changed_callback_(int(commands_.size()));
}
}
}
+140
View File
@@ -0,0 +1,140 @@
/***
Olive - Non-Linear Video Editor
Copyright (C) 2022 Olive Team
Modifications Copyright (C) 2025 mikesolar
Modifications Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef OAK_UNDOSTACK_H
#define OAK_UNDOSTACK_H
#include <functional>
#include <list>
#include <string>
#include "define.h"
#include "undocommand.h"
namespace olive
{
/**
* @brief History stack of undoable commands.
*
* The Qt version inherited QAbstractItemModel and owned undo/redo
* QActions; both are UI concerns and were removed in oakundo (they belong
* to the app layer). The index_changed signal is replaced by an optional
* std::function callback (set_index_changed_callback()).
*/
class UndoStack {
public:
UndoStack();
virtual ~UndoStack();
DISABLE_COPY_MOVE(UndoStack)
void push(UndoCommand *command, const std::string &name);
/**
* @brief Push a command that has already been executed (redo skipped).
*
* Used by the facade undo-group: child commands are added to the group
* and executed eagerly, then the whole group is pushed with this method
* so it is not redone again. Empty commands are discarded.
*/
void push_pre_executed(UndoCommand *command, const std::string &name);
void jump(size_t index);
void clear();
bool can_undo() const;
bool can_redo() const
{
return !undone_commands_.empty();
}
void undo();
void redo();
/**
* @brief Set the callback fired after every stack mutation with the
* current done-command count (replaces the Qt index_changed signal).
*/
void set_index_changed_callback(std::function<void(int)> callback)
{
index_changed_callback_ = std::move(callback);
}
// Facade accessors (oakengine/undo.h C ABI): row-based history queries.
// Rows 0..done_count()-1 are done commands (commands_ in order), rows
// done_count()..command_count()-1 are undone commands (undone_commands_
// in order, most recently undone first).
int command_count() const
{
return int(commands_.size() + undone_commands_.size());
}
int done_count() const
{
return int(commands_.size());
}
bool command_is_done(int row) const
{
return row >= 0 && row < done_count();
}
std::string command_name(int row) const
{
if (row < 0 || row >= command_count()) {
return std::string();
}
if (row < done_count()) {
auto it = commands_.begin();
std::advance(it, row);
return it->name;
}
auto it = undone_commands_.begin();
std::advance(it, row - done_count());
return it->name;
}
private:
static const int k_max_undo_commands;
struct CommandEntry {
UndoCommand *command;
std::string name;
};
void emit_index_changed();
std::list<CommandEntry> commands_;
std::list<CommandEntry> undone_commands_;
std::function<void(int)> index_changed_callback_;
};
}
#endif // OAK_UNDOSTACK_H
+40
View File
@@ -0,0 +1,40 @@
# Oak Video Editor - Non-Linear Video Editor
# Copyright (C) 2026 Oak Team
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# Standalone build driver for the oakundo module. oakundo depends only on
# oakcommon's header-only define.h macros (pulled in by include path, no
# linking), so this builds just oakundo and its tests.
#
# Usage (macOS/Homebrew):
# cmake -S src/undo/standalone -B build-oakundo
# cmake --build build-oakundo -j
# ctest --test-dir build-oakundo
cmake_minimum_required(VERSION 3.16 FATAL_ERROR)
project(oakundo-standalone LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
get_filename_component(OAK_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../.." ABSOLUTE)
list(APPEND CMAKE_MODULE_PATH "${OAK_REPO_ROOT}/cmake")
if(EXISTS "/opt/homebrew")
list(APPEND CMAKE_PREFIX_PATH "/opt/homebrew")
endif()
set(BUILD_TESTS ON)
enable_testing()
add_subdirectory(${OAK_REPO_ROOT}/src/undo ${CMAKE_BINARY_DIR}/undo)
+28
View File
@@ -0,0 +1,28 @@
# Oak Video Editor - Non-Linear Video Editor
# Copyright (C) 2026 Oak Team
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
find_package(GTest REQUIRED)
include(GoogleTest)
add_executable(oakundo-gtest
undocommand_test.cpp
undostack_test.cpp
)
target_link_libraries(oakundo-gtest PRIVATE
oakundo
GTest::gtest
GTest::gtest_main
)
gtest_discover_tests(oakundo-gtest)
+202
View File
@@ -0,0 +1,202 @@
/***
Oak Video Editor - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#include "undo/undocommand.h"
#include <gtest/gtest.h>
namespace
{
struct CommandLog {
int redo_count = 0;
int undo_count = 0;
int free_count = 0;
};
OakUndoCommandVtable make_vtable()
{
OakUndoCommandVtable vtable;
vtable.redo = [](void *userdata) {
static_cast<CommandLog *>(userdata)->redo_count++;
};
vtable.undo = [](void *userdata) {
static_cast<CommandLog *>(userdata)->undo_count++;
};
vtable.free_fn = [](void *userdata) {
static_cast<CommandLog *>(userdata)->free_count++;
};
return vtable;
}
OakUndoCommand *make_command(CommandLog &log)
{
OakUndoCommandVtable vtable = make_vtable();
OakUndoCommand *command = oakundo_command_init(&vtable, &log);
EXPECT_NE(command, nullptr);
return command;
}
}
TEST(OakUndoCommand, InitFree)
{
CommandLog log;
OakUndoCommand *command = make_command(log);
oakundo_command_free(command);
EXPECT_EQ(log.free_count, 1);
}
TEST(OakUndoCommand, InitNullVtableFails)
{
EXPECT_EQ(oakundo_command_init(nullptr, nullptr), nullptr);
}
TEST(OakUndoCommand, FreeNullIsNoOp)
{
oakundo_command_free(nullptr);
}
TEST(OakUndoCommand, RedoUndoNowRoundtrip)
{
CommandLog log;
OakUndoCommand *command = make_command(log);
EXPECT_EQ(oakundo_command_redo_now(command), OAKUNDO_OK);
EXPECT_EQ(log.redo_count, 1);
// redo_now on a done command is a no-op
EXPECT_EQ(oakundo_command_redo_now(command), OAKUNDO_OK);
EXPECT_EQ(log.redo_count, 1);
EXPECT_EQ(oakundo_command_undo_now(command), OAKUNDO_OK);
EXPECT_EQ(log.undo_count, 1);
// undo_now on an undone command is a no-op
EXPECT_EQ(oakundo_command_undo_now(command), OAKUNDO_OK);
EXPECT_EQ(log.undo_count, 1);
oakundo_command_free(command);
}
TEST(OakUndoCommand, RedoNowNullHandleFails)
{
EXPECT_EQ(oakundo_command_redo_now(nullptr), OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_command_undo_now(nullptr), OAKUNDO_E_INVALID);
}
TEST(OakUndoCommand, NullCallbacksAreNoOp)
{
OakUndoCommandVtable vtable;
vtable.redo = nullptr;
vtable.undo = nullptr;
vtable.free_fn = nullptr;
OakUndoCommand *command = oakundo_command_init(&vtable, nullptr);
ASSERT_NE(command, nullptr);
EXPECT_EQ(oakundo_command_redo_now(command), OAKUNDO_OK);
EXPECT_EQ(oakundo_command_undo_now(command), OAKUNDO_OK);
oakundo_command_free(command);
}
TEST(OakUndoCommand, MultiAddChildAndCount)
{
CommandLog log_a;
CommandLog log_b;
OakUndoCommand *multi = oakundo_command_init_multi();
ASSERT_NE(multi, nullptr);
int count = -1;
EXPECT_EQ(oakundo_command_multi_child_count(multi, &count), OAKUNDO_OK);
EXPECT_EQ(count, 0);
EXPECT_EQ(oakundo_command_multi_add_child(multi, make_command(log_a)),
OAKUNDO_OK);
EXPECT_EQ(oakundo_command_multi_add_child(multi, make_command(log_b)),
OAKUNDO_OK);
EXPECT_EQ(oakundo_command_multi_child_count(multi, &count), OAKUNDO_OK);
EXPECT_EQ(count, 2);
// Multi redo/undo forward to children.
EXPECT_EQ(oakundo_command_redo_now(multi), OAKUNDO_OK);
EXPECT_EQ(log_a.redo_count, 1);
EXPECT_EQ(log_b.redo_count, 1);
EXPECT_EQ(oakundo_command_undo_now(multi), OAKUNDO_OK);
EXPECT_EQ(log_a.undo_count, 1);
EXPECT_EQ(log_b.undo_count, 1);
// Freeing the multi frees the children it owns.
oakundo_command_free(multi);
EXPECT_EQ(log_a.free_count, 1);
EXPECT_EQ(log_b.free_count, 1);
}
TEST(OakUndoCommand, MultiChildBorrowedHandle)
{
CommandLog log;
OakUndoCommand *multi = oakundo_command_init_multi();
ASSERT_NE(multi, nullptr);
ASSERT_EQ(oakundo_command_multi_add_child(multi, make_command(log)),
OAKUNDO_OK);
OakUndoCommand *child = nullptr;
EXPECT_EQ(oakundo_command_multi_child(multi, 0, &child), OAKUNDO_OK);
ASSERT_NE(child, nullptr);
EXPECT_EQ(oakundo_command_redo_now(child), OAKUNDO_OK);
EXPECT_EQ(log.redo_count, 1);
// Freeing the borrowed wrapper must not free the underlying command.
oakundo_command_free(child);
EXPECT_EQ(log.free_count, 0);
oakundo_command_free(multi);
EXPECT_EQ(log.free_count, 1);
}
TEST(OakUndoCommand, MultiErrorPaths)
{
CommandLog log;
OakUndoCommand *multi = oakundo_command_init_multi();
ASSERT_NE(multi, nullptr);
OakUndoCommand *plain = make_command(log);
int count = 0;
OakUndoCommand *child = nullptr;
EXPECT_EQ(oakundo_command_multi_add_child(nullptr, plain),
OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_command_multi_add_child(multi, nullptr),
OAKUNDO_E_INVALID);
// plain is not a multi command
EXPECT_EQ(oakundo_command_multi_add_child(plain, make_command(log)),
OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_command_multi_child_count(nullptr, &count),
OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_command_multi_child_count(multi, nullptr),
OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_command_multi_child(multi, 0, nullptr),
OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_command_multi_child(multi, 5, &child),
OAKUNDO_E_NOT_FOUND);
EXPECT_EQ(oakundo_command_multi_child(multi, -1, &child),
OAKUNDO_E_NOT_FOUND);
oakundo_command_free(multi);
oakundo_command_free(plain);
}
+455
View File
@@ -0,0 +1,455 @@
/***
Oak Video Editor - Non-Linear Video Editor
Copyright (C) 2026 Oak Team
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#include "undo/undostack.h"
#include <gtest/gtest.h>
#include <cstdio>
#include <cstring>
#include <memory>
#include <vector>
#include "../src/undostack.h"
namespace
{
struct CommandLog {
int redo_count = 0;
int undo_count = 0;
int free_count = 0;
};
OakUndoCommandVtable make_vtable()
{
OakUndoCommandVtable vtable;
vtable.redo = [](void *userdata) {
static_cast<CommandLog *>(userdata)->redo_count++;
};
vtable.undo = [](void *userdata) {
static_cast<CommandLog *>(userdata)->undo_count++;
};
vtable.free_fn = [](void *userdata) {
static_cast<CommandLog *>(userdata)->free_count++;
};
return vtable;
}
OakUndoCommand *make_command(CommandLog &log)
{
OakUndoCommandVtable vtable = make_vtable();
OakUndoCommand *command = oakundo_command_init(&vtable, &log);
EXPECT_NE(command, nullptr);
return command;
}
int64_t stack_index(OakUndoStack *stack)
{
int64_t index = -1;
EXPECT_EQ(oakundo_undostack_index(stack, &index), OAKUNDO_OK);
return index;
}
int64_t stack_count(OakUndoStack *stack)
{
int64_t count = -1;
EXPECT_EQ(oakundo_undostack_count(stack, &count), OAKUNDO_OK);
return count;
}
int stack_can_undo(OakUndoStack *stack)
{
int value = -1;
EXPECT_EQ(oakundo_undostack_can_undo(stack, &value), OAKUNDO_OK);
return value;
}
int stack_can_redo(OakUndoStack *stack)
{
int value = -1;
EXPECT_EQ(oakundo_undostack_can_redo(stack, &value), OAKUNDO_OK);
return value;
}
}
TEST(OakUndoStack, InitFree)
{
OakUndoStack *stack = oakundo_undostack_init();
ASSERT_NE(stack, nullptr);
// A fresh stack holds the "New/Open Project" empty command.
EXPECT_EQ(stack_count(stack), 1);
EXPECT_EQ(stack_index(stack), 1);
EXPECT_EQ(stack_can_undo(stack), 0);
EXPECT_EQ(stack_can_redo(stack), 0);
oakundo_undostack_free(stack);
}
TEST(OakUndoStack, FreeNullIsNoOp)
{
oakundo_undostack_free(nullptr);
}
TEST(OakUndoStack, NullHandleFails)
{
CommandLog log;
int64_t value64 = 0;
int value = 0;
char buf[8];
EXPECT_EQ(oakundo_undostack_push(nullptr, make_command(log), "x"),
OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_push_pre_executed(nullptr, make_command(log),
"x"),
OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_undo(nullptr), OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_redo(nullptr), OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_jump(nullptr, 0), OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_clear(nullptr), OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_can_undo(nullptr, &value), OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_can_redo(nullptr, &value), OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_count(nullptr, &value64), OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_index(nullptr, &value64), OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_command_text(nullptr, 0, buf, sizeof(buf)),
OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_command_is_done(nullptr, 0, &value),
OAKUNDO_E_INVALID);
OakUndoStack *stack = oakundo_undostack_init();
ASSERT_NE(stack, nullptr);
EXPECT_EQ(oakundo_undostack_push(stack, nullptr, "x"), OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_push_pre_executed(stack, nullptr, "x"),
OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_can_undo(stack, nullptr), OAKUNDO_E_INVALID);
EXPECT_EQ(oakundo_undostack_count(stack, nullptr), OAKUNDO_E_INVALID);
oakundo_undostack_free(stack);
}
TEST(OakUndoStack, PushUndoRedoRoundtrip)
{
CommandLog log;
OakUndoStack *stack = oakundo_undostack_init();
ASSERT_NE(stack, nullptr);
ASSERT_EQ(oakundo_undostack_push(stack, make_command(log), "edit"),
OAKUNDO_OK);
EXPECT_EQ(log.redo_count, 1);
EXPECT_EQ(stack_count(stack), 2);
EXPECT_EQ(stack_index(stack), 2);
EXPECT_EQ(stack_can_undo(stack), 1);
EXPECT_EQ(stack_can_redo(stack), 0);
ASSERT_EQ(oakundo_undostack_undo(stack), OAKUNDO_OK);
EXPECT_EQ(log.undo_count, 1);
EXPECT_EQ(stack_index(stack), 1);
EXPECT_EQ(stack_can_undo(stack), 0);
EXPECT_EQ(stack_can_redo(stack), 1);
ASSERT_EQ(oakundo_undostack_redo(stack), OAKUNDO_OK);
EXPECT_EQ(log.redo_count, 2);
EXPECT_EQ(stack_index(stack), 2);
EXPECT_EQ(stack_can_undo(stack), 1);
EXPECT_EQ(stack_can_redo(stack), 0);
oakundo_undostack_free(stack);
EXPECT_EQ(log.free_count, 1);
}
TEST(OakUndoStack, PushClearsRedoable)
{
CommandLog log_a;
CommandLog log_b;
OakUndoStack *stack = oakundo_undostack_init();
ASSERT_NE(stack, nullptr);
ASSERT_EQ(oakundo_undostack_push(stack, make_command(log_a), "a"),
OAKUNDO_OK);
ASSERT_EQ(oakundo_undostack_undo(stack), OAKUNDO_OK);
ASSERT_EQ(stack_can_redo(stack), 1);
// Pushing a new command deletes the redoable one.
ASSERT_EQ(oakundo_undostack_push(stack, make_command(log_b), "b"),
OAKUNDO_OK);
EXPECT_EQ(log_a.free_count, 1);
EXPECT_EQ(stack_can_redo(stack), 0);
EXPECT_EQ(stack_count(stack), 2);
oakundo_undostack_free(stack);
}
TEST(OakUndoStack, PushPreExecutedSkipsRedo)
{
CommandLog log;
OakUndoStack *stack = oakundo_undostack_init();
ASSERT_NE(stack, nullptr);
ASSERT_EQ(oakundo_undostack_push_pre_executed(stack, make_command(log),
"done elsewhere"),
OAKUNDO_OK);
EXPECT_EQ(log.redo_count, 0);
EXPECT_EQ(stack_index(stack), 2);
// Undo and redo still work afterwards.
ASSERT_EQ(oakundo_undostack_undo(stack), OAKUNDO_OK);
EXPECT_EQ(log.undo_count, 1);
ASSERT_EQ(oakundo_undostack_redo(stack), OAKUNDO_OK);
EXPECT_EQ(log.redo_count, 1);
oakundo_undostack_free(stack);
}
TEST(OakUndoStack, EmptyMultiIsDiscarded)
{
OakUndoStack *stack = oakundo_undostack_init();
ASSERT_NE(stack, nullptr);
OakUndoCommand *multi = oakundo_command_init_multi();
ASSERT_NE(multi, nullptr);
ASSERT_EQ(oakundo_undostack_push(stack, multi, "empty"), OAKUNDO_OK);
EXPECT_EQ(stack_count(stack), 1);
multi = oakundo_command_init_multi();
ASSERT_NE(multi, nullptr);
ASSERT_EQ(oakundo_undostack_push_pre_executed(stack, multi, "empty"),
OAKUNDO_OK);
EXPECT_EQ(stack_count(stack), 1);
oakundo_undostack_free(stack);
}
TEST(OakUndoStack, Jump)
{
CommandLog logs[3];
OakUndoStack *stack = oakundo_undostack_init();
ASSERT_NE(stack, nullptr);
for (int i = 0; i < 3; i++) {
char name[8];
snprintf(name, sizeof(name), "cmd%d", i);
ASSERT_EQ(oakundo_undostack_push(stack, make_command(logs[i]), name),
OAKUNDO_OK);
}
ASSERT_EQ(stack_index(stack), 4);
// Jump back to 1: undo all three commands.
ASSERT_EQ(oakundo_undostack_jump(stack, 1), OAKUNDO_OK);
EXPECT_EQ(stack_index(stack), 1);
for (int i = 0; i < 3; i++) {
EXPECT_EQ(logs[i].undo_count, 1);
}
EXPECT_EQ(stack_can_redo(stack), 1);
// Jump forward to 3: redo the first two undone commands.
ASSERT_EQ(oakundo_undostack_jump(stack, 3), OAKUNDO_OK);
EXPECT_EQ(stack_index(stack), 3);
EXPECT_EQ(logs[0].redo_count, 2);
EXPECT_EQ(logs[1].redo_count, 2);
EXPECT_EQ(logs[2].redo_count, 1);
// Negative index clamps to 0; the bottom "New/Open Project" empty
// command is not undoable, so the jump stops at index 1.
ASSERT_EQ(oakundo_undostack_jump(stack, -5), OAKUNDO_OK);
EXPECT_EQ(stack_index(stack), 1);
oakundo_undostack_free(stack);
}
TEST(OakUndoStack, Clear)
{
CommandLog log;
OakUndoStack *stack = oakundo_undostack_init();
ASSERT_NE(stack, nullptr);
ASSERT_EQ(oakundo_undostack_push(stack, make_command(log), "edit"),
OAKUNDO_OK);
ASSERT_EQ(oakundo_undostack_clear(stack), OAKUNDO_OK);
EXPECT_EQ(log.free_count, 1);
EXPECT_EQ(stack_count(stack), 1);
EXPECT_EQ(stack_can_undo(stack), 0);
EXPECT_EQ(stack_can_redo(stack), 0);
char buf[32];
ASSERT_EQ(oakundo_undostack_command_text(stack, 0, buf, sizeof(buf)),
int(strlen("New/Open Project")) + 1);
EXPECT_STREQ(buf, "New/Open Project");
oakundo_undostack_free(stack);
}
TEST(OakUndoStack, CommandTextTwoStage)
{
CommandLog log;
OakUndoStack *stack = oakundo_undostack_init();
ASSERT_NE(stack, nullptr);
ASSERT_EQ(oakundo_undostack_push(stack, make_command(log), "hello"),
OAKUNDO_OK);
// Stage one: query the required size.
int required = oakundo_undostack_command_text(stack, 1, nullptr, 0);
EXPECT_EQ(required, int(strlen("hello")) + 1);
// Stage two: copy into an exact buffer.
std::vector<char> buf(required);
EXPECT_EQ(oakundo_undostack_command_text(stack, 1, buf.data(), required),
required);
EXPECT_STREQ(buf.data(), "hello");
// Truncating buffer still reports the full required size.
char small[3];
EXPECT_EQ(oakundo_undostack_command_text(stack, 1, small, sizeof(small)),
required);
EXPECT_EQ(small[sizeof(small) - 1], '\0');
// Invalid rows.
EXPECT_EQ(oakundo_undostack_command_text(stack, 2, nullptr, 0),
OAKUNDO_E_NOT_FOUND);
EXPECT_EQ(oakundo_undostack_command_text(stack, -1, nullptr, 0),
OAKUNDO_E_NOT_FOUND);
// NULL name behaves like an empty label.
ASSERT_EQ(oakundo_undostack_push(stack, make_command(log), nullptr),
OAKUNDO_OK);
EXPECT_EQ(oakundo_undostack_command_text(stack, 2, nullptr, 0), 1);
oakundo_undostack_free(stack);
}
TEST(OakUndoStack, CommandIsDone)
{
CommandLog log;
OakUndoStack *stack = oakundo_undostack_init();
ASSERT_NE(stack, nullptr);
ASSERT_EQ(oakundo_undostack_push(stack, make_command(log), "edit"),
OAKUNDO_OK);
ASSERT_EQ(oakundo_undostack_undo(stack), OAKUNDO_OK);
int value = -1;
EXPECT_EQ(oakundo_undostack_command_is_done(stack, 0, &value), OAKUNDO_OK);
EXPECT_EQ(value, 1);
EXPECT_EQ(oakundo_undostack_command_is_done(stack, 1, &value), OAKUNDO_OK);
EXPECT_EQ(value, 0);
EXPECT_EQ(oakundo_undostack_command_is_done(stack, 2, &value),
OAKUNDO_E_NOT_FOUND);
EXPECT_EQ(oakundo_undostack_command_is_done(stack, -1, &value),
OAKUNDO_E_NOT_FOUND);
EXPECT_EQ(oakundo_undostack_command_is_done(stack, 0, nullptr),
OAKUNDO_E_INVALID);
oakundo_undostack_free(stack);
}
TEST(OakUndoStack, MaxUndoCommands)
{
OakUndoStack *stack = oakundo_undostack_init();
ASSERT_NE(stack, nullptr);
// The fresh stack holds 1 empty command; push well past the 200 cap.
std::vector<std::unique_ptr<CommandLog>> logs;
for (int i = 0; i < 250; i++) {
logs.emplace_back(new CommandLog());
ASSERT_EQ(oakundo_undostack_push(stack, make_command(*logs.back()),
"edit"),
OAKUNDO_OK);
}
EXPECT_EQ(stack_count(stack), 200);
EXPECT_EQ(stack_index(stack), 200);
// The empty "New/Open Project" command was evicted, so everything left
// is undoable.
EXPECT_EQ(stack_can_undo(stack), 1);
// The 51 oldest commands (empty + first 50 pushed) were freed.
int freed = 0;
for (int i = 0; i < 50; i++) {
freed += logs[i]->free_count;
}
EXPECT_EQ(freed, 50);
// Undo all 200 entries: the bottom one is not an EmptyCommand.
for (int i = 0; i < 200; i++) {
EXPECT_EQ(stack_can_undo(stack), 1);
ASSERT_EQ(oakundo_undostack_undo(stack), OAKUNDO_OK);
}
EXPECT_EQ(stack_can_undo(stack), 0);
EXPECT_EQ(stack_count(stack), 200);
oakundo_undostack_free(stack);
}
namespace
{
class FakeCommand : public olive::UndoCommand {
public:
FakeCommand(int *redo_count, int *undo_count)
: redo_count_(redo_count), undo_count_(undo_count)
{
}
protected:
virtual void redo() override
{
(*redo_count_)++;
}
virtual void undo() override
{
(*undo_count_)++;
}
private:
int *redo_count_;
int *undo_count_;
};
}
TEST(OakUndoStack, CppIndexChangedCallback)
{
olive::UndoStack stack;
std::vector<int> notifications;
stack.set_index_changed_callback(
[&notifications](int index) { notifications.push_back(index); });
// The callback only fires on mutations after it is attached.
int redo_count = 0;
int undo_count = 0;
stack.push(new FakeCommand(&redo_count, &undo_count), "fake");
ASSERT_EQ(notifications.size(), 1u);
EXPECT_EQ(notifications.back(), 2);
stack.undo();
ASSERT_EQ(notifications.size(), 2u);
EXPECT_EQ(notifications.back(), 1);
stack.redo();
EXPECT_EQ(notifications.back(), 2);
// The bottom EmptyCommand is not undoable: jump(0) stops at index 1.
stack.jump(0);
EXPECT_EQ(notifications.back(), 1);
}