diff --git a/src/modules/editor/EditCommand.cpp b/src/modules/editor/EditCommand.cpp new file mode 100644 index 00000000..a5c62058 --- /dev/null +++ b/src/modules/editor/EditCommand.cpp @@ -0,0 +1,93 @@ +#include "editor/EditCommand.h" + +#include + +namespace eve::editor { + +IntFieldEditCommand::IntFieldEditCommand(std::string name, IEditableTarget *target) + : name_(std::move(name)), target_(target) {} +bool IntFieldEditCommand::record(int x, int y, int after) { + auto *field = target_ ? target_->query() : nullptr; + if (!field || !field->inBounds(x, y)) return false; + for (auto &change : changes_) { + if (change.x == x && change.y == y) { change.after = after; return true; } + } + const int before = field->readInt(x, y); + if (before == after) return false; + changes_.push_back({x, y, before, after}); + dirty_.include(x, y); + return true; +} +bool IntFieldEditCommand::apply() { + auto *field = target_ ? target_->query() : nullptr; + if (!field) return false; + for (const auto &change : changes_) field->writeInt(change.x, change.y, change.after); + return !changes_.empty(); +} +void IntFieldEditCommand::revert() { + auto *field = target_ ? target_->query() : nullptr; + if (!field) return; + for (auto it = changes_.rbegin(); it != changes_.rend(); ++it) field->writeInt(it->x, it->y, it->before); +} +bool IntFieldEditCommand::mergeWith(const IEditCommand &laterBase) { + auto *later = dynamic_cast(&laterBase); + if (!later || later->target_ != target_) return false; + for (const auto &incoming : later->changes_) { + bool merged = false; + for (auto ¤t : changes_) { + if (current.x == incoming.x && current.y == incoming.y) { + current.after = incoming.after; + merged = true; + break; + } + } + if (!merged) changes_.push_back(incoming); + } + dirty_.include(later->dirty_); + return true; +} + +ScalarFieldEditCommand::ScalarFieldEditCommand(std::string name, IEditableTarget *target) + : name_(std::move(name)), target_(target) {} +bool ScalarFieldEditCommand::record(int x, int y, float after) { + auto *field = target_ ? target_->query() : nullptr; + if (!field || !field->inBounds(x, y)) return false; + for (auto &change : changes_) { + if (change.x == x && change.y == y) { change.after = after; return true; } + } + const float before = field->readScalar(x, y); + if (before == after) return false; + changes_.push_back({x, y, before, after}); + dirty_.include(x, y); + return true; +} +bool ScalarFieldEditCommand::apply() { + auto *field = target_ ? target_->query() : nullptr; + if (!field) return false; + for (const auto &change : changes_) field->writeScalar(change.x, change.y, change.after); + return !changes_.empty(); +} +void ScalarFieldEditCommand::revert() { + auto *field = target_ ? target_->query() : nullptr; + if (!field) return; + for (auto it = changes_.rbegin(); it != changes_.rend(); ++it) field->writeScalar(it->x, it->y, it->before); +} +bool ScalarFieldEditCommand::mergeWith(const IEditCommand &laterBase) { + auto *later = dynamic_cast(&laterBase); + if (!later || later->target_ != target_) return false; + for (const auto &incoming : later->changes_) { + bool merged = false; + for (auto ¤t : changes_) { + if (current.x == incoming.x && current.y == incoming.y) { + current.after = incoming.after; + merged = true; + break; + } + } + if (!merged) changes_.push_back(incoming); + } + dirty_.include(later->dirty_); + return true; +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditCommand.h b/src/modules/editor/EditCommand.h new file mode 100644 index 00000000..f85a4598 --- /dev/null +++ b/src/modules/editor/EditCommand.h @@ -0,0 +1,65 @@ +#pragma once + +#include "editor/EditorTarget.h" + +#include +#include + +namespace eve::editor { + +/** @brief Reversible unit accepted by the editor transaction manager. */ +class IEditCommand { +public: + virtual ~IEditCommand() = default; + virtual const std::string &name() const = 0; + virtual bool apply() = 0; + virtual void revert() = 0; + /** @brief Optionally absorb a later command from the same stroke. */ + virtual bool mergeWith(const IEditCommand &later) { + (void)later; + return false; + } + virtual EditRegion dirtyRegion() const = 0; +}; + +struct IntFieldChange { int x = 0, y = 0, before = 0, after = 0; }; + +/** @brief Reversible edits to any target exposing IIntFieldTarget. */ +class IntFieldEditCommand final : public IEditCommand { +public: + IntFieldEditCommand(std::string name, IEditableTarget *target); + const std::string &name() const override { return name_; } + bool record(int x, int y, int after); + int changeCount() const { return static_cast(changes_.size()); } + bool apply() override; + void revert() override; + bool mergeWith(const IEditCommand &later) override; + EditRegion dirtyRegion() const override { return dirty_; } +private: + std::string name_; + IEditableTarget *target_ = nullptr; + std::vector changes_; + EditRegion dirty_; +}; + +struct ScalarFieldChange { int x = 0, y = 0; float before = 0.f, after = 0.f; }; + +/** @brief Reversible edits to any target exposing IScalarFieldTarget. */ +class ScalarFieldEditCommand final : public IEditCommand { +public: + ScalarFieldEditCommand(std::string name, IEditableTarget *target); + const std::string &name() const override { return name_; } + bool record(int x, int y, float after); + int changeCount() const { return static_cast(changes_.size()); } + bool apply() override; + void revert() override; + bool mergeWith(const IEditCommand &later) override; + EditRegion dirtyRegion() const override { return dirty_; } +private: + std::string name_; + IEditableTarget *target_ = nullptr; + std::vector changes_; + EditRegion dirty_; +}; + +} // namespace eve::editor diff --git a/src/modules/editor/EditorSession.cpp b/src/modules/editor/EditorSession.cpp index c5ae0dd6..3a0166e8 100644 --- a/src/modules/editor/EditorSession.cpp +++ b/src/modules/editor/EditorSession.cpp @@ -6,6 +6,8 @@ namespace eve::editor { EditorSession::EditorSession() : context_(this) {} +EditorTransactions &EditorContext::transactions() const { return session_->transactions(); } + EditorSession::~EditorSession() { deactivateCurrent(); } bool EditorSession::addTool(IEditorTool *tool) { diff --git a/src/modules/editor/EditorSession.h b/src/modules/editor/EditorSession.h index 5f5c81c0..27d9deec 100644 --- a/src/modules/editor/EditorSession.h +++ b/src/modules/editor/EditorSession.h @@ -1,6 +1,7 @@ #pragma once #include "editor/EditorTool.h" +#include "editor/EditorTransactions.h" #include #include @@ -53,6 +54,7 @@ public: /** @brief Bind a non-owning editable target available to every tool callback. */ void bindTarget(IEditableTarget *target) { context_.target_ = target; } IEditableTarget *target() const { return context_.target_; } + EditorTransactions &transactions() { return transactions_; } private: void deactivateCurrent(); @@ -61,6 +63,7 @@ private: IEditorTool *activeTool_ = nullptr; int capturedPointerId_ = -1; EditorContext context_; + EditorTransactions transactions_; }; } // namespace eve::editor diff --git a/src/modules/editor/EditorTool.h b/src/modules/editor/EditorTool.h index 2759b182..8259a358 100644 --- a/src/modules/editor/EditorTool.h +++ b/src/modules/editor/EditorTool.h @@ -6,6 +6,7 @@ namespace eve::editor { class EditorSession; class IEditableTarget; +class EditorTransactions; /** @brief Pointer input normalized by an editor viewport adapter. */ struct EditorPointerEvent { @@ -60,6 +61,7 @@ public: EditorSession *session() const { return session_; } /** @brief Target currently bound to the session, or nullptr. */ IEditableTarget *target() const { return target_; } + EditorTransactions &transactions() const; template Capability *targetCapability() const; diff --git a/src/modules/editor/EditorTransactions.cpp b/src/modules/editor/EditorTransactions.cpp new file mode 100644 index 00000000..09fc31ac --- /dev/null +++ b/src/modules/editor/EditorTransactions.cpp @@ -0,0 +1,62 @@ +#include "editor/EditorTransactions.h" + +namespace eve::editor { + +bool EditorTransactions::begin(const std::string &name) { + if (active_) return false; + active_ = true; + pending_ = {}; + pending_.name = name; + return true; +} +bool EditorTransactions::execute(std::unique_ptr command) { + if (!active_ || !command || !command->apply()) return false; + if (!pending_.commands.empty() && pending_.commands.back()->mergeWith(*command)) return true; + pending_.commands.push_back(std::move(command)); + return true; +} +bool EditorTransactions::commit() { + if (!active_) return false; + active_ = false; + if (pending_.commands.empty()) { pending_ = {}; return false; } + undo_.push_back(std::move(pending_)); + pending_ = {}; + redo_.clear(); + return true; +} +bool EditorTransactions::rollback() { + if (!active_) return false; + revert(pending_); + active_ = false; + pending_ = {}; + return true; +} +bool EditorTransactions::undo() { + if (active_ || undo_.empty()) return false; + Transaction transaction = std::move(undo_.back()); + undo_.pop_back(); + revert(transaction); + redo_.push_back(std::move(transaction)); + return true; +} +bool EditorTransactions::redo() { + if (active_ || redo_.empty()) return false; + Transaction transaction = std::move(redo_.back()); + redo_.pop_back(); + apply(transaction); + undo_.push_back(std::move(transaction)); + return true; +} +void EditorTransactions::clear() { + if (active_) rollback(); + undo_.clear(); + redo_.clear(); +} +void EditorTransactions::apply(Transaction &transaction) { + for (auto &command : transaction.commands) command->apply(); +} +void EditorTransactions::revert(Transaction &transaction) { + for (auto it = transaction.commands.rbegin(); it != transaction.commands.rend(); ++it) (*it)->revert(); +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditorTransactions.h b/src/modules/editor/EditorTransactions.h new file mode 100644 index 00000000..75e0703c --- /dev/null +++ b/src/modules/editor/EditorTransactions.h @@ -0,0 +1,39 @@ +#pragma once + +#include "editor/EditCommand.h" + +#include +#include +#include + +namespace eve::editor { + +/** @brief Executes, groups, rolls back and replays arbitrary edit commands. */ +class EditorTransactions { +public: + bool begin(const std::string &name); + bool execute(std::unique_ptr command); + bool commit(); + bool rollback(); + bool undo(); + bool redo(); + void clear(); + bool isActive() const { return active_; } + bool canUndo() const { return !undo_.empty(); } + bool canRedo() const { return !redo_.empty(); } + int undoCount() const { return static_cast(undo_.size()); } + int redoCount() const { return static_cast(redo_.size()); } +private: + struct Transaction { + std::string name; + std::vector> commands; + }; + static void apply(Transaction &transaction); + static void revert(Transaction &transaction); + bool active_ = false; + Transaction pending_; + std::vector undo_; + std::vector redo_; +}; + +} // namespace eve::editor diff --git a/test/editor.cpp b/test/editor.cpp index 7720d0c2..7a0efd98 100644 --- a/test/editor.cpp +++ b/test/editor.cpp @@ -8,6 +8,7 @@ #include "editor/EditorHistory.h" #include "editor/EditorInspector.h" #include "editor/EditorSession.h" +#include "editor/EditorTransactions.h" #include "editor/EditorToolbar.h" #include "editor/FieldTargets.h" #include "editor/GizmoManager.h" @@ -162,6 +163,36 @@ TEST_CASE("editor.brush_kernel.separates_shape_and_falloff") { CHECK_EQ(points.size(), 9); } +TEST_CASE("editor.transactions.undo_redo_and_rollback_any_field") { + TileBuffer buffer(3, 3); + TileBufferTarget target("tiles", &buffer); + EditorTransactions transactions; + CHECK(transactions.begin("paint")); + auto first = std::make_unique("paint", &target); + CHECK(first->record(1, 1, 5)); + CHECK(transactions.execute(std::move(first))); + auto second = std::make_unique("paint", &target); + CHECK(second->record(1, 1, 7)); + CHECK(second->record(2, 1, 4)); + CHECK(transactions.execute(std::move(second))); + CHECK_EQ(buffer.getGid(1, 1), 7); + CHECK(transactions.commit()); + CHECK_EQ(transactions.undoCount(), 1); + CHECK(transactions.undo()); + CHECK_EQ(buffer.getGid(1, 1), 0); + CHECK_EQ(buffer.getGid(2, 1), 0); + CHECK(transactions.redo()); + CHECK_EQ(buffer.getGid(1, 1), 7); + + CHECK(transactions.begin("cancel")); + auto cancelled = std::make_unique("paint", &target); + CHECK(cancelled->record(0, 0, 9)); + CHECK(transactions.execute(std::move(cancelled))); + CHECK_EQ(buffer.getGid(0, 0), 9); + CHECK(transactions.rollback()); + CHECK_EQ(buffer.getGid(0, 0), 0); +} + TEST_CASE("editor.module.name") { auto *mod = Editor::create(); CHECK_EQ(mod->getName(), std::string("Editor"));