From 44039c90fc5d797080bcd2c687561516dbf2145e Mon Sep 17 00:00:00 2001 From: sunxfancy Date: Sun, 23 Aug 2026 19:14:47 +0800 Subject: [PATCH] feat(editor): add extensible tool session protocol --- src/modules/editor/Editor.cpp | 3 + src/modules/editor/Editor.h | 3 + src/modules/editor/EditorSession.cpp | 103 ++++++++++++++++++++++++ src/modules/editor/EditorSession.h | 63 +++++++++++++++ src/modules/editor/EditorTool.h | 114 +++++++++++++++++++++++++++ test/editor.cpp | 77 ++++++++++++++++++ 6 files changed, 363 insertions(+) create mode 100644 src/modules/editor/EditorSession.cpp create mode 100644 src/modules/editor/EditorSession.h create mode 100644 src/modules/editor/EditorTool.h diff --git a/src/modules/editor/Editor.cpp b/src/modules/editor/Editor.cpp index fb0718f0..e6da578f 100644 --- a/src/modules/editor/Editor.cpp +++ b/src/modules/editor/Editor.cpp @@ -4,6 +4,7 @@ #include "editor/EditorDock.h" #include "editor/EditorHistory.h" #include "editor/EditorInspector.h" +#include "editor/EditorSession.h" #include "editor/EditorToolbar.h" #include "editor/GizmoManager.h" #include "editor/TileBuffer.h" @@ -148,6 +149,8 @@ EditorDock *Editor::newDock() { return new EditorDock(); } EditorHistory *Editor::newHistory() { return new EditorHistory(); } +EditorSession *Editor::newSession() { return new EditorSession(); } + #ifdef EVENGINE_HAS_PROCGEN graphics::Mesh *Editor::newHeightmapMesh(procgen::Heightmap *hm, float cellSize, float heightScale) { diff --git a/src/modules/editor/Editor.h b/src/modules/editor/Editor.h index c8cd6a60..1c14c97c 100644 --- a/src/modules/editor/Editor.h +++ b/src/modules/editor/Editor.h @@ -24,6 +24,7 @@ class EditorToolbar; class EditorInspector; class EditorDock; class EditorHistory; +class EditorSession; /** * @brief Editor building blocks — not a shipped 3D/map editor app. @@ -48,6 +49,8 @@ public: EditorInspector *newInspector(); EditorDock *newDock(); EditorHistory *newHistory(); + /** @brief Create a host for interchangeable IEditorTool implementations. */ + EditorSession *newSession(); #ifdef EVENGINE_HAS_PROCGEN /** diff --git a/src/modules/editor/EditorSession.cpp b/src/modules/editor/EditorSession.cpp new file mode 100644 index 00000000..c5ae0dd6 --- /dev/null +++ b/src/modules/editor/EditorSession.cpp @@ -0,0 +1,103 @@ +#include "editor/EditorSession.h" + +#include + +namespace eve::editor { + +EditorSession::EditorSession() : context_(this) {} + +EditorSession::~EditorSession() { deactivateCurrent(); } + +bool EditorSession::addTool(IEditorTool *tool) { + if (!tool || tool->descriptor().id.empty()) return false; + for (auto *registered : tools_) { + if (registered == tool || registered->descriptor().id == tool->descriptor().id) return false; + } + tools_.push_back(tool); + return true; +} + +bool EditorSession::removeTool(const std::string &id) { + auto it = std::find_if(tools_.begin(), tools_.end(), [&](const IEditorTool *tool) { + return tool && tool->descriptor().id == id; + }); + if (it == tools_.end()) return false; + if (*it == activeTool_) deactivateCurrent(); + tools_.erase(it); + return true; +} + +void EditorSession::clearTools() { + deactivateCurrent(); + tools_.clear(); +} + +int EditorSession::getToolCount() const { return static_cast(tools_.size()); } + +IEditorTool *EditorSession::getTool(int index) const { + if (index < 0 || index >= static_cast(tools_.size())) return nullptr; + return tools_[static_cast(index)]; +} + +IEditorTool *EditorSession::findTool(const std::string &id) const { + for (auto *tool : tools_) { + if (tool && tool->descriptor().id == id) return tool; + } + return nullptr; +} + +bool EditorSession::activateTool(const std::string &id) { + if (id.empty()) { + deactivateCurrent(); + return true; + } + IEditorTool *next = findTool(id); + if (!next) return false; + if (next == activeTool_) return true; + deactivateCurrent(); + activeTool_ = next; + activeTool_->activate(context_); + return true; +} + +std::string EditorSession::activeToolId() const { + return activeTool_ ? activeTool_->descriptor().id : std::string{}; +} + +ToolResponse EditorSession::dispatchPointer(const EditorPointerEvent &event) { + if (!activeTool_) return ToolResponse::ignored(); + if (capturedPointerId_ >= 0 && event.pointerId != capturedPointerId_) { + return ToolResponse::ignored(); + } + ToolResponse response = activeTool_->pointerEvent(context_, event); + if (response.capturePointer && capturedPointerId_ < 0) capturedPointerId_ = event.pointerId; + if (response.releasePointer && capturedPointerId_ == event.pointerId) capturedPointerId_ = -1; + if (event.phase == EditorPointerEvent::Phase::Cancel && capturedPointerId_ == event.pointerId) { + capturedPointerId_ = -1; + } + return response; +} + +ToolResponse EditorSession::dispatchKey(const EditorKeyEvent &event) { + return activeTool_ ? activeTool_->keyEvent(context_, event) : ToolResponse::ignored(); +} + +void EditorSession::update(float dt) { + if (activeTool_) activeTool_->update(context_, dt); +} + +void EditorSession::cancelActiveTool() { + if (activeTool_) activeTool_->cancel(context_); + capturedPointerId_ = -1; +} + +void EditorSession::deactivateCurrent() { + if (activeTool_) { + activeTool_->cancel(context_); + activeTool_->deactivate(context_); + } + activeTool_ = nullptr; + capturedPointerId_ = -1; +} + +} // namespace eve::editor diff --git a/src/modules/editor/EditorSession.h b/src/modules/editor/EditorSession.h new file mode 100644 index 00000000..9b252d57 --- /dev/null +++ b/src/modules/editor/EditorSession.h @@ -0,0 +1,63 @@ +#pragma once + +#include "editor/EditorTool.h" + +#include +#include + +namespace eve::editor { + +/** + * @brief Hosts interchangeable editor tools and routes their lifecycle/input. + * + * Tools are non-owning: the caller must keep a registered tool alive until it + * is removed or the session is destroyed. + */ +class EditorSession { +public: + EditorSession(); + ~EditorSession(); + + EditorSession(const EditorSession &) = delete; + EditorSession &operator=(const EditorSession &) = delete; + + /** @brief Register a tool. Returns false for null or duplicate id/pointer. */ + bool addTool(IEditorTool *tool); + /** @brief Remove a registered tool by id, deactivating it when necessary. */ + bool removeTool(const std::string &id); + /** @brief Remove all tools and release any pointer capture. */ + void clearTools(); + + int getToolCount() const; + IEditorTool *getTool(int index) const; + IEditorTool *findTool(const std::string &id) const; + + /** @brief Activate a registered tool by id. Empty id deactivates all tools. */ + bool activateTool(const std::string &id); + IEditorTool *activeTool() const { return activeTool_; } + std::string activeToolId() const; + + /** @brief Route input to the active tool and apply pointer-capture response. */ + ToolResponse dispatchPointer(const EditorPointerEvent &event); + /** @brief Route keyboard input to the active tool. */ + ToolResponse dispatchKey(const EditorKeyEvent &event); + /** @brief Update the active tool. */ + void update(float dt); + /** @brief Cancel its gesture and clear pointer capture. */ + void cancelActiveTool(); + + bool hasPointerCapture() const { return capturedPointerId_ >= 0; } + int capturedPointerId() const { return capturedPointerId_; } + EditorContext &context() { return context_; } + const EditorContext &context() const { return context_; } + +private: + void deactivateCurrent(); + + std::vector tools_; + IEditorTool *activeTool_ = nullptr; + int capturedPointerId_ = -1; + EditorContext context_; +}; + +} // namespace eve::editor diff --git a/src/modules/editor/EditorTool.h b/src/modules/editor/EditorTool.h new file mode 100644 index 00000000..912125e0 --- /dev/null +++ b/src/modules/editor/EditorTool.h @@ -0,0 +1,114 @@ +#pragma once + +#include + +namespace eve::editor { + +class EditorSession; + +/** @brief Pointer input normalized by an editor viewport adapter. */ +struct EditorPointerEvent { + enum class Phase { Down, Move, Up, Cancel }; + + Phase phase = Phase::Move; + int pointerId = 0; + int button = 0; + float x = 0.f; + float y = 0.f; + float deltaX = 0.f; + float deltaY = 0.f; + float pressure = 1.f; + bool shift = false; + bool control = false; + bool alt = false; +}; + +/** @brief Keyboard input normalized by the editor host. */ +struct EditorKeyEvent { + std::string key; + bool pressed = false; + bool repeated = false; + bool shift = false; + bool control = false; + bool alt = false; +}; + +/** @brief Result returned by a tool after processing input. */ +struct ToolResponse { + bool handled = false; + bool capturePointer = false; + bool releasePointer = false; + + static ToolResponse ignored() { return {}; } + static ToolResponse consumed() { return {true, false, false}; } + static ToolResponse capture() { return {true, true, false}; } + static ToolResponse release() { return {true, false, true}; } +}; + +/** + * @brief Context supplied by EditorSession to tools. + * + * Later editor capabilities (targets, transactions, overlays, selection) attach + * to this stable context instead of adding concrete tool cases to the session. + */ +class EditorContext { +public: + explicit EditorContext(EditorSession *session = nullptr) : session_(session) {} + + /** @brief Session currently dispatching the tool callback. */ + EditorSession *session() const { return session_; } + +private: + EditorSession *session_ = nullptr; +}; + +/** @brief UI-facing metadata supplied by a tool implementation. */ +struct ToolDescriptor { + std::string id; + std::string label; + std::string shortcut; +}; + +/** + * @brief Protocol implemented by every editor tool. + * + * EditorSession only depends on this lifecycle. Tile, terrain, object and game- + * specific tools are peers and can be added without modifying the session. + */ +class IEditorTool { +public: + virtual ~IEditorTool() = default; + + /** @brief Stable tool identity and presentation metadata. */ + virtual const ToolDescriptor &descriptor() const = 0; + + /** @brief Called exactly once when this tool becomes active. */ + virtual void activate(EditorContext &context) { (void)context; } + /** @brief Called exactly once before this tool stops being active. */ + virtual void deactivate(EditorContext &context) { (void)context; } + + /** @brief Handle normalized pointer input. */ + virtual ToolResponse pointerEvent(EditorContext &context, const EditorPointerEvent &event) { + (void)context; + (void)event; + return ToolResponse::ignored(); + } + + /** @brief Handle normalized keyboard input. */ + virtual ToolResponse keyEvent(EditorContext &context, const EditorKeyEvent &event) { + (void)context; + (void)event; + return ToolResponse::ignored(); + } + + /** @brief Per-frame hook for an active tool. */ + virtual void update(EditorContext &context, float dt) { + (void)context; + (void)dt; + } + + /** @brief Cancel the active gesture while keeping the tool selected. */ + virtual void cancel(EditorContext &context) { (void)context; } +}; + +} // namespace eve::editor diff --git a/test/editor.cpp b/test/editor.cpp index 9b16627d..ebbd8f0b 100644 --- a/test/editor.cpp +++ b/test/editor.cpp @@ -6,6 +6,7 @@ #include "editor/EditorDock.h" #include "editor/EditorHistory.h" #include "editor/EditorInspector.h" +#include "editor/EditorSession.h" #include "editor/EditorToolbar.h" #include "editor/GizmoManager.h" #include "editor/TileBuffer.h" @@ -19,6 +20,82 @@ using namespace eve::editor; +namespace { +class TestEditorTool final : public IEditorTool { +public: + explicit TestEditorTool(std::string id) { desc.id = std::move(id); } + const ToolDescriptor &descriptor() const override { return desc; } + void activate(EditorContext &) override { ++activations; } + void deactivate(EditorContext &) override { ++deactivations; } + void cancel(EditorContext &) override { ++cancellations; } + ToolResponse pointerEvent(EditorContext &, const EditorPointerEvent &event) override { + ++pointerEvents; + if (event.phase == EditorPointerEvent::Phase::Down) return ToolResponse::capture(); + if (event.phase == EditorPointerEvent::Phase::Up) return ToolResponse::release(); + return ToolResponse::consumed(); + } + ToolResponse keyEvent(EditorContext &, const EditorKeyEvent &) override { + ++keyEvents; + return ToolResponse::consumed(); + } + void update(EditorContext &, float) override { ++updates; } + + ToolDescriptor desc; + int activations = 0; + int deactivations = 0; + int cancellations = 0; + int pointerEvents = 0; + int keyEvents = 0; + int updates = 0; +}; +} // namespace + +TEST_CASE("editor.session.routes_tool_lifecycle_and_capture") { + EditorSession session; + TestEditorTool first("first"); + TestEditorTool second("second"); + CHECK(session.addTool(&first)); + CHECK(session.addTool(&second)); + CHECK(!session.addTool(&first)); + CHECK_EQ(session.getToolCount(), 2); + CHECK(session.activateTool("first")); + CHECK_EQ(first.activations, 1); + + EditorPointerEvent down; + down.phase = EditorPointerEvent::Phase::Down; + down.pointerId = 7; + CHECK(session.dispatchPointer(down).handled); + CHECK(session.hasPointerCapture()); + CHECK_EQ(session.capturedPointerId(), 7); + + EditorPointerEvent other = down; + other.phase = EditorPointerEvent::Phase::Move; + other.pointerId = 8; + CHECK(!session.dispatchPointer(other).handled); + CHECK_EQ(first.pointerEvents, 1); + + EditorPointerEvent up = down; + up.phase = EditorPointerEvent::Phase::Up; + CHECK(session.dispatchPointer(up).releasePointer); + CHECK(!session.hasPointerCapture()); + + EditorKeyEvent key; + key.key = "W"; + key.pressed = true; + CHECK(session.dispatchKey(key).handled); + session.update(0.016f); + CHECK_EQ(first.keyEvents, 1); + CHECK_EQ(first.updates, 1); + + CHECK(session.activateTool("second")); + CHECK_EQ(first.cancellations, 1); + CHECK_EQ(first.deactivations, 1); + CHECK_EQ(second.activations, 1); + CHECK(session.removeTool("second")); + CHECK_EQ(second.deactivations, 1); + CHECK_EQ(session.activeToolId(), std::string("")); +} + TEST_CASE("editor.module.name") { auto *mod = Editor::create(); CHECK_EQ(mod->getName(), std::string("Editor"));