From c50017127b7655d723b336a2bc435fd8e7e0e8ce Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 5 Aug 2026 18:02:24 +0800 Subject: [PATCH] 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 --- docs/zh/plans/riir/M2-oakundo.md | 66 ++++ docs/zh/plans/riir/notes.md | 46 +++ include/undo/error.h | 39 +++ include/undo/undocommand.h | 137 +++++++++ include/undo/undostack.h | 164 ++++++++++ src/CMakeLists.txt | 3 +- src/common/CMakeLists.txt | 2 +- src/undo/CMakeLists.txt | 6 + src/undo/c_api/CMakeLists.txt | 4 + src/undo/c_api/commandhandle.h | 41 +++ src/undo/c_api/undocommand.cpp | 217 +++++++++++++ src/undo/c_api/undostack.cpp | 242 +++++++++++++++ src/undo/src/CMakeLists.txt | 34 +++ src/undo/src/undocommand.cpp | 103 +++++++ src/undo/src/undocommand.h | 146 +++++++++ src/undo/src/undostack.cpp | 194 ++++++++++++ src/undo/src/undostack.h | 140 +++++++++ src/undo/standalone/CMakeLists.txt | 40 +++ src/undo/tests/CMakeLists.txt | 28 ++ src/undo/tests/undocommand_test.cpp | 202 ++++++++++++ src/undo/tests/undostack_test.cpp | 455 ++++++++++++++++++++++++++++ 21 files changed, 2307 insertions(+), 2 deletions(-) create mode 100644 include/undo/error.h create mode 100644 include/undo/undocommand.h create mode 100644 include/undo/undostack.h create mode 100644 src/undo/CMakeLists.txt create mode 100644 src/undo/c_api/CMakeLists.txt create mode 100644 src/undo/c_api/commandhandle.h create mode 100644 src/undo/c_api/undocommand.cpp create mode 100644 src/undo/c_api/undostack.cpp create mode 100644 src/undo/src/CMakeLists.txt create mode 100644 src/undo/src/undocommand.cpp create mode 100644 src/undo/src/undocommand.h create mode 100644 src/undo/src/undostack.cpp create mode 100644 src/undo/src/undostack.h create mode 100644 src/undo/standalone/CMakeLists.txt create mode 100644 src/undo/tests/CMakeLists.txt create mode 100644 src/undo/tests/undocommand_test.cpp create mode 100644 src/undo/tests/undostack_test.cpp diff --git a/docs/zh/plans/riir/M2-oakundo.md b/docs/zh/plans/riir/M2-oakundo.md index 8dc01a939..4ae915efd 100644 --- a/docs/zh/plans/riir/M2-oakundo.md +++ b/docs/zh/plans/riir/M2-oakundo.md @@ -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/` — gtest,target `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)` + 作为 Qt signal 的替代。 +- §3 切割点的处理与计划不同:`get_relevant_project()` 未改成返回 + `void *`,而是整体删除,修改标记语义改为 + `UndoCommand::set_modified_callbacks(is_modified, set_modified)` + 回调对(oakundo 完全不认识 Project;M3 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`)。 diff --git a/docs/zh/plans/riir/notes.md b/docs/zh/plans/riir/notes.md index 3dc9cef3e..2b8e662d5 100644 --- a/docs/zh/plans/riir/notes.md +++ b/docs/zh/plans/riir/notes.md @@ -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`(`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 wrapper(free 只释放 + wrapper)。 diff --git a/include/undo/error.h b/include/undo/error.h new file mode 100644 index 000000000..f1239ba77 --- /dev/null +++ b/include/undo/error.h @@ -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 . + +***/ + +#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 diff --git a/include/undo/undocommand.h b/include/undo/undocommand.h new file mode 100644 index 000000000..95c0ae456 --- /dev/null +++ b/include/undo/undocommand.h @@ -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 . + +***/ + +#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 diff --git a/include/undo/undostack.h b/include/undo/undostack.h new file mode 100644 index 000000000..31312cab1 --- /dev/null +++ b/include/undo/undostack.h @@ -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 . + +***/ + +#ifndef OAK_EDITOR_UNDO_UNDOSTACK_H +#define OAK_EDITOR_UNDO_UNDOSTACK_H + +#include + +#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 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 094469010..8e089491e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1 +1,2 @@ -add_subdirectory(common) \ No newline at end of file +add_subdirectory(common) +add_subdirectory(undo) \ No newline at end of file diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index f7d718e18..f41570121 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -1,6 +1,6 @@ add_subdirectory(src) add_subdirectory(c_api) -if(BUILD_TESTING) +if(BUILD_TESTS) add_subdirectory(tests) endif() diff --git a/src/undo/CMakeLists.txt b/src/undo/CMakeLists.txt new file mode 100644 index 000000000..f41570121 --- /dev/null +++ b/src/undo/CMakeLists.txt @@ -0,0 +1,6 @@ +add_subdirectory(src) +add_subdirectory(c_api) + +if(BUILD_TESTS) + add_subdirectory(tests) +endif() diff --git a/src/undo/c_api/CMakeLists.txt b/src/undo/c_api/CMakeLists.txt new file mode 100644 index 000000000..1d29b2fb4 --- /dev/null +++ b/src/undo/c_api/CMakeLists.txt @@ -0,0 +1,4 @@ +target_sources(oakundo PRIVATE + undocommand.cpp + undostack.cpp +) diff --git a/src/undo/c_api/commandhandle.h b/src/undo/c_api/commandhandle.h new file mode 100644 index 000000000..3696d9ae2 --- /dev/null +++ b/src/undo/c_api/commandhandle.h @@ -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 . + +***/ + +#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 diff --git a/src/undo/c_api/undocommand.cpp b/src/undo/c_api/undocommand.cpp new file mode 100644 index 000000000..b6b6a1d56 --- /dev/null +++ b/src/undo/c_api/undocommand.cpp @@ -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 . + +***/ + +#include "undo/undocommand.h" + +#include + +#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(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(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(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; +} diff --git a/src/undo/c_api/undostack.cpp b/src/undo/c_api/undostack.cpp new file mode 100644 index 000000000..9db2c2534 --- /dev/null +++ b/src/undo/c_api/undostack.cpp @@ -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 . + +***/ + +#include "undo/undostack.h" + +#include +#include + +#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(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(name.size()) + 1; + if (buf && buf_size > 0) { + size_t copy_len = name.size(); + if (copy_len > static_cast(buf_size) - 1) { + copy_len = static_cast(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; + } +} diff --git a/src/undo/src/CMakeLists.txt b/src/undo/src/CMakeLists.txt new file mode 100644 index 000000000..c229c881f --- /dev/null +++ b/src/undo/src/CMakeLists.txt @@ -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 +) diff --git a/src/undo/src/undocommand.cpp b/src/undo/src/undocommand.cpp new file mode 100644 index 000000000..c5d748f2b --- /dev/null +++ b/src/undo/src/undocommand.cpp @@ -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 . + +***/ + +#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 is_modified, std::function 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; + } +} + +} diff --git a/src/undo/src/undocommand.h b/src/undo/src/undocommand.h new file mode 100644 index 000000000..1d659bb6c --- /dev/null +++ b/src/undo/src/undocommand.h @@ -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 . + +***/ + +#ifndef OAK_UNDOCOMMAND_H +#define OAK_UNDOCOMMAND_H + +#include +#include + +#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 is_modified, + std::function set_modified); + +protected: + virtual void prepare() + { + } + virtual void redo() = 0; + virtual void undo() = 0; + +private: + bool modified_; + + bool prepared_; + + bool done_; + + std::function is_modified_; + + std::function 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 children_; +}; + +} + +#endif // OAK_UNDOCOMMAND_H diff --git a/src/undo/src/undostack.cpp b/src/undo/src/undostack.cpp new file mode 100644 index 000000000..3c2c78e25 --- /dev/null +++ b/src/undo/src/undostack.cpp @@ -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 . + +***/ + +#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(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(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(commands_.back().command); +} + +void UndoStack::emit_index_changed() +{ + if (index_changed_callback_) { + index_changed_callback_(int(commands_.size())); + } +} + +} diff --git a/src/undo/src/undostack.h b/src/undo/src/undostack.h new file mode 100644 index 000000000..a93b662e3 --- /dev/null +++ b/src/undo/src/undostack.h @@ -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 . + +***/ + +#ifndef OAK_UNDOSTACK_H +#define OAK_UNDOSTACK_H + +#include +#include +#include + +#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 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 commands_; + + std::list undone_commands_; + + std::function index_changed_callback_; +}; + +} + +#endif // OAK_UNDOSTACK_H diff --git a/src/undo/standalone/CMakeLists.txt b/src/undo/standalone/CMakeLists.txt new file mode 100644 index 000000000..d5baa97e5 --- /dev/null +++ b/src/undo/standalone/CMakeLists.txt @@ -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) diff --git a/src/undo/tests/CMakeLists.txt b/src/undo/tests/CMakeLists.txt new file mode 100644 index 000000000..34c7d8d04 --- /dev/null +++ b/src/undo/tests/CMakeLists.txt @@ -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) diff --git a/src/undo/tests/undocommand_test.cpp b/src/undo/tests/undocommand_test.cpp new file mode 100644 index 000000000..461b5c92b --- /dev/null +++ b/src/undo/tests/undocommand_test.cpp @@ -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 . + +***/ + +#include "undo/undocommand.h" + +#include + +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(userdata)->redo_count++; + }; + vtable.undo = [](void *userdata) { + static_cast(userdata)->undo_count++; + }; + vtable.free_fn = [](void *userdata) { + static_cast(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); +} diff --git a/src/undo/tests/undostack_test.cpp b/src/undo/tests/undostack_test.cpp new file mode 100644 index 000000000..7bcc27ecf --- /dev/null +++ b/src/undo/tests/undostack_test.cpp @@ -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 . + +***/ + +#include "undo/undostack.h" + +#include + +#include +#include +#include +#include + +#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(userdata)->redo_count++; + }; + vtable.undo = [](void *userdata) { + static_cast(userdata)->undo_count++; + }; + vtable.free_fn = [](void *userdata) { + static_cast(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 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> 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 notifications; + stack.set_index_changed_callback( + [¬ifications](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); +}