From 0e69178be8da8c4de49c2e6e2b21a088b4f7a9f1 Mon Sep 17 00:00:00 2001 From: M1KUS3Q Date: Sat, 18 Jul 2026 15:24:11 +0200 Subject: [PATCH 1/9] feat: add event system primitives (EventListener, Delegate, MarkerEventLink) --- .gitignore | 1 + include/events/Delegate.hpp | 38 +++++++++++++++++++ include/events/EventListener.hpp | 19 ++++++++++ include/events/MarkerEventLink.hpp | 24 ++++++++++++ .../components/MarkerEmitterComponent.hpp | 18 +++++++++ 5 files changed, 100 insertions(+) create mode 100644 include/events/Delegate.hpp create mode 100644 include/events/EventListener.hpp create mode 100644 include/events/MarkerEventLink.hpp create mode 100644 include/scene/components/MarkerEmitterComponent.hpp diff --git a/.gitignore b/.gitignore index d1b4e06..9017fac 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ CTestTestfile.cmake _deps/ # IDEs and Editors +.clangd .vscode/ .idea/ *.swp diff --git a/include/events/Delegate.hpp b/include/events/Delegate.hpp new file mode 100644 index 0000000..cc2ae12 --- /dev/null +++ b/include/events/Delegate.hpp @@ -0,0 +1,38 @@ +#ifndef DELEGATE_HPP +#define DELEGATE_HPP + +#include +#include + +#include "events/EventListener.hpp" + +struct Context; + +class Delegate { + public: + Delegate() = default; + ~Delegate() = default; + + Delegate(const Delegate&) = delete; + Delegate& operator=(const Delegate&) = delete; + Delegate(Delegate&&) = delete; + Delegate& operator=(Delegate&&) = delete; + + void subscribe(EventListener* listener) { listeners.push_back(listener); } + + void unsubscribe(EventListener* listener) { + listeners.erase(std::remove(listeners.begin(), listeners.end(), listener), listeners.end()); + } + + void invoke(const Context& ctx) { + for (auto* listener : listeners) { + listener->onEventTriggered(ctx); + } + } + + private: + // NOLINTNEXTLINE(cppcoreguidelines-non-private-member-variables-in-classes) + std::vector listeners; +}; + +#endif // DELEGATE_HPP diff --git a/include/events/EventListener.hpp b/include/events/EventListener.hpp new file mode 100644 index 0000000..787e79e --- /dev/null +++ b/include/events/EventListener.hpp @@ -0,0 +1,19 @@ +#ifndef EVENTLISTENER_HPP +#define EVENTLISTENER_HPP + +struct Context; + +class EventListener { + public: + EventListener() = default; + virtual ~EventListener() = default; + + EventListener(const EventListener&) = delete; + EventListener& operator=(const EventListener&) = delete; + EventListener(EventListener&&) = delete; + EventListener& operator=(EventListener&&) = delete; + + virtual void onEventTriggered(const Context& ctx) = 0; +}; + +#endif // EVENTLISTENER_HPP diff --git a/include/events/MarkerEventLink.hpp b/include/events/MarkerEventLink.hpp new file mode 100644 index 0000000..69cff36 --- /dev/null +++ b/include/events/MarkerEventLink.hpp @@ -0,0 +1,24 @@ +#ifndef MARKEREVENTLINK_HPP +#define MARKEREVENTLINK_HPP + +#include +#include + +#include "data_structures/Context.hpp" +#include "events/EventListener.hpp" + +class MarkerEventLink : public EventListener { + public: + explicit MarkerEventLink(std::string name) : eventName(std::move(name)) {} + + void onEventTriggered(const Context& ctx) override { + if (ctx.markers != nullptr) { + ctx.markers->push_back(eventName); + } + } + + private: + std::string eventName; +}; + +#endif // MARKEREVENTLINK_HPP diff --git a/include/scene/components/MarkerEmitterComponent.hpp b/include/scene/components/MarkerEmitterComponent.hpp new file mode 100644 index 0000000..b27176f --- /dev/null +++ b/include/scene/components/MarkerEmitterComponent.hpp @@ -0,0 +1,18 @@ +#ifndef MARKEREMITTERCOMPONENT_HPP +#define MARKEREMITTERCOMPONENT_HPP + +#include + +#include "Component.hpp" + +namespace NeuronIDE { +class Component; +} + +class MarkerEmitterComponent : public Component { + public: + void update(const Context& context) override; + void render(SDL_Renderer* renderer) override; +}; + +#endif // BLINKCOMPONENT_HPP \ No newline at end of file From a70d52b6414787dbcc4c195f91fc7d85dd7e141f Mon Sep 17 00:00:00 2001 From: M1KUS3Q Date: Sat, 18 Jul 2026 15:25:51 +0200 Subject: [PATCH 2/9] feat: add onSceneReady lifecycle and getEvent/resolveEvents to Component and Scene --- include/scene/Scene.hpp | 5 +++++ include/scene/components/Component.hpp | 9 ++++++++ src/scene/Scene.cpp | 30 ++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/include/scene/Scene.hpp b/include/scene/Scene.hpp index d0c1025..163ad76 100644 --- a/include/scene/Scene.hpp +++ b/include/scene/Scene.hpp @@ -3,8 +3,10 @@ #include #include +#include #include +class Delegate; class SceneObject; class SDL_Renderer; struct Context; @@ -24,6 +26,9 @@ class Scene { void update(const Context& ctx); void render(SDL_Renderer* renderer); + + void onSceneReady(); + Delegate& resolveEvents(std::string_view objectName, std::string_view eventName); }; #endif // SCENE_HPP \ No newline at end of file diff --git a/include/scene/components/Component.hpp b/include/scene/components/Component.hpp index 4e8d467..efb67d1 100644 --- a/include/scene/components/Component.hpp +++ b/include/scene/components/Component.hpp @@ -2,7 +2,10 @@ #define COMPONENT_HPP #include +#include +class Delegate; +class Scene; class SceneObject; class SDL_Renderer; struct Context; @@ -21,6 +24,12 @@ class Component { virtual void update(const Context& context) = 0; virtual void render(SDL_Renderer* renderer) = 0; + virtual void onSceneReady(Scene& scene) { (void)scene; } + virtual Delegate* getEvent(std::string_view eventName) { + (void)eventName; + return nullptr; + } + protected: // NOLINTNEXTLINE(cppcoreguidelines-non-private-member-variables-in-classes) std::weak_ptr owner; diff --git a/src/scene/Scene.cpp b/src/scene/Scene.cpp index 9360b5c..b6a67e6 100644 --- a/src/scene/Scene.cpp +++ b/src/scene/Scene.cpp @@ -1,6 +1,10 @@ #include "scene/Scene.hpp" +#include + +#include "events/Delegate.hpp" #include "scene/SceneObject.hpp" +#include "scene/components/Component.hpp" void Scene::update(const Context& ctx) { for (const auto& obj : objects) { @@ -14,4 +18,30 @@ void Scene::render(SDL_Renderer* renderer) { obj->render(renderer); } } +} + +void Scene::onSceneReady() { + for (const auto& obj : objects) { + for (const auto& comp : obj->components) { + comp->onSceneReady(*this); + } + } +} + +Delegate& Scene::resolveEvents(std::string_view objectName, std::string_view eventName) { + for (const auto& obj : objects) { + if (obj->name != objectName) { + continue; + } + for (const auto& comp : obj->components) { + auto* delegate = comp->getEvent(eventName); + if (delegate != nullptr) { + return *delegate; + } + } + throw std::runtime_error("Scene::resolveEvents: event '" + std::string(eventName) + + "' not found on object '" + std::string(objectName) + "'"); + } + throw std::runtime_error("Scene::resolveEvents: object '" + std::string(objectName) + + "' not found"); } \ No newline at end of file From 99be3bf3914d456678b3cff928d5c899db3d0c54 Mon Sep 17 00:00:00 2001 From: M1KUS3Q Date: Sat, 18 Jul 2026 15:26:54 +0200 Subject: [PATCH 3/9] feat: trigger onSceneReady lifecycle from Parser --- src/parser/Parser.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/parser/Parser.cpp b/src/parser/Parser.cpp index f651cc9..43413f6 100644 --- a/src/parser/Parser.cpp +++ b/src/parser/Parser.cpp @@ -43,6 +43,8 @@ std::shared_ptr Parser::parseStream(std::istream& stream) { scene->addObject(std::move(obj)); } + scene->onSceneReady(); + return scene; } From d221afbbada7223361bb20d625a449dd5ff12e77 Mon Sep 17 00:00:00 2001 From: M1KUS3Q Date: Sat, 18 Jul 2026 15:28:06 +0200 Subject: [PATCH 4/9] feat: add MarkerEmitterComponent protobuf message and extend Component oneof --- protoFiles/neuronide.proto | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/protoFiles/neuronide.proto b/protoFiles/neuronide.proto index 24e15a3..0623fb6 100644 --- a/protoFiles/neuronide.proto +++ b/protoFiles/neuronide.proto @@ -21,6 +21,15 @@ message ScriptComponent { string script_path = 1; } +message MarkerEmitterComponent { + message Binding { + string target_object = 1; + string target_event = 2; + string marker_name = 3; + } + repeated Binding bindings = 1; +} + message Transform { double x = 1; double y = 2; @@ -35,6 +44,7 @@ message Component { TextRenderer text = 2; BlinkComponent blinker = 3; ScriptComponent script = 4; + MarkerEmitterComponent marker_emitter = 5; } } From 2a00cc4c7d9044be6baafdadb3addf94e879ff2f Mon Sep 17 00:00:00 2001 From: M1KUS3Q Date: Sat, 18 Jul 2026 15:30:01 +0200 Subject: [PATCH 5/9] feat: implement MarkerEmitterComponent --- .../components/MarkerEmitterComponent.hpp | 41 +++++++++++++++-- src/scene/CMakeLists.txt | 1 + .../components/MarkerEmitterComponent.cpp | 44 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/scene/components/MarkerEmitterComponent.cpp diff --git a/include/scene/components/MarkerEmitterComponent.hpp b/include/scene/components/MarkerEmitterComponent.hpp index b27176f..dbb9f75 100644 --- a/include/scene/components/MarkerEmitterComponent.hpp +++ b/include/scene/components/MarkerEmitterComponent.hpp @@ -1,9 +1,15 @@ #ifndef MARKEREMITTERCOMPONENT_HPP #define MARKEREMITTERCOMPONENT_HPP -#include +#include +#include +#include -#include "Component.hpp" +#include "events/MarkerEventLink.hpp" +#include "scene/components/Component.hpp" + +class Delegate; +class Scene; namespace NeuronIDE { class Component; @@ -11,8 +17,37 @@ class Component; class MarkerEmitterComponent : public Component { public: + struct Binding { + std::string targetObject; + std::string targetEvent; + std::string markerName; + }; + + MarkerEmitterComponent(const std::shared_ptr& owner, + std::vector bindings); + ~MarkerEmitterComponent() override; + + MarkerEmitterComponent(const MarkerEmitterComponent&) = delete; + MarkerEmitterComponent& operator=(const MarkerEmitterComponent&) = delete; + MarkerEmitterComponent(MarkerEmitterComponent&&) = delete; + MarkerEmitterComponent& operator=(MarkerEmitterComponent&&) = delete; + void update(const Context& context) override; void render(SDL_Renderer* renderer) override; + void onSceneReady(Scene& scene) override; + + static std::unique_ptr createMarkerEmitter( + const NeuronIDE::Component& protoComp, + const std::shared_ptr& owner); + + private: + struct ActiveSubscription { + std::unique_ptr link; + Delegate* delegate = nullptr; + }; + + std::vector bindings; + std::vector activeSubscriptions; }; -#endif // BLINKCOMPONENT_HPP \ No newline at end of file +#endif // MARKEREMITTERCOMPONENT_HPP diff --git a/src/scene/CMakeLists.txt b/src/scene/CMakeLists.txt index 1338a40..212cb07 100644 --- a/src/scene/CMakeLists.txt +++ b/src/scene/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(scene OBJECT Scene.cpp components/ComponentRegistry.cpp components/BlinkComponent.cpp + components/MarkerEmitterComponent.cpp ) target_include_directories(scene PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../../include) target_link_libraries(scene PUBLIC neuronide_proto) \ No newline at end of file diff --git a/src/scene/components/MarkerEmitterComponent.cpp b/src/scene/components/MarkerEmitterComponent.cpp new file mode 100644 index 0000000..7501485 --- /dev/null +++ b/src/scene/components/MarkerEmitterComponent.cpp @@ -0,0 +1,44 @@ +#include "scene/components/MarkerEmitterComponent.hpp" + +#include "events/Delegate.hpp" +#include "neuronide.pb.h" +#include "scene/Scene.hpp" +#include "scene/components/ComponentRegistry.hpp" + +MarkerEmitterComponent::MarkerEmitterComponent(const std::shared_ptr& owner, + std::vector bindings) + : Component(owner), bindings(std::move(bindings)) {} + +MarkerEmitterComponent::~MarkerEmitterComponent() { + for (auto& sub : activeSubscriptions) { + sub.delegate->unsubscribe(sub.link.get()); + } +} + +void MarkerEmitterComponent::update(const Context& context) { (void)context; } + +void MarkerEmitterComponent::render(SDL_Renderer* renderer) { (void)renderer; } + +void MarkerEmitterComponent::onSceneReady(Scene& scene) { + for (const auto& binding : bindings) { + auto& delegate = scene.resolveEvents(binding.targetObject, binding.targetEvent); + + auto link = std::make_unique(binding.markerName); + delegate.subscribe(link.get()); + + activeSubscriptions.push_back({std::move(link), &delegate}); + } +} + +std::unique_ptr MarkerEmitterComponent::createMarkerEmitter( + const NeuronIDE::Component& protoComp, const std::shared_ptr& owner) { + std::vector bindings; + for (const auto& protoBinding : protoComp.marker_emitter().bindings()) { + bindings.push_back({protoBinding.target_object(), protoBinding.target_event(), + protoBinding.marker_name()}); + } + return std::make_unique(owner, std::move(bindings)); +} + +REGISTER_COMPONENT(NeuronIDE::Component::kMarkerEmitter, + MarkerEmitterComponent::createMarkerEmitter) From 926523ae2842d4710d49aa3aea4e243e66d5c8dd Mon Sep 17 00:00:00 2001 From: M1KUS3Q Date: Sat, 18 Jul 2026 15:33:01 +0200 Subject: [PATCH 6/9] test: add unit tests for event system and MarkerEmitterComponent --- tests/component_tests/marker_emitter_test.cpp | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 tests/component_tests/marker_emitter_test.cpp diff --git a/tests/component_tests/marker_emitter_test.cpp b/tests/component_tests/marker_emitter_test.cpp new file mode 100644 index 0000000..31e7120 --- /dev/null +++ b/tests/component_tests/marker_emitter_test.cpp @@ -0,0 +1,177 @@ +#include + +#include +#include +#include +#include + +#include "data_structures/Context.hpp" +#include "events/Delegate.hpp" +#include "events/MarkerEventLink.hpp" +#include "scene/Scene.hpp" +#include "scene/SceneObject.hpp" +#include "scene/components/Component.hpp" +#include "scene/components/MarkerEmitterComponent.hpp" + +// ------------------------------------------------------------------- +// Mock event listener for delegate tests +// ------------------------------------------------------------------- +class MockListener : public EventListener { + public: + void onEventTriggered(const Context& ctx) override { + (void)ctx; + callCount++; + } + + int callCount = 0; +}; + +// ------------------------------------------------------------------- +// Test component that exposes a Delegate via getEvent() +// ------------------------------------------------------------------- +class TestEventProvider : public Component { + public: + explicit TestEventProvider(const std::shared_ptr& owner) : Component(owner) {} + + Delegate* getEvent(std::string_view eventName) override { + if (eventName == "trigger") { + return &testDelegate; + } + return nullptr; + } + + void update(const Context& /*ctx*/) override {} + void render(SDL_Renderer* /*renderer*/) override {} + + Delegate testDelegate; +}; + +// =================================================================== +// Delegate tests +// =================================================================== +TEST(DelegateTest, SubscribeInvokesListener) { + Delegate delegate; + MockListener listener; + + delegate.subscribe(&listener); + std::vector markers; + Context ctx{0.0, &markers}; + delegate.invoke(ctx); + + EXPECT_EQ(listener.callCount, 1); +} + +TEST(DelegateTest, UnsubscribeRemovesListener) { + Delegate delegate; + MockListener listener; + + delegate.subscribe(&listener); + delegate.unsubscribe(&listener); + std::vector markers; + Context ctx{0.0, &markers}; + delegate.invoke(ctx); + + EXPECT_EQ(listener.callCount, 0); +} + +TEST(DelegateTest, MultipleListenersAllCalled) { + Delegate delegate; + MockListener listener1; + MockListener listener2; + MockListener listener3; + + delegate.subscribe(&listener1); + delegate.subscribe(&listener2); + delegate.subscribe(&listener3); + std::vector markers; + Context ctx{0.0, &markers}; + delegate.invoke(ctx); + + EXPECT_EQ(listener1.callCount, 1); + EXPECT_EQ(listener2.callCount, 1); + EXPECT_EQ(listener3.callCount, 1); +} + +// =================================================================== +// MarkerEventLink tests +// =================================================================== +TEST(MarkerEventLinkTest, PushesMarkerToContext) { + MarkerEventLink link("test_event"); + std::vector markers; + Context ctx{0.0, &markers}; + + link.onEventTriggered(ctx); + + ASSERT_EQ(markers.size(), 1); + EXPECT_EQ(markers[0], "test_event"); +} + +TEST(MarkerEventLinkTest, NullMarkersNoCrash) { + MarkerEventLink link("test_event"); + Context ctx{0.0, nullptr}; + + EXPECT_NO_THROW(link.onEventTriggered(ctx)); +} + +// =================================================================== +// Scene::resolveEvents tests +// =================================================================== +TEST(ResolveEventsTest, ReturnsDelegateFromComponent) { + auto scene = std::make_shared(); + auto obj = std::make_shared("Provider"); + auto comp = std::make_unique(obj); + obj->addComponent(std::move(comp)); + scene->addObject(obj); + + auto& delegate = scene->resolveEvents("Provider", "trigger"); + // Should return a valid reference + EXPECT_NO_THROW(delegate.invoke(Context{})); +} + +TEST(ResolveEventsTest, ThrowsOnMissingObject) { + auto scene = std::make_shared(); + + EXPECT_THROW(scene->resolveEvents("NoSuchObject", "trigger"), std::runtime_error); +} + +TEST(ResolveEventsTest, ThrowsOnMissingEvent) { + auto scene = std::make_shared(); + auto obj = std::make_shared("Provider"); + auto comp = std::make_unique(obj); + obj->addComponent(std::move(comp)); + scene->addObject(obj); + + EXPECT_THROW(scene->resolveEvents("Provider", "no_such_event"), std::runtime_error); +} + +// =================================================================== +// MarkerEmitterComponent integration test +// =================================================================== +TEST(MarkerEmitterIntegration, FullFlow) { + // Build scene with a provider and an emitter + auto scene = std::make_shared(); + + auto providerObj = std::make_shared("Provider"); + auto providerComp = std::make_unique(providerObj); + auto* providerPtr = providerComp.get(); + providerObj->addComponent(std::move(providerComp)); + scene->addObject(providerObj); + + auto emitterObj = std::make_shared("Emitter"); + std::vector bindings = { + {"Provider", "trigger", "hello_world"}}; + emitterObj->addComponent( + std::make_unique(emitterObj, std::move(bindings))); + scene->addObject(emitterObj); + + // Simulate onSceneReady + scene->onSceneReady(); + + // Fire the delegate + std::vector markers; + Context ctx{0.0, &markers}; + providerPtr->testDelegate.invoke(ctx); + + ASSERT_EQ(markers.size(), 1); + EXPECT_EQ(markers[0], "hello_world"); +} From 0a371fcc7777f0b60f562095c2a3e8ec6111ba70 Mon Sep 17 00:00:00 2001 From: M1KUS3Q Date: Sat, 18 Jul 2026 15:48:20 +0200 Subject: [PATCH 7/9] chore: format via clang-format --- include/events/EventListener.hpp | 2 +- include/scene/Scene.hpp | 2 +- include/scene/components/Component.hpp | 2 +- include/scene/components/MarkerEmitterComponent.hpp | 7 +++---- tests/component_tests/marker_emitter_test.cpp | 6 +++--- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/include/events/EventListener.hpp b/include/events/EventListener.hpp index 787e79e..b2558b1 100644 --- a/include/events/EventListener.hpp +++ b/include/events/EventListener.hpp @@ -5,7 +5,7 @@ struct Context; class EventListener { public: - EventListener() = default; + EventListener() = default; virtual ~EventListener() = default; EventListener(const EventListener&) = delete; diff --git a/include/scene/Scene.hpp b/include/scene/Scene.hpp index 163ad76..358f526 100644 --- a/include/scene/Scene.hpp +++ b/include/scene/Scene.hpp @@ -27,7 +27,7 @@ class Scene { void update(const Context& ctx); void render(SDL_Renderer* renderer); - void onSceneReady(); + void onSceneReady(); Delegate& resolveEvents(std::string_view objectName, std::string_view eventName); }; diff --git a/include/scene/components/Component.hpp b/include/scene/components/Component.hpp index efb67d1..0f17992 100644 --- a/include/scene/components/Component.hpp +++ b/include/scene/components/Component.hpp @@ -24,7 +24,7 @@ class Component { virtual void update(const Context& context) = 0; virtual void render(SDL_Renderer* renderer) = 0; - virtual void onSceneReady(Scene& scene) { (void)scene; } + virtual void onSceneReady(Scene& scene) { (void)scene; } virtual Delegate* getEvent(std::string_view eventName) { (void)eventName; return nullptr; diff --git a/include/scene/components/MarkerEmitterComponent.hpp b/include/scene/components/MarkerEmitterComponent.hpp index dbb9f75..3a14ad1 100644 --- a/include/scene/components/MarkerEmitterComponent.hpp +++ b/include/scene/components/MarkerEmitterComponent.hpp @@ -37,8 +37,7 @@ class MarkerEmitterComponent : public Component { void onSceneReady(Scene& scene) override; static std::unique_ptr createMarkerEmitter( - const NeuronIDE::Component& protoComp, - const std::shared_ptr& owner); + const NeuronIDE::Component& protoComp, const std::shared_ptr& owner); private: struct ActiveSubscription { @@ -46,8 +45,8 @@ class MarkerEmitterComponent : public Component { Delegate* delegate = nullptr; }; - std::vector bindings; - std::vector activeSubscriptions; + std::vector bindings; + std::vector activeSubscriptions; }; #endif // MARKEREMITTERCOMPONENT_HPP diff --git a/tests/component_tests/marker_emitter_test.cpp b/tests/component_tests/marker_emitter_test.cpp index 31e7120..dbd486c 100644 --- a/tests/component_tests/marker_emitter_test.cpp +++ b/tests/component_tests/marker_emitter_test.cpp @@ -151,9 +151,9 @@ TEST(MarkerEmitterIntegration, FullFlow) { // Build scene with a provider and an emitter auto scene = std::make_shared(); - auto providerObj = std::make_shared("Provider"); - auto providerComp = std::make_unique(providerObj); - auto* providerPtr = providerComp.get(); + auto providerObj = std::make_shared("Provider"); + auto providerComp = std::make_unique(providerObj); + auto* providerPtr = providerComp.get(); providerObj->addComponent(std::move(providerComp)); scene->addObject(providerObj); From 1f462ce1eeba1aed5a8533d6a2a26c93ba535f82 Mon Sep 17 00:00:00 2001 From: M1KUS3Q Date: Thu, 20 Aug 2026 16:48:40 +0200 Subject: [PATCH 8/9] feat: implement Subscription and ListenerStore for easier delegate management --- include/events/Delegate.hpp | 22 ++++---- include/events/Subscription.hpp | 50 +++++++++++++++++++ .../components/MarkerEmitterComponent.hpp | 8 +-- .../components/MarkerEmitterComponent.cpp | 10 +--- tests/component_tests/marker_emitter_test.cpp | 37 ++++++++++---- 5 files changed, 94 insertions(+), 33 deletions(-) create mode 100644 include/events/Subscription.hpp diff --git a/include/events/Delegate.hpp b/include/events/Delegate.hpp index cc2ae12..e7864a2 100644 --- a/include/events/Delegate.hpp +++ b/include/events/Delegate.hpp @@ -1,16 +1,17 @@ #ifndef DELEGATE_HPP #define DELEGATE_HPP -#include -#include +#include +#include #include "events/EventListener.hpp" +#include "events/Subscription.hpp" struct Context; class Delegate { public: - Delegate() = default; + Delegate() : store(std::make_shared()) {} ~Delegate() = default; Delegate(const Delegate&) = delete; @@ -18,21 +19,20 @@ class Delegate { Delegate(Delegate&&) = delete; Delegate& operator=(Delegate&&) = delete; - void subscribe(EventListener* listener) { listeners.push_back(listener); } - - void unsubscribe(EventListener* listener) { - listeners.erase(std::remove(listeners.begin(), listeners.end(), listener), listeners.end()); + Subscription subscribe(EventListener* listener) { + const std::size_t subscriptionId = store->nextId++; + store->listeners.push_back({listener, subscriptionId}); + return Subscription(store, subscriptionId); } void invoke(const Context& ctx) { - for (auto* listener : listeners) { - listener->onEventTriggered(ctx); + for (const auto& entry : store->listeners) { + entry.listener->onEventTriggered(ctx); } } private: - // NOLINTNEXTLINE(cppcoreguidelines-non-private-member-variables-in-classes) - std::vector listeners; + std::shared_ptr store; }; #endif // DELEGATE_HPP diff --git a/include/events/Subscription.hpp b/include/events/Subscription.hpp new file mode 100644 index 0000000..b53d5a3 --- /dev/null +++ b/include/events/Subscription.hpp @@ -0,0 +1,50 @@ +#ifndef SUBSCRIPTION_HPP +#define SUBSCRIPTION_HPP + +#include +#include +#include +#include + +class EventListener; + +// Shared listener bookkeeping. A Delegate owns one of these via std::shared_ptr and hands a +// std::weak_ptr to each Subscription it creates, so a Subscription can detect when its Delegate +// has already been destroyed and skip unsubscribing (a no-op instead of UB). +struct ListenerStore { + struct Entry { + EventListener* listener; + std::size_t subscriptionId; + }; + + std::vector listeners; + std::size_t nextId = 0; +}; + +// RAII handle returned by Delegate::subscribe. Unsubscribes from its Delegate on destruction; +// if the Delegate is already gone the operation is a no-op. +class Subscription { + public: + Subscription(std::weak_ptr store, std::size_t subscriptionId) + : store(std::move(store)), subscriptionId(subscriptionId) {} + + ~Subscription() { + if (auto lockedStore = store.lock()) { + std::erase_if(lockedStore->listeners, + [subscriptionId = subscriptionId](const ListenerStore::Entry& entry) { + return entry.subscriptionId == subscriptionId; + }); + } + } + + Subscription(const Subscription&) = delete; + Subscription& operator=(const Subscription&) = delete; + Subscription(Subscription&&) noexcept = default; + Subscription& operator=(Subscription&&) noexcept = default; + + private: + std::weak_ptr store; + std::size_t subscriptionId; +}; + +#endif // SUBSCRIPTION_HPP diff --git a/include/scene/components/MarkerEmitterComponent.hpp b/include/scene/components/MarkerEmitterComponent.hpp index 3a14ad1..95c11ea 100644 --- a/include/scene/components/MarkerEmitterComponent.hpp +++ b/include/scene/components/MarkerEmitterComponent.hpp @@ -6,9 +6,9 @@ #include #include "events/MarkerEventLink.hpp" +#include "events/Subscription.hpp" #include "scene/components/Component.hpp" -class Delegate; class Scene; namespace NeuronIDE { @@ -25,7 +25,7 @@ class MarkerEmitterComponent : public Component { MarkerEmitterComponent(const std::shared_ptr& owner, std::vector bindings); - ~MarkerEmitterComponent() override; + ~MarkerEmitterComponent() override = default; MarkerEmitterComponent(const MarkerEmitterComponent&) = delete; MarkerEmitterComponent& operator=(const MarkerEmitterComponent&) = delete; @@ -41,8 +41,10 @@ class MarkerEmitterComponent : public Component { private: struct ActiveSubscription { + // Declaration order matters: the Subscription is destroyed first, unsubscribing before + // the listener it points to is freed. std::unique_ptr link; - Delegate* delegate = nullptr; + Subscription subscription; }; std::vector bindings; diff --git a/src/scene/components/MarkerEmitterComponent.cpp b/src/scene/components/MarkerEmitterComponent.cpp index 7501485..9066439 100644 --- a/src/scene/components/MarkerEmitterComponent.cpp +++ b/src/scene/components/MarkerEmitterComponent.cpp @@ -9,12 +9,6 @@ MarkerEmitterComponent::MarkerEmitterComponent(const std::shared_ptr bindings) : Component(owner), bindings(std::move(bindings)) {} -MarkerEmitterComponent::~MarkerEmitterComponent() { - for (auto& sub : activeSubscriptions) { - sub.delegate->unsubscribe(sub.link.get()); - } -} - void MarkerEmitterComponent::update(const Context& context) { (void)context; } void MarkerEmitterComponent::render(SDL_Renderer* renderer) { (void)renderer; } @@ -24,9 +18,9 @@ void MarkerEmitterComponent::onSceneReady(Scene& scene) { auto& delegate = scene.resolveEvents(binding.targetObject, binding.targetEvent); auto link = std::make_unique(binding.markerName); - delegate.subscribe(link.get()); + auto sub = delegate.subscribe(link.get()); - activeSubscriptions.push_back({std::move(link), &delegate}); + activeSubscriptions.push_back({std::move(link), std::move(sub)}); } } diff --git a/tests/component_tests/marker_emitter_test.cpp b/tests/component_tests/marker_emitter_test.cpp index dbd486c..dbee715 100644 --- a/tests/component_tests/marker_emitter_test.cpp +++ b/tests/component_tests/marker_emitter_test.cpp @@ -53,7 +53,7 @@ TEST(DelegateTest, SubscribeInvokesListener) { Delegate delegate; MockListener listener; - delegate.subscribe(&listener); + auto subscription = delegate.subscribe(&listener); std::vector markers; Context ctx{0.0, &markers}; delegate.invoke(ctx); @@ -61,16 +61,17 @@ TEST(DelegateTest, SubscribeInvokesListener) { EXPECT_EQ(listener.callCount, 1); } -TEST(DelegateTest, UnsubscribeRemovesListener) { - Delegate delegate; - MockListener listener; - - delegate.subscribe(&listener); - delegate.unsubscribe(&listener); +TEST(DelegateTest, DestroyingSubscriptionRemovesListener) { + Delegate delegate; + MockListener listener; std::vector markers; Context ctx{0.0, &markers}; - delegate.invoke(ctx); + { + auto subscription = delegate.subscribe(&listener); + } + + delegate.invoke(ctx); EXPECT_EQ(listener.callCount, 0); } @@ -80,9 +81,9 @@ TEST(DelegateTest, MultipleListenersAllCalled) { MockListener listener2; MockListener listener3; - delegate.subscribe(&listener1); - delegate.subscribe(&listener2); - delegate.subscribe(&listener3); + auto subscription1 = delegate.subscribe(&listener1); + auto subscription2 = delegate.subscribe(&listener2); + auto subscription3 = delegate.subscribe(&listener3); std::vector markers; Context ctx{0.0, &markers}; delegate.invoke(ctx); @@ -92,6 +93,20 @@ TEST(DelegateTest, MultipleListenersAllCalled) { EXPECT_EQ(listener3.callCount, 1); } +TEST(DelegateTest, UnsubscribeAfterDelegateDestroyedIsNoOp) { + std::unique_ptr subscription; + MockListener listener; + + { + Delegate delegate; + subscription = std::make_unique(delegate.subscribe(&listener)); + // delegate (and its ListenerStore) destroyed here. + } + + // The Subscription's destructor must tolerate its Delegate being gone. + EXPECT_NO_THROW(subscription.reset()); +} + // =================================================================== // MarkerEventLink tests // =================================================================== From be10c1205c5b59cbbf256869be0643bbe3baaf38 Mon Sep 17 00:00:00 2001 From: M1KUS3Q Date: Sat, 22 Aug 2026 20:59:50 +0200 Subject: [PATCH 9/9] fix: format necessary changes --- tests/component_tests/marker_emitter_test.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/component_tests/marker_emitter_test.cpp b/tests/component_tests/marker_emitter_test.cpp index dbee715..14e679f 100644 --- a/tests/component_tests/marker_emitter_test.cpp +++ b/tests/component_tests/marker_emitter_test.cpp @@ -67,9 +67,7 @@ TEST(DelegateTest, DestroyingSubscriptionRemovesListener) { std::vector markers; Context ctx{0.0, &markers}; - { - auto subscription = delegate.subscribe(&listener); - } + { auto subscription = delegate.subscribe(&listener); } delegate.invoke(ctx); EXPECT_EQ(listener.callCount, 0);