From 01d5614a7140c46a0002a5342557e9dde3a25ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sat, 8 Aug 2026 20:28:45 -0300 Subject: [PATCH] ui: add scoped event and observable value bindings Introduce EventConnection and EventConnectionList for scoped Node event listeners that safely handle either emitter or observer destruction order. Add ObservableValue and UIValueBinding for UI-independent observable state, along with a shared UIValueConverter policy used by both observable and raw data bindings. Harden UIDataBind and UIProperty lifetime behavior, documentation, conversion, operators, and multi-widget handling. Preserve UIProperty as the lightweight option for UI-local owned values and UIDataBind for explicitly managed external values. Use inline ordered callback storage to preserve registration-order dispatch without per-listener tree allocations. Migrate submenu observation to scoped connections, safely clear ecode run configuration bindings before model erasure, and simplify the 7GUIs Counter example with UIProperty. Add lifecycle, ordering, conversion, binding, and mutation regression tests. --- include/eepp/core/observablevalue.hpp | 221 ++++++++++++++++++ include/eepp/scene/eventconnection.hpp | 105 +++++++++ include/eepp/scene/node.hpp | 23 +- include/eepp/ui.hpp | 2 + include/eepp/ui/uidatabind.hpp | 198 ++++++++-------- include/eepp/ui/uimenusubmenu.hpp | 2 +- include/eepp/ui/uiproperty.hpp | 113 ++++++++- include/eepp/ui/uivaluebinding.hpp | 133 +++++++++++ include/eepp/ui/uivalueconverter.hpp | 104 +++++++++ src/eepp/scene/eventconnection.cpp | 74 ++++++ src/eepp/scene/eventconnectionstate.hpp | 81 +++++++ src/eepp/scene/node.cpp | 67 +++--- src/eepp/ui/uimenusubmenu.cpp | 9 +- src/examples/7guis/counter/counter.cpp | 7 +- .../unit_tests/eventconnection_tests.cpp | 169 ++++++++++++++ .../unit_tests/observablevalue_tests.cpp | 92 ++++++++ src/tests/unit_tests/uidatabind_tests.cpp | 121 ++++++++++ src/tools/ecode/uibuildsettings.cpp | 19 +- 18 files changed, 1388 insertions(+), 152 deletions(-) create mode 100644 include/eepp/core/observablevalue.hpp create mode 100644 include/eepp/scene/eventconnection.hpp create mode 100644 include/eepp/ui/uivaluebinding.hpp create mode 100644 include/eepp/ui/uivalueconverter.hpp create mode 100644 src/eepp/scene/eventconnection.cpp create mode 100644 src/eepp/scene/eventconnectionstate.hpp create mode 100644 src/tests/unit_tests/eventconnection_tests.cpp create mode 100644 src/tests/unit_tests/observablevalue_tests.cpp create mode 100644 src/tests/unit_tests/uidatabind_tests.cpp diff --git a/include/eepp/core/observablevalue.hpp b/include/eepp/core/observablevalue.hpp new file mode 100644 index 000000000..3c662b387 --- /dev/null +++ b/include/eepp/core/observablevalue.hpp @@ -0,0 +1,221 @@ +#ifndef EE_CORE_OBSERVABLEVALUE_HPP +#define EE_CORE_OBSERVABLEVALUE_HPP + +#include +#include +#include +#include +#include +#include + +namespace EE { + +/** + * @brief Owns a value and notifies scoped observers after the value changes. + * + * ObservableValue is a deliberately small synchronous primitive. Assignment and set() notify + * observers immediately on the calling thread. Observer callbacks use snapshot semantics: changes + * to the observer list during a notification take effect on the next notification. + * + * The class is non-copyable. Moving it transfers the value and its existing observers, allowing + * handles and UI bindings to keep observing the moved-to instance. ObservableValue and all of its + * connections must be used from a single owning thread. + * + * Use ObservableValue for model or application state whose observers are not known by the model. + * A configuration object, for example, can publish changes without depending on UIWidget; a live + * UI may attach with UIValueBinding and disappear safely later. + * + * @code + * struct ApplicationConfig { + * ObservableValue showLineNumbers{ true }; + * }; + * ApplicationConfig config; + * auto connection = config.showLineNumbers.observe( + * []( bool enabled ) { updateEditorPolicy( enabled ); } ); + * config.showLineNumbers = false; + * @endcode + */ +template class ObservableValue { + private: + struct State { + using Callback = std::function; + struct Observer { + Uint32 id; + Callback callback; + }; + using Observers = SmallVector; + + explicit State( T value ) : value( std::move( value ) ) {} + + void set( const T& newValue ) { + if ( value == newValue ) + return; + value = newValue; + notify(); + } + + void set( T&& newValue ) { + if ( value == newValue ) + return; + value = std::move( newValue ); + notify(); + } + + void notify() { + auto snapshot = observers; + for ( const auto& observer : snapshot ) + observer.callback( value ); + } + + typename Observers::iterator find( Uint32 id ) { + return std::lower_bound( observers.begin(), observers.end(), id, + []( const Observer& observer, Uint32 observerId ) { + return observer.id < observerId; + } ); + } + + typename Observers::const_iterator find( Uint32 id ) const { + return std::lower_bound( observers.begin(), observers.end(), id, + []( const Observer& observer, Uint32 observerId ) { + return observer.id < observerId; + } ); + } + + bool contains( Uint32 id ) const { + auto observer = find( id ); + return observer != observers.end() && observer->id == id; + } + + void remove( Uint32 id ) { + auto observer = find( id ); + if ( observer != observers.end() && observer->id == id ) + observers.erase( observer ); + } + + T value; + Uint32 nextId{ 0 }; + Observers observers; + }; + + public: + using Callback = std::function; + + /** @brief Move-only scoped ownership of one ObservableValue observer. */ + class Connection { + public: + Connection() = default; + ~Connection() { disconnect(); } + Connection( const Connection& ) = delete; + Connection& operator=( const Connection& ) = delete; + + Connection( Connection&& other ) noexcept : + mState( std::move( other.mState ) ), mId( other.mId ) { + other.mId = 0; + } + + Connection& operator=( Connection&& other ) noexcept { + if ( this != &other ) { + disconnect(); + mState = std::move( other.mState ); + mId = other.mId; + other.mId = 0; + } + return *this; + } + + void disconnect() { + if ( auto state = mState.lock() ) + state->remove( mId ); + mState.reset(); + mId = 0; + } + + explicit operator bool() const { + if ( auto state = mState.lock() ) + return mId != 0 && state->contains( mId ); + return false; + } + + private: + friend class ObservableValue; + Connection( const std::shared_ptr& state, Uint32 id ) : mState( state ), mId( id ) {} + + std::weak_ptr mState; + Uint32 mId{ 0 }; + }; + + /** @brief Non-owning, lifetime-safe access used by adapters such as UIValueBinding. */ + class WeakHandle { + public: + WeakHandle() = default; + + bool set( const T& value ) const { + if ( auto state = mState.lock() ) { + state->set( value ); + return true; + } + return false; + } + + bool set( T&& value ) const { + if ( auto state = mState.lock() ) { + state->set( std::move( value ) ); + return true; + } + return false; + } + + explicit operator bool() const { return !mState.expired(); } + + private: + friend class ObservableValue; + explicit WeakHandle( const std::shared_ptr& state ) : mState( state ) {} + std::weak_ptr mState; + }; + + ObservableValue() : mState( std::make_shared( T{} ) ) {} + explicit ObservableValue( T value ) : mState( std::make_shared( std::move( value ) ) ) {} + ObservableValue( const ObservableValue& ) = delete; + ObservableValue& operator=( const ObservableValue& ) = delete; + ObservableValue( ObservableValue&& ) noexcept = default; + ObservableValue& operator=( ObservableValue&& ) noexcept = default; + + const T& get() const { return mState->value; } + void set( const T& value ) { + auto state = mState; + state->set( value ); + } + void set( T&& value ) { + auto state = mState; + state->set( std::move( value ) ); + } + + ObservableValue& operator=( const T& value ) { + set( value ); + return *this; + } + + ObservableValue& operator=( T&& value ) { + set( std::move( value ) ); + return *this; + } + + const T& operator*() const { return get(); } + const T* operator->() const { return &get(); } + operator const T&() const { return get(); } + + Connection observe( Callback callback ) { + auto id = ++mState->nextId; + mState->observers.emplace_back( typename State::Observer{ id, std::move( callback ) } ); + return Connection( mState, id ); + } + + WeakHandle weakHandle() const { return WeakHandle( mState ); } + + private: + std::shared_ptr mState; +}; + +} // namespace EE + +#endif diff --git a/include/eepp/scene/eventconnection.hpp b/include/eepp/scene/eventconnection.hpp new file mode 100644 index 000000000..0e3389855 --- /dev/null +++ b/include/eepp/scene/eventconnection.hpp @@ -0,0 +1,105 @@ +#ifndef EE_SCENE_EVENTCONNECTION_HPP +#define EE_SCENE_EVENTCONNECTION_HPP + +#include +#include +#include + +namespace EE { namespace Scene { + +struct EventConnectionState; + +/** + * @brief Move-only handle that disconnects a Node event listener on destruction. + * + * EventConnection provides scoped ownership for a listener registered with Node::connect(). If + * the connection is destroyed first, its listener is removed from the emitter. If the emitter is + * destroyed first, the connection expires and its eventual destruction is a safe no-op. + * + * A connection does not keep its emitting Node alive. Moving a connection transfers listener + * ownership; copying is disabled so that exactly one handle owns the scoped listener. + * + * Emitter destruction silently expires the connection. EventConnection is therefore not a + * replacement for UIWidget::OnClose: observers that must perform work when a widget closes must + * still connect to that widget-level notification. + * + * Event registration, dispatch, disconnection, and connection destruction must all happen on the + * emitter's owning thread. EventConnection does not make Node's event registry thread-safe. + */ +class EE_API EventConnection { + public: + /** @brief Creates a disconnected handle. */ + EventConnection() = default; + + /** @brief Disconnects the owned listener, if its emitter still exists. */ + ~EventConnection(); + + /** @brief Transfers listener ownership from @p other. */ + EventConnection( EventConnection&& other ) noexcept; + + /** + * @brief Disconnects the currently owned listener and transfers ownership from @p other. + */ + EventConnection& operator=( EventConnection&& other ) noexcept; + EventConnection( const EventConnection& ) = delete; + EventConnection& operator=( const EventConnection& ) = delete; + + /** + * @brief Removes the listener and makes this handle disconnected. + * + * Calling disconnect() more than once, or after the emitter has been destroyed, is safe. + */ + void disconnect(); + + /** + * @return True while this handle owns a listener and the emitter's event state still exists. + */ + explicit operator bool() const; + + private: + friend class Node; + EventConnection( std::weak_ptr state, Uint32 eventType, + Uint32 callbackId ); + + std::weak_ptr mState; + Uint32 mEventType{ 0 }; + Uint32 mCallbackId{ 0 }; +}; + +/** + * @brief Owns a small group of event connections and disconnects them together. + * + * The first four connections are stored inline. Destroying or clearing the list destroys every + * contained EventConnection and therefore disconnects all of their listeners. As with an + * individual EventConnection, the list must be manipulated on the emitters' owning thread. + */ +class EE_API EventConnectionList { + public: + EventConnectionList() = default; + EventConnectionList( EventConnectionList&& ) noexcept = default; + EventConnectionList& operator=( EventConnectionList&& ) noexcept = default; + EventConnectionList( const EventConnectionList& ) = delete; + EventConnectionList& operator=( const EventConnectionList& ) = delete; + + /** @brief Adds a connection by transferring its ownership into this list. */ + EventConnectionList& add( EventConnection connection ); + + /** @brief Equivalent to add(). */ + EventConnectionList& operator+=( EventConnection connection ); + + /** @brief Disconnects and removes all owned connections. */ + void clear(); + + /** @return True if the list owns no connections. */ + bool empty() const; + + /** @return The number of connections owned by the list. */ + size_t size() const; + + private: + SmallVector mConnections; +}; + +}} // namespace EE::Scene + +#endif diff --git a/include/eepp/scene/node.hpp b/include/eepp/scene/node.hpp index 64d85fb3b..5b6822251 100644 --- a/include/eepp/scene/node.hpp +++ b/include/eepp/scene/node.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -700,6 +701,22 @@ class EE_API Node : public Transformable { */ Uint32 on( const Uint32& eventType, const EventCallback& callback ); + /** + * @brief Connects an event listener whose lifetime is controlled by the returned handle. + * + * Destroying or disconnecting the handle removes the listener. If this node is destroyed + * first, the handle safely becomes disconnected. Unlike on() and addEventListener(), this API + * transfers listener ownership to an EventConnection instead of exposing a numeric callback ID. + * Expiry does not notify the observer; connect separately to UIWidget::OnClose when + * widget-close cleanup is required. The connection and event registry must be used on this + * node's owning thread. + * + * @param eventType The event type constant. + * @param callback The function to call when the event occurs. + * @return A move-only connection that owns the registered listener. + */ + EventConnection connect( const Uint32& eventType, EventCallback callback ); + /** * @brief Adds a mouse click event listener. * @@ -1935,9 +1952,6 @@ class EE_API Node : public Transformable { void clipSmartDisable(); protected: - /** @brief Map of event type to callback ID to callback function. */ - typedef UnorderedMap> EventsMap; - /** @brief Forward declaration for EventDispatcher. */ friend class EventDispatcher; friend class EE::UI::UISceneNode; @@ -1959,11 +1973,10 @@ class EE_API Node : public Transformable { BlendMode mBlend{ BlendMode::Alpha() }; bool mVisible{ true }; bool mEnabled{ true }; - Uint32 mNumCallBacks{ 0 }; mutable Polygon2f mPoly; mutable Rectf mWorldBounds; Vector2f mCenter; - EventsMap mEvents; + std::shared_ptr mEventConnectionState; OriginPoint mRotationOriginPoint; OriginPoint mScaleOriginPoint; diff --git a/include/eepp/ui.hpp b/include/eepp/ui.hpp index 38488a760..767f3d653 100644 --- a/include/eepp/ui.hpp +++ b/include/eepp/ui.hpp @@ -172,6 +172,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/include/eepp/ui/uidatabind.hpp b/include/eepp/ui/uidatabind.hpp index bd03b2986..121987d2c 100644 --- a/include/eepp/ui/uidatabind.hpp +++ b/include/eepp/ui/uidatabind.hpp @@ -1,83 +1,55 @@ #ifndef EE_UI_UIDATABIND_HPP #define EE_UI_UIDATABIND_HPP +#include +#include #include +#include #include #include -#include #include namespace EE { namespace UI { +/** + * @brief Synchronizes a value with one or more UIWidget properties. + * + * UIDataBind observes each widget's value event and writes converted values back to the external + * object supplied at construction. Calling set() updates that object and propagates the converted + * value to every bound widget. + * + * @warning The external object is not owned. It must outlive the UIDataBind, or reset() must be + * called before that object is destroyed. UIProperty is the owning alternative when the value + * should have the same lifetime as its binding. + * + * Widgets are also observed without ownership: EventConnection handles remove listeners when the + * binding dies, while the widget-level Event::OnClose notification removes widgets that die before + * the binding. All binding operations and widget events must run on the widgets' owning UI thread. + * The class is non-copyable and non-movable because its listeners capture its address. + * + * Use UIDataBind when adapting an existing externally owned value and its lifetime is already + * controlled by the caller. Prefer UIProperty for small UI-local state, or ObservableValue with + * UIValueBinding when the model must publish changes without depending on the UI. + * + * @code + * bool showDetails = false; + * auto binding = UIDataBind::New( + * &showDetails, checkbox, UIValueConverter::converterBool() ); + * // 'binding' must be destroyed or reset before 'showDetails'. + * @endcode + */ template class UIDataBind { public: - struct Converter { - Converter() {} + using Converter = UIValueConverter; - Converter( std::function*, T&, const std::string& )> toVal, - std::function*, std::string&, const T& )> fromVal ) : - toVal( toVal ), fromVal( fromVal ) {} - - std::function*, T&, const std::string& )> toVal; - std::function*, std::string&, const T& )> fromVal; - }; - - static Converter converterDefault() { - return Converter( - []( const UIDataBind* databind, T& val, const std::string& str ) { - if constexpr ( std::is_same_v ) { - str = val; - return true; - } else if constexpr ( std::is_same_v ) { - val = StyleSheetProperty( databind->getPropertyDefinition(), str ).asBool(); - return true; - } else { - return String::fromString( val, str ); - } - }, - []( const UIDataBind*, std::string& str, const T& val ) { - if constexpr ( std::is_same_v || - std::is_same_v ) { - str = val; - } else if constexpr ( std::is_same_v ) { - str = String::fromDouble( val ); - } else if constexpr ( std::is_same_v ) { - str = String::fromFloat( val ); - } else if constexpr ( std::is_same_v ) { - str = val ? "true" : "false"; - } else { - str = String::toString( val ); - } - return true; - } ); - } - - static Converter converterString() { - return Converter( - []( const UIDataBind*, T& val, const std::string& str ) { - val = str; - return true; - }, - []( const UIDataBind*, std::string& str, const T& val ) { - str = val; - return true; - } ); - } - - static Converter converterBool() { - return Converter( - []( const UIDataBind* databind, T& val, const std::string& str ) -> bool { - val = StyleSheetProperty( databind->getPropertyDefinition(), str ).asBool(); - return true; - }, - []( const UIDataBind*, std::string& str, const T& val ) -> bool { - str = val ? "true" : "false"; - return true; - } ); - } + // Compatibility helpers keep existing UIDataBind call sites source-compatible while the + // conversion policy itself remains independent from this binding type. + static Converter converterDefault() { return Converter::converterDefault(); } + static Converter converterString() { return Converter::converterString(); } + static Converter converterBool() { return Converter::converterBool(); } static std::unique_ptr> - New( T* t, const std::unordered_set& widgets, + New( T* t, const UnorderedSet& widgets, const Converter& converter = UIDataBind::converterDefault(), const std::string& valueKey = "value", const Event::EventType& eventType = Event::OnValueChange ) { @@ -93,9 +65,13 @@ template class UIDataBind { new UIDataBind( t, widget, converter, valueKey, eventType ) ); } - UIDataBind() : dataInitialized( true ) {} + UIDataBind() = default; + UIDataBind( const UIDataBind& ) = delete; + UIDataBind& operator=( const UIDataBind& ) = delete; + UIDataBind( UIDataBind&& ) = delete; + UIDataBind& operator=( UIDataBind&& ) = delete; - UIDataBind( T* t, const std::unordered_set& widgets, + UIDataBind( T* t, const UnorderedSet& widgets, const Converter& converter = UIDataBind::converterDefault(), const std::string& valueKey = "value", const Event::EventType& eventType = Event::OnValueChange ) { @@ -109,22 +85,27 @@ template class UIDataBind { init( t, { widget }, converter, valueKey, eventType ); } - void init( T* t, const std::unordered_set& widgets, + void init( T* t, const UnorderedSet& widgets, const Converter& converter = UIDataBind::converterDefault(), const std::string& valueKey = "value", const Event::EventType& eventType = Event::OnValueChange ) { + eeASSERT( t != nullptr ); + reset(); data = t; this->widgets = widgets; this->property = StyleSheetSpecification::instance()->getProperty( valueKey ); this->converter = converter; this->eventType = eventType; - for ( auto widget : widgets ) + for ( auto widget : widgets ) { + eeASSERT( widget != nullptr ); bindListeners( widget ); + } set( *data ); dataInitialized = true; } void set( const T& t ) { + eeASSERT( isInitialized() ); if ( dataInitialized && t == *data ) return; inSetValue = true; @@ -136,6 +117,7 @@ template class UIDataBind { } void set( T&& t ) { + eeASSERT( isInitialized() ); if ( dataInitialized && t == *data ) return; inSetValue = true; @@ -143,40 +125,52 @@ template class UIDataBind { setValueChange(); inSetValue = false; if ( onValueChangeCb ) - onValueChangeCb( t ); + onValueChangeCb( *data ); } - const T& get() const { return *data; } + const T& get() const { + eeASSERT( isInitialized() ); + return *data; + } + /** @return True when the binding has a valid external value, property, and converter. */ + bool isInitialized() const { + return data != nullptr && property != nullptr && converter.toValue && converter.fromValue; + } + + /** + * @brief Disconnects every widget and releases the reference to the external value. + * + * After reset(), the binding must be initialized again before get() or set() is used. + */ void reset() { - for ( auto widget : widgets ) { - widget->removeEventListener( valueCbs[widget] ); - widget->removeEventListener( closeCbs[widget] ); - } + connections.clear(); widgets.clear(); - valueCbs.clear(); - closeCbs.clear(); converter = Converter(); inSetValue = false; + dataInitialized = false; property = nullptr; data = nullptr; } + /** @brief Adds @p widget to the synchronized widget set. Duplicate binds are ignored. */ void bind( UIWidget* widget ) { + eeASSERT( isInitialized() ); + eeASSERT( widget != nullptr ); + if ( widgets.find( widget ) != widgets.end() ) + return; bindListeners( widget ); widgets.insert( widget ); inSetValue = true; - widget->applyProperty( StyleSheetProperty( property, String::toString( data ) ) ); + widget->applyProperty( StyleSheetProperty( property, dataToString() ) ); inSetValue = false; } + /** @brief Disconnects and removes @p widget from the synchronized widget set. */ void unbind( UIWidget* widget ) { if ( widgets.find( widget ) == widgets.end() ) return; - widget->removeEventListener( valueCbs[widget] ); - widget->removeEventListener( closeCbs[widget] ); - valueCbs.erase( widget ); - closeCbs.erase( widget ); + connections.erase( widget ); widgets.erase( widget ); } @@ -186,13 +180,12 @@ template class UIDataBind { std::function onValueChangeCb; - const std::unordered_set& getWidgets() const { return widgets; } + const UnorderedSet& getWidgets() const { return widgets; } protected: T* data{ nullptr }; - std::unordered_set widgets; - std::unordered_map valueCbs; - std::unordered_map closeCbs; + UnorderedSet widgets; + UnorderedMap connections; bool inSetValue{ false }; bool dataInitialized{ false }; const PropertyDefinition* property{ nullptr }; @@ -200,30 +193,35 @@ template class UIDataBind { Event::EventType eventType{ Event::OnValueChange }; void bindListeners( UIWidget* widget ) { - valueCbs[widget] = widget->on( eventType, [this]( const Event* event ) { + auto& widgetConnections = connections[widget]; + widgetConnections += widget->connect( eventType, [this]( const Event* event ) { processValueChange( event->getNode()->asType() ); } ); - closeCbs[widget] = widget->on( Event::OnClose, [this]( const Event* event ) { - closeCbs.erase( event->getNode()->asType() ); - this->widgets.erase( event->getNode()->asType() ); + widgetConnections += widget->connect( Event::OnClose, [this]( const Event* event ) { + auto widget = event->getNode()->asType(); + connections.erase( widget ); + widgets.erase( widget ); } ); } std::string dataToString() const { + eeASSERT( isInitialized() ); std::string str; - if ( !converter.fromVal( this, str, *data ) ) { - Log::error( "UIDataBind::dataToString converter::fromVal: unable to convert value " + if ( !converter.fromValue( property, str, *data ) ) { + Log::error( "UIDataBind::dataToString converter::fromValue: unable to convert value " "to string." ); } return str; } void processValueChange( UIWidget* emitter ) { + eeASSERT( isInitialized() ); + eeASSERT( emitter != nullptr ); if ( inSetValue ) return; bool success = false; T val; - success = converter.toVal( this, val, emitter->getPropertyString( property ) ); + success = converter.toValue( property, val, emitter->getPropertyString( property ) ); if ( success ) { *data = val; @@ -251,7 +249,7 @@ class UIDataBindBool { using Ptr = std::unique_ptr>; static Ptr - New( bool* t, const std::unordered_set& widgets, + New( bool* t, const UnorderedSet& widgets, const UIDataBind::Converter& converter = UIDataBind::converterBool(), const std::string& valueKey = "value" ) { return UIDataBind::New( t, widgets, converter, valueKey ); @@ -269,7 +267,7 @@ class UIDataBindString { public: using Ptr = std::unique_ptr>; - static Ptr New( std::string* t, const std::unordered_set& widgets, + static Ptr New( std::string* t, const UnorderedSet& widgets, const UIDataBind::Converter& converter = UIDataBind::converterString(), const std::string& valueKey = "text", @@ -286,6 +284,11 @@ class UIDataBindString { } }; +/** + * @brief Owns heterogeneous UIDataBind instances with stable heap addresses. + * + * Clearing or destroying the holder destroys every binding and disconnects its widget listeners. + */ template class UIDataBindHolder { public: using UIDataBindVariant = std::variant>...>; @@ -306,6 +309,11 @@ template class UIDataBindHolder { std::vector mHolder; }; +/** + * @brief Keyed owner for heterogeneous UIDataBind instances with stable heap addresses. + * + * Replacing a key destroys its previous binding and disconnects that binding's widget listeners. + */ template class UIDataBindHolderKV { public: using UIDataBindVariant = std::variant>...>; diff --git a/include/eepp/ui/uimenusubmenu.hpp b/include/eepp/ui/uimenusubmenu.hpp index 03bf707f9..3955e93f5 100644 --- a/include/eepp/ui/uimenusubmenu.hpp +++ b/include/eepp/ui/uimenusubmenu.hpp @@ -38,7 +38,7 @@ class EE_API UIMenuSubMenu : public UIMenuItem { UIWidget* mArrow{ nullptr }; Time mMaxTime; Action* mCurWait{ nullptr }; - Uint32 mSubMenuCloseCb{ 0 }; + EventConnection mSubMenuCloseConnection; UIMenuSubMenu(); diff --git a/include/eepp/ui/uiproperty.hpp b/include/eepp/ui/uiproperty.hpp index c7aa2c526..6783fec44 100644 --- a/include/eepp/ui/uiproperty.hpp +++ b/include/eepp/ui/uiproperty.hpp @@ -1,10 +1,37 @@ #include +#include namespace EE { namespace UI { +/** + * @brief Owns a value and exposes it as a UIDataBind-backed widget property. + * + * UIProperty is the owning counterpart to UIDataBind: the synchronized value is stored inside the + * property, so callers only need to ensure the UIProperty itself remains alive while using it. + * Assignments propagate to connected widgets, and widget-originated changes update value(). + * Connections are removed automatically when either the UIProperty or a connected widget dies. + * + * The class is non-copyable and non-movable because its UIDataBind stores the address of mValue and + * installs callbacks that capture the binding's address. + * + * Use UIProperty for concise UI-local state when the value and its widgets naturally share a + * lifetime. It avoids the shared state required by ObservableValue and owns its UIDataBind + * directly. + * + * @code + * UIProperty celsius( 0.0, celsiusInput ); + * UIProperty fahrenheit( 32.0, fahrenheitInput ); + * celsius.changed( [&fahrenheit]( double value ) { + * fahrenheit = value * 9.0 / 5.0 + 32.0; + * } ); + * @endcode + */ template class UIProperty { public: - UIProperty() {} + UIProperty( const UIProperty& ) = delete; + UIProperty& operator=( const UIProperty& ) = delete; + UIProperty( UIProperty&& ) = delete; + UIProperty& operator=( UIProperty&& ) = delete; UIProperty( T defaultValue, UIWidget* widget, const typename EE::UI::UIDataBind::Converter& converter = @@ -14,7 +41,7 @@ template class UIProperty { mValue( std::move( defaultValue ) ), mBindedData( &mValue, widget, converter, valueKey, eventType ) {} - UIProperty( T defaultValue, const std::unordered_set& widgets = {}, + UIProperty( T defaultValue, const UnorderedSet& widgets = {}, const typename EE::UI::UIDataBind::Converter& converter = EE::UI::UIDataBind::converterDefault(), const std::string& valueKey = "value", @@ -22,7 +49,7 @@ template class UIProperty { mValue( std::move( defaultValue ) ), mBindedData( &mValue, widgets, converter, valueKey, eventType ) {} - UIProperty( const std::unordered_set& widgets = {}, + UIProperty( const UnorderedSet& widgets = {}, const typename EE::UI::UIDataBind::Converter& converter = EE::UI::UIDataBind::converterDefault(), const std::string& valueKey = "value", @@ -36,19 +63,94 @@ template class UIProperty { const Event::EventType& eventType = Event::OnValueChange ) : mBindedData( &mValue, widget, converter, valueKey, eventType ) {} - void operator=( const T& newVal ) { mBindedData.set( newVal ); } + UIProperty& operator=( const T& newVal ) { + mBindedData.set( newVal ); + return *this; + } - void operator=( T&& newVal ) noexcept { mBindedData.set( std::move( newVal ) ); } + UIProperty& operator=( T&& newVal ) noexcept { + mBindedData.set( std::move( newVal ) ); + return *this; + } + + /** @name Value mutation + * Compound assignment propagates through the binding like assignment. Arithmetic properties + * support the conventional numeric mutations; std::string and String properties support + * concatenation. + * @{ */ + template && !std::is_same_v) || + std::is_same_v || std::is_same_v, + int> = 0> + UIProperty& operator+=( const T& operand ) { + return *this = value() + operand; + } + + template < + typename U = T, + std::enable_if_t || std::is_same_v, int> = 0> + T operator+( const T& operand ) const { + return value() + operand; + } + + template && !std::is_same_v, int> = 0> + UIProperty& operator-=( const T& operand ) { + return *this = value() - operand; + } + + template && !std::is_same_v, int> = 0> + UIProperty& operator*=( const T& operand ) { + return *this = value() * operand; + } + + template && !std::is_same_v, int> = 0> + UIProperty& operator/=( const T& operand ) { + return *this = value() / operand; + } + + template && !std::is_same_v, int> = 0> + UIProperty& operator++() { + return *this += 1; + } + + template && !std::is_same_v, int> = 0> + T operator++( int ) { + T previous = value(); + ++( *this ); + return previous; + } + + template && !std::is_same_v, int> = 0> + UIProperty& operator--() { + return *this -= 1; + } + + template && !std::is_same_v, int> = 0> + T operator--( int ) { + T previous = value(); + --( *this ); + return previous; + } + /** @} */ const T& value() const { return mBindedData.get(); } const UIDataBind& databind() const { return mBindedData; } + /** @brief Connects another widget to this property's value. */ UIProperty& connect( UIWidget* widget ) { mBindedData.bind( widget ); return *this; } + /** @brief Disconnects a widget from this property's value. */ UIProperty& disconnect( UIWidget* widget ) { mBindedData.unbind( widget ); return *this; @@ -60,6 +162,7 @@ template class UIProperty { operator const T&() const noexcept { return value(); } + /** @brief Sets the callback invoked after the synchronized value changes. */ UIProperty& changed( const std::function& fn ) { mBindedData.onValueChangeCb = fn; return *this; diff --git a/include/eepp/ui/uivaluebinding.hpp b/include/eepp/ui/uivaluebinding.hpp new file mode 100644 index 000000000..83ae23ad7 --- /dev/null +++ b/include/eepp/ui/uivaluebinding.hpp @@ -0,0 +1,133 @@ +#ifndef EE_UI_UIVALUEBINDING_HPP +#define EE_UI_UIVALUEBINDING_HPP + +#include +#include +#include +#include + +namespace EE { namespace UI { + +/** + * @brief Move-only two-way binding between an ObservableValue and a UIWidget property. + * + * The binding synchronizes the observable's current value into the widget immediately. Later + * observable changes update the widget, and the selected widget event converts the property back + * into the observable. Destroying the binding disconnects both directions. Destroying either the + * observable or widget first is safe and does not keep that endpoint alive. + * + * Synchronization is immediate and single-threaded. The observable, widget, and binding must all be + * used on the widget's owning UI thread. + * + * Use UIValueBinding when an ObservableValue belongs to a UI-independent model. The returned + * binding must be retained for as long as synchronization is desired. + * + * @code + * ObservableValue userName{ "Ada" }; + * auto binding = bindValue( userName, textInput, + * UIValueConverter::converterString(), + * "text", Event::OnTextChanged ); + * userName = "Grace"; // Updates textInput without coupling the model to UIWidget. + * @endcode + */ +template class UIValueBinding { + public: + using Converter = UIValueConverter; + static Converter converterDefault() { return Converter::converterDefault(); } + + UIValueBinding() = default; + UIValueBinding( const UIValueBinding& ) = delete; + UIValueBinding& operator=( const UIValueBinding& ) = delete; + UIValueBinding( UIValueBinding&& ) noexcept = default; + UIValueBinding& operator=( UIValueBinding&& ) noexcept = default; + + UIValueBinding( ObservableValue& value, UIWidget* widget, + const Converter& converter = converterDefault(), + const std::string& propertyName = "value", + Event::EventType eventType = Event::OnValueChange ) { + connect( value, widget, converter, propertyName, eventType ); + } + + void disconnect() { mState.reset(); } + explicit operator bool() const { return mState && mState->widget && mState->value; } + + private: + struct State { + typename ObservableValue::WeakHandle value; + UIWidget* widget{ nullptr }; + const PropertyDefinition* property{ nullptr }; + Converter converter; + bool synchronizing{ false }; + typename ObservableValue::Connection valueConnection; + EventConnectionList widgetConnections; + + bool applyToWidget( const T& newValue ) { + if ( !widget ) + return false; + std::string string; + if ( !converter.fromValue( property, string, newValue ) ) { + Log::error( "UIValueBinding: unable to convert observable value to string." ); + return false; + } + synchronizing = true; + widget->applyProperty( StyleSheetProperty( property, string ) ); + synchronizing = false; + return true; + } + }; + + void connect( ObservableValue& value, UIWidget* widget, const Converter& converter, + const std::string& propertyName, Event::EventType eventType ) { + eeASSERT( widget != nullptr ); + auto state = std::make_shared(); + state->value = value.weakHandle(); + state->widget = widget; + state->property = StyleSheetSpecification::instance()->getProperty( propertyName ); + state->converter = converter; + eeASSERT( state->property != nullptr ); + eeASSERT( state->converter.toValue && state->converter.fromValue ); + + std::weak_ptr weakState = state; + state->valueConnection = value.observe( [weakState]( const T& newValue ) { + if ( auto state = weakState.lock() ) + state->applyToWidget( newValue ); + } ); + state->widgetConnections += widget->connect( eventType, [weakState]( const Event* event ) { + if ( auto state = weakState.lock(); state && !state->synchronizing ) { + T newValue; + if ( state->converter.toValue( + state->property, newValue, + event->getNode()->asType()->getPropertyString( + state->property ) ) && + !state->value.set( std::move( newValue ) ) ) { + state->widget = nullptr; + state->widgetConnections.clear(); + } + } + } ); + state->widgetConnections += widget->connect( Event::OnClose, [weakState]( const Event* ) { + if ( auto state = weakState.lock() ) { + state->widget = nullptr; + state->valueConnection.disconnect(); + state->widgetConnections.clear(); + } + } ); + state->applyToWidget( value.get() ); + mState = std::move( state ); + } + + std::shared_ptr mState; +}; + +/** @brief Creates a scoped two-way binding between @p value and @p widget. */ +template +UIValueBinding bindValue( + ObservableValue& value, UIWidget* widget, + const typename UIValueBinding::Converter& converter = UIValueBinding::converterDefault(), + const std::string& propertyName = "value", Event::EventType eventType = Event::OnValueChange ) { + return UIValueBinding( value, widget, converter, propertyName, eventType ); +} + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uivalueconverter.hpp b/include/eepp/ui/uivalueconverter.hpp new file mode 100644 index 000000000..fdfdd688f --- /dev/null +++ b/include/eepp/ui/uivalueconverter.hpp @@ -0,0 +1,104 @@ +#ifndef EE_UI_UIVALUECONVERTER_HPP +#define EE_UI_UIVALUECONVERTER_HPP + +#include +#include +#include +#include +#include +#include +#include + +namespace EE { namespace UI { + +/** + * @brief Binding-independent conversion between a typed value and a widget property string. + * + * UIDataBind and UIValueBinding share this policy type, so custom conversions do not depend on + * either ownership model. The PropertyDefinition describes the widget property being synchronized + * and can be used for CSS-aware parsing. + * + * @code + * auto converter = UIValueConverter( + * []( const CSS::PropertyDefinition*, MyEnum& value, const std::string& text ) { + * return enumFromString( value, text ); + * }, + * []( const CSS::PropertyDefinition*, std::string& text, const MyEnum& value ) { + * text = enumToString( value ); + * return true; + * } ); + * @endcode + */ +template struct UIValueConverter { + using ToValue = std::function; + using FromValue = std::function; + + UIValueConverter() = default; + UIValueConverter( ToValue toValue, FromValue fromValue ) : + toValue( std::move( toValue ) ), fromValue( std::move( fromValue ) ) {} + + ToValue toValue; + FromValue fromValue; + + static UIValueConverter converterDefault() { + return UIValueConverter( + []( const CSS::PropertyDefinition* property, T& value, const std::string& string ) { + if constexpr ( std::is_same_v || std::is_same_v ) { + value = T( string ); + return true; + } else if constexpr ( std::is_same_v ) { + value = CSS::StyleSheetProperty( property, string ).asBool(); + return true; + } else { + return String::fromString( value, string ); + } + }, + []( const CSS::PropertyDefinition*, std::string& string, const T& value ) { + if constexpr ( std::is_same_v ) { + string = value; + } else if constexpr ( std::is_same_v ) { + string = value.toUtf8(); + } else if constexpr ( std::is_same_v ) { + string = String::fromDouble( value ); + } else if constexpr ( std::is_same_v ) { + string = String::fromFloat( value ); + } else if constexpr ( std::is_same_v ) { + string = value ? "true" : "false"; + } else { + string = String::toString( value ); + } + return true; + } ); + } + + static UIValueConverter converterString() { + return UIValueConverter( + []( const CSS::PropertyDefinition*, T& value, const std::string& string ) { + value = T( string ); + return true; + }, + []( const CSS::PropertyDefinition*, std::string& string, const T& value ) { + if constexpr ( std::is_same_v ) + string = value.toUtf8(); + else + string = value; + return true; + } ); + } + + static UIValueConverter converterBool() { + return UIValueConverter( + []( const CSS::PropertyDefinition* property, T& value, const std::string& string ) { + value = CSS::StyleSheetProperty( property, string ).asBool(); + return true; + }, + []( const CSS::PropertyDefinition*, std::string& string, const T& value ) { + string = value ? "true" : "false"; + return true; + } ); + } +}; + +}} // namespace EE::UI + +#endif diff --git a/src/eepp/scene/eventconnection.cpp b/src/eepp/scene/eventconnection.cpp new file mode 100644 index 000000000..61951b8da --- /dev/null +++ b/src/eepp/scene/eventconnection.cpp @@ -0,0 +1,74 @@ +#include +#include + +namespace EE { namespace Scene { + +EventConnection::EventConnection( std::weak_ptr state, Uint32 eventType, + Uint32 callbackId ) : + mState( std::move( state ) ), mEventType( eventType ), mCallbackId( callbackId ) {} + +EventConnection::~EventConnection() { + disconnect(); +} + +EventConnection::EventConnection( EventConnection&& other ) noexcept : + mState( std::move( other.mState ) ), + mEventType( other.mEventType ), + mCallbackId( other.mCallbackId ) { + other.mEventType = 0; + other.mCallbackId = 0; +} + +EventConnection& EventConnection::operator=( EventConnection&& other ) noexcept { + if ( this != &other ) { + disconnect(); + mState = std::move( other.mState ); + mEventType = other.mEventType; + mCallbackId = other.mCallbackId; + other.mEventType = 0; + other.mCallbackId = 0; + } + return *this; +} + +void EventConnection::disconnect() { + if ( mCallbackId != 0 ) { + if ( auto state = mState.lock() ) + state->remove( mEventType, mCallbackId ); + } + mState.reset(); + mEventType = 0; + mCallbackId = 0; +} + +EventConnection::operator bool() const { + if ( mCallbackId != 0 ) { + if ( auto state = mState.lock() ) + return state->contains( mEventType, mCallbackId ); + } + return false; +} + +EventConnectionList& EventConnectionList::add( EventConnection connection ) { + mConnections.emplace_back( std::move( connection ) ); + return *this; +} + +EventConnectionList& EventConnectionList::operator+=( EventConnection connection ) { + add( std::move( connection ) ); + return *this; +} + +void EventConnectionList::clear() { + mConnections.clear(); +} + +bool EventConnectionList::empty() const { + return mConnections.empty(); +} + +size_t EventConnectionList::size() const { + return mConnections.size(); +} + +}} // namespace EE::Scene diff --git a/src/eepp/scene/eventconnectionstate.hpp b/src/eepp/scene/eventconnectionstate.hpp new file mode 100644 index 000000000..db2f572f0 --- /dev/null +++ b/src/eepp/scene/eventconnectionstate.hpp @@ -0,0 +1,81 @@ +#ifndef EE_SCENE_EVENTCONNECTIONSTATE_HPP +#define EE_SCENE_EVENTCONNECTIONSTATE_HPP + +#include +#include +#include +#include +#include + +namespace EE { namespace Scene { + +// Nodes own this registry strongly while EventConnection observes it weakly. Keeping callbacks in +// the shared state, rather than keeping a Node pointer in each connection, makes emitter-first +// destruction safe without relying on UIWidget::OnClose or extending the emitter's lifetime. +struct EventConnectionState { + using EventCallback = std::function; + + struct EventListener { + Uint32 id; + EventCallback callback; + }; + + // Listener IDs increase monotonically, so appending preserves registration order and keeps the + // collection sorted for lookup while avoiding one allocation per listener. + using EventListeners = SmallVector; + using EventsMap = UnorderedMap; + + Uint32 nextId{ 0 }; + EventsMap events; + + Uint32 add( Uint32 eventType, EventCallback callback ) { + auto id = ++nextId; + events[eventType].emplace_back( EventListener{ id, std::move( callback ) } ); + return id; + } + + bool contains( Uint32 eventType, Uint32 callbackId ) const { + auto event = events.find( eventType ); + if ( event == events.end() ) + return false; + auto listener = lowerBound( event->second, callbackId ); + return listener != event->second.end() && listener->id == callbackId; + } + + void remove( Uint32 eventType, Uint32 callbackId ) { + auto event = events.find( eventType ); + if ( event != events.end() ) { + auto listener = lowerBound( event->second, callbackId ); + if ( listener != event->second.end() && listener->id == callbackId ) + event->second.erase( listener ); + } + } + + void remove( Uint32 callbackId ) { + for ( auto& event : events ) { + auto listener = lowerBound( event.second, callbackId ); + if ( listener != event.second.end() && listener->id == callbackId ) { + event.second.erase( listener ); + break; + } + } + } + + private: + static EventListeners::iterator lowerBound( EventListeners& listeners, Uint32 callbackId ) { + return std::lower_bound( + listeners.begin(), listeners.end(), callbackId, + []( const EventListener& listener, Uint32 id ) { return listener.id < id; } ); + } + + static EventListeners::const_iterator lowerBound( const EventListeners& listeners, + Uint32 callbackId ) { + return std::lower_bound( + listeners.begin(), listeners.end(), callbackId, + []( const EventListener& listener, Uint32 id ) { return listener.id < id; } ); + } +}; + +}} // namespace EE::Scene + +#endif diff --git a/src/eepp/scene/node.cpp b/src/eepp/scene/node.cpp index 86773024d..cb9feb963 100644 --- a/src/eepp/scene/node.cpp +++ b/src/eepp/scene/node.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -29,8 +30,7 @@ Node::Node() : mNodeFlags( NODE_FLAG_POSITION_DIRTY | NODE_FLAG_POLYGON_DIRTY ), mBlend( BlendMode::Alpha() ), mVisible( true ), - mEnabled( true ), - mNumCallBacks( 0 ) {} + mEnabled( true ) {} Node::~Node() { if ( !SceneManager::instance()->isShuttingDown() && NULL != mSceneNode ) { @@ -1107,13 +1107,20 @@ void Node::updateCenter() { } Uint32 Node::addEventListener( const Uint32& eventType, const EventCallback& callback ) { - mEvents[eventType][++mNumCallBacks] = callback; - return mNumCallBacks; + if ( !mEventConnectionState ) + mEventConnectionState = std::make_shared(); + return mEventConnectionState->add( eventType, callback ); } Uint32 Node::on( const Uint32& eventType, const EventCallback& callback ) { - mEvents[eventType][++mNumCallBacks] = callback; - return mNumCallBacks; + return addEventListener( eventType, callback ); +} + +EventConnection Node::connect( const Uint32& eventType, EventCallback callback ) { + if ( !mEventConnectionState ) + mEventConnectionState = std::make_shared(); + auto callbackId = mEventConnectionState->add( eventType, std::move( callback ) ); + return EventConnection( mEventConnectionState, eventType, callbackId ); } Uint32 Node::onClick( const std::function& callback, @@ -1135,49 +1142,47 @@ Uint32 Node::onDoubleClick( const std::function& call } bool Node::hasEventsOfType( const Uint32& eventType ) const { - return mEvents.find( eventType ) != mEvents.end(); + return mEventConnectionState && + mEventConnectionState->events.find( eventType ) != mEventConnectionState->events.end(); } void Node::removeEventsOfType( const Uint32& eventType ) { - auto it = mEvents.find( eventType ); - if ( it != mEvents.end() ) - mEvents.erase( it ); + if ( mEventConnectionState ) + mEventConnectionState->events.erase( eventType ); } void Node::removeEventListener( const Uint32& callbackId ) { - EventsMap::iterator it; - for ( it = mEvents.begin(); it != mEvents.end(); ++it ) { - auto& event = it->second; - if ( event.erase( callbackId ) > 0 ) - break; - } + if ( mEventConnectionState ) + mEventConnectionState->remove( callbackId ); } void Node::removeEventListener( const std::vector& callbacksIds ) { - for ( auto& event : mEvents ) { - auto& events = event.second; + if ( !mEventConnectionState ) + return; + for ( auto& event : mEventConnectionState->events ) { + auto& listeners = event.second; for ( auto& cbId : callbacksIds ) { - auto it = events.find( cbId ); - if ( it != events.end() ) { - events.erase( it ); - } + auto listener = + std::lower_bound( listeners.begin(), listeners.end(), cbId, + []( const EventConnectionState::EventListener& listener, + Uint32 id ) { return listener.id < id; } ); + if ( listener != listeners.end() && listener->id == cbId ) + listeners.erase( listener ); } } } void Node::clearEventListener() { - mEvents.clear(); + if ( mEventConnectionState ) + mEventConnectionState->events.clear(); } void Node::sendEvent( const Event* event ) { - if ( 0 != mEvents.count( event->getType() ) ) { - auto eventMap = mEvents[event->getType()]; - if ( eventMap.begin() != eventMap.end() ) { - std::map::iterator it; - for ( it = eventMap.begin(); it != eventMap.end(); ++it ) { - const_cast( event )->mCallbackId = it->first; - it->second( event ); - } + if ( mEventConnectionState && 0 != mEventConnectionState->events.count( event->getType() ) ) { + auto listeners = mEventConnectionState->events[event->getType()]; + for ( const auto& listener : listeners ) { + const_cast( event )->mCallbackId = listener.id; + listener.callback( event ); } } } diff --git a/src/eepp/ui/uimenusubmenu.cpp b/src/eepp/ui/uimenusubmenu.cpp index 439b4d31d..738a09ad0 100644 --- a/src/eepp/ui/uimenusubmenu.cpp +++ b/src/eepp/ui/uimenusubmenu.cpp @@ -27,8 +27,6 @@ UIMenuSubMenu::UIMenuSubMenu() : UIMenuSubMenu::~UIMenuSubMenu() { if ( mSubMenu ) { - if ( mSubMenuCloseCb ) - mSubMenu->removeEventListener( mSubMenuCloseCb ); mSubMenu->setOwnerNode( nullptr ); } } @@ -83,14 +81,14 @@ UIWidget* UIMenuSubMenu::getExtraInnerWidget() const { void UIMenuSubMenu::setSubMenu( UIMenu* subMenu ) { if ( nullptr != mSubMenu && mSubMenu != subMenu ) { getActionManager()->removeActionsByTagFromTarget( this, String::hash( "subMenu" ) ); - if ( mSubMenuCloseCb ) - mSubMenu->removeEventListener( mSubMenuCloseCb ); + mSubMenuCloseConnection.disconnect(); mSubMenu->setOwnerNode( nullptr ); } mSubMenu = subMenu; if ( nullptr != mSubMenu ) { mSubMenu->setOwnerNode( this ); - mSubMenuCloseCb = mSubMenu->on( Event::OnClose, [this]( auto ) { mSubMenu = nullptr; } ); + mSubMenuCloseConnection = + mSubMenu->connect( Event::OnClose, [this]( auto ) { mSubMenu = nullptr; } ); } } @@ -161,6 +159,7 @@ void UIMenuSubMenu::setMouseOverTimeShowMenu( const Time& maxTime ) { void UIMenuSubMenu::onClose() { if ( mSubMenu ) mSubMenu->setOwnerNode( nullptr ); + UIMenuItem::onClose(); } }} // namespace EE::UI diff --git a/src/examples/7guis/counter/counter.cpp b/src/examples/7guis/counter/counter.cpp index 01e9fc8cf..23d0caec7 100644 --- a/src/examples/7guis/counter/counter.cpp +++ b/src/examples/7guis/counter/counter.cpp @@ -9,10 +9,7 @@ EE_MAIN_FUNC int main( int, char** ) { )xml" ); - int count = 0; - auto view = hbox->find( "count_view" ); - hbox->find( "count_click" )->onClick( [&count, view]( auto ) { - view->setText( String::toString( ++count ) ); - } ); + UIProperty count( 0, hbox->find( "count_view" ) ); + hbox->find( "count_click" )->onClick( [&count]( auto ) { ++count; } ); return app.run(); } diff --git a/src/tests/unit_tests/eventconnection_tests.cpp b/src/tests/unit_tests/eventconnection_tests.cpp new file mode 100644 index 000000000..9fb6dea0b --- /dev/null +++ b/src/tests/unit_tests/eventconnection_tests.cpp @@ -0,0 +1,169 @@ +#include "utest.h" +#include + +using namespace EE; +using namespace EE::Scene; + +namespace { + +class EventEmitter : public Node { + public: + void emit( Uint32 eventType ) { sendCommonEvent( eventType ); } +}; + +constexpr Uint32 TestEvent = Event::UserEvent; + +} // namespace + +UTEST( EventConnection, disconnectsWhenDestroyed ) { + EventEmitter emitter; + int callbackCount = 0; + { + auto connection = emitter.connect( TestEvent, [&]( const Event* ) { ++callbackCount; } ); + EXPECT_TRUE( static_cast( connection ) ); + emitter.emit( TestEvent ); + } + + emitter.emit( TestEvent ); + EXPECT_EQ( callbackCount, 1 ); +} + +UTEST( EventConnection, expiresWhenEmitterIsDestroyed ) { + auto emitter = eeNew( EventEmitter, () ); + auto connection = emitter->connect( TestEvent, []( const Event* ) {} ); + EXPECT_TRUE( static_cast( connection ) ); + + eeDelete( emitter ); + EXPECT_FALSE( static_cast( connection ) ); + connection.disconnect(); +} + +UTEST( EventConnection, reflectsListenerRemovalByEmitter ) { + EventEmitter emitter; + auto connection = emitter.connect( TestEvent, []( const Event* ) {} ); + EXPECT_TRUE( static_cast( connection ) ); + + emitter.removeEventsOfType( TestEvent ); + EXPECT_FALSE( static_cast( connection ) ); +} + +UTEST( EventConnection, moveAssignmentDisconnectsPreviousListener ) { + EventEmitter firstEmitter; + EventEmitter secondEmitter; + int firstCallbackCount = 0; + int secondCallbackCount = 0; + EventConnection connection = + firstEmitter.connect( TestEvent, [&]( const Event* ) { ++firstCallbackCount; } ); + + connection = secondEmitter.connect( TestEvent, [&]( const Event* ) { ++secondCallbackCount; } ); + firstEmitter.emit( TestEvent ); + secondEmitter.emit( TestEvent ); + + EXPECT_EQ( firstCallbackCount, 0 ); + EXPECT_EQ( secondCallbackCount, 1 ); +} + +UTEST( EventConnectionList, disconnectsAllListeners ) { + EventEmitter emitter; + int callbackCount = 0; + EventConnectionList connections; + connections += emitter.connect( TestEvent, [&]( const Event* ) { ++callbackCount; } ); + connections += emitter.connect( TestEvent, [&]( const Event* ) { ++callbackCount; } ); + EXPECT_EQ( connections.size(), 2u ); + + emitter.emit( TestEvent ); + connections.clear(); + emitter.emit( TestEvent ); + + EXPECT_EQ( callbackCount, 2 ); + EXPECT_TRUE( connections.empty() ); +} + +UTEST( EventConnection, callbackCanDisconnectItselfDuringDispatch ) { + EventEmitter emitter; + int callbackCount = 0; + EventConnection connection; + connection = emitter.connect( TestEvent, [&]( const Event* ) { + ++callbackCount; + connection.disconnect(); + } ); + + emitter.emit( TestEvent ); + emitter.emit( TestEvent ); + + EXPECT_EQ( callbackCount, 1 ); + EXPECT_FALSE( static_cast( connection ) ); +} + +UTEST( EventConnection, callbackCanDisconnectAnotherDuringDispatch ) { + EventEmitter emitter; + int disconnectedCallbackCount = 0; + EventConnection disconnectedConnection; + auto disconnectingConnection = + emitter.connect( TestEvent, [&]( const Event* ) { disconnectedConnection.disconnect(); } ); + disconnectedConnection = + emitter.connect( TestEvent, [&]( const Event* ) { ++disconnectedCallbackCount; } ); + + emitter.emit( TestEvent ); + emitter.emit( TestEvent ); + + // Dispatch uses a snapshot, so removal takes effect on the following dispatch. + EXPECT_EQ( disconnectedCallbackCount, 1 ); +} + +UTEST( EventConnection, coexistsWithLegacyNumericListener ) { + EventEmitter emitter; + int scopedCallbackCount = 0; + int legacyCallbackCount = 0; + auto legacyId = emitter.on( TestEvent, [&]( const Event* ) { ++legacyCallbackCount; } ); + auto connection = emitter.connect( TestEvent, [&]( const Event* ) { ++scopedCallbackCount; } ); + + connection.disconnect(); + emitter.emit( TestEvent ); + EXPECT_EQ( scopedCallbackCount, 0 ); + EXPECT_EQ( legacyCallbackCount, 1 ); + + emitter.removeEventListener( legacyId ); + emitter.emit( TestEvent ); + EXPECT_EQ( legacyCallbackCount, 1 ); +} + +UTEST( EventConnection, clearEventListenerInvalidatesConnection ) { + EventEmitter emitter; + auto connection = emitter.connect( TestEvent, []( const Event* ) {} ); + + emitter.clearEventListener(); + + EXPECT_FALSE( static_cast( connection ) ); +} + +UTEST( EventConnection, emitterCanBeDestroyedDuringCallback ) { + auto emitter = eeNew( EventEmitter, () ); + int callbackCount = 0; + auto connection = emitter->connect( TestEvent, [&]( const Event* ) { + ++callbackCount; + eeDelete( emitter ); + emitter = nullptr; + } ); + + emitter->emit( TestEvent ); + + EXPECT_EQ( callbackCount, 1 ); + EXPECT_TRUE( emitter == nullptr ); + EXPECT_FALSE( static_cast( connection ) ); +} + +UTEST( EventConnection, dispatchesListenersInRegistrationOrder ) { + EventEmitter emitter; + std::vector order; + auto third = emitter.connect( TestEvent, [&]( const Event* ) { order.emplace_back( 3 ); } ); + auto first = emitter.connect( TestEvent, [&]( const Event* ) { order.emplace_back( 1 ); } ); + auto second = emitter.connect( TestEvent, [&]( const Event* ) { order.emplace_back( 2 ); } ); + + emitter.emit( TestEvent ); + + EXPECT_EQ( order.size(), 3u ); + EXPECT_EQ( order[0], 3 ); + EXPECT_EQ( order[1], 1 ); + EXPECT_EQ( order[2], 2 ); +} diff --git a/src/tests/unit_tests/observablevalue_tests.cpp b/src/tests/unit_tests/observablevalue_tests.cpp new file mode 100644 index 000000000..a9c8ca4eb --- /dev/null +++ b/src/tests/unit_tests/observablevalue_tests.cpp @@ -0,0 +1,92 @@ +#include "utest.h" +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::UI; + +UTEST( ObservableValue, notifiesUntilConnectionIsDestroyed ) { + ObservableValue value( 1 ); + int observed = 0; + { + auto connection = value.observe( [&]( const int& newValue ) { observed = newValue; } ); + value = 2; + EXPECT_EQ( observed, 2 ); + } + + value = 3; + EXPECT_EQ( observed, 2 ); +} + +UTEST( ObservableValue, connectionsFollowMovedValueAndExpireWithIt ) { + typename ObservableValue::Connection connection; + int observed = 0; + { + ObservableValue original( 1 ); + connection = original.observe( [&]( const int& newValue ) { observed = newValue; } ); + ObservableValue moved( std::move( original ) ); + moved = 2; + EXPECT_EQ( observed, 2 ); + } + EXPECT_FALSE( static_cast( connection ) ); +} + +UTEST( ObservableValue, canBeDestroyedDuringNotification ) { + auto value = std::make_unique>( 1 ); + typename ObservableValue::Connection destroyingConnection; + destroyingConnection = value->observe( [&]( const int& ) { value.reset(); } ); + int observed = 0; + auto remainingConnection = + value->observe( [&]( const int& newValue ) { observed = newValue; } ); + + *value = 2; + + EXPECT_TRUE( value == nullptr ); + EXPECT_EQ( observed, 2 ); + EXPECT_FALSE( static_cast( destroyingConnection ) ); + EXPECT_FALSE( static_cast( remainingConnection ) ); +} + +UTEST( ObservableValue, notifiesObserversInRegistrationOrder ) { + ObservableValue value( 0 ); + std::vector order; + auto third = value.observe( [&]( const int& ) { order.emplace_back( 3 ); } ); + auto first = value.observe( [&]( const int& ) { order.emplace_back( 1 ); } ); + auto second = value.observe( [&]( const int& ) { order.emplace_back( 2 ); } ); + + value = 1; + + EXPECT_EQ( order.size(), 3u ); + EXPECT_EQ( order[0], 3 ); + EXPECT_EQ( order[1], 1 ); + EXPECT_EQ( order[2], 2 ); +} + +UTEST( UIValueBinding, synchronizesBothDirectionsAndHandlesEndpointLifetimes ) { + UIApplication app( + WindowSettings( 320, 240, "eepp - UIValueBinding Test", WindowStyle::Default, + WindowBackend::Default, 32 ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto widget = UICheckBox::New(); + ObservableValue value( true ); + auto binding = bindValue( value, widget ); + + EXPECT_TRUE( widget->isChecked() ); + value = false; + EXPECT_FALSE( widget->isChecked() ); + widget->setChecked( true ); + EXPECT_TRUE( value.get() ); + + { + ObservableValue temporary( false ); + binding = bindValue( temporary, widget ); + EXPECT_TRUE( static_cast( binding ) ); + } + EXPECT_FALSE( static_cast( binding ) ); + + eeDelete( widget ); + EXPECT_FALSE( static_cast( binding ) ); +} diff --git a/src/tests/unit_tests/uidatabind_tests.cpp b/src/tests/unit_tests/uidatabind_tests.cpp new file mode 100644 index 000000000..6ff34cea0 --- /dev/null +++ b/src/tests/unit_tests/uidatabind_tests.cpp @@ -0,0 +1,121 @@ +#include "utest.h" +#include +#include +#include +#include + +using namespace EE; +using namespace EE::UI; + +UTEST( UIProperty, defaultConstructionOwnsUsableValue ) { + UIProperty property; + EXPECT_EQ( property.value(), 0 ); + + property = 42; + EXPECT_EQ( property.value(), 42 ); +} + +UTEST( UIProperty, rvalueAssignmentReportsStoredValue ) { + UIProperty property; + std::string reportedValue; + property.changed( [&]( const std::string& value ) { reportedValue = value; } ); + + property = std::string( "updated" ); + EXPECT_TRUE( property.value() == "updated" ); + EXPECT_TRUE( reportedValue == "updated" ); +} + +UTEST( UIProperty, numericMutationOperatorsPropagateAndReturnExpectedValues ) { + UIProperty property( 2 ); + int reportedValue = 0; + property.changed( [&]( const int& value ) { reportedValue = value; } ); + + ++property; + EXPECT_EQ( property.value(), 3 ); + EXPECT_EQ( reportedValue, 3 ); + EXPECT_EQ( property++, 3 ); + EXPECT_EQ( property.value(), 4 ); + property += 3; + EXPECT_EQ( property.value(), 7 ); + property -= 2; + EXPECT_EQ( property.value(), 5 ); + property *= 4; + EXPECT_EQ( property.value(), 20 ); + property /= 5; + EXPECT_EQ( property.value(), 4 ); + --property; + EXPECT_EQ( property.value(), 3 ); + EXPECT_EQ( property--, 3 ); + EXPECT_EQ( property.value(), 2 ); +} + +UTEST( UIProperty, stringConcatenationPropagatesForStandardAndEEStrings ) { + UIProperty standardString( std::string( "hello" ) ); + std::string reportedStandardString; + standardString.changed( [&]( const std::string& value ) { reportedStandardString = value; } ); + standardString += std::string( " world" ); + EXPECT_TRUE( standardString.value() == "hello world" ); + EXPECT_TRUE( reportedStandardString == "hello world" ); + EXPECT_TRUE( standardString + std::string( "!" ) == "hello world!" ); + + UIProperty eeString( String( "hello" ) ); + String reportedEEString; + eeString.changed( [&]( const String& value ) { reportedEEString = value; } ); + eeString += String( " eepp" ); + EXPECT_TRUE( eeString.value() == String( "hello eepp" ) ); + EXPECT_TRUE( reportedEEString == String( "hello eepp" ) ); + EXPECT_TRUE( eeString + String( "!" ) == String( "hello eepp!" ) ); +} + +UTEST( UIDataBind, defaultStringConverterReadsWidgetValue ) { + auto converter = UIDataBind::converterDefault(); + std::string value; + EXPECT_TRUE( converter.toValue( nullptr, value, "widget value" ) ); + EXPECT_TRUE( value == "widget value" ); +} + +UTEST( UIDataBind, lateBoundWidgetReceivesValueAndCanDieFirst ) { + UIApplication app( + WindowSettings( 320, 240, "eepp - UIDataBind Test", WindowStyle::Default, + WindowBackend::Default, 32 ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + bool value = false; + UIDataBind binding( &value, UnorderedSet{}, + UIDataBind::converterBool() ); + auto widget = UICheckBox::New(); + widget->setChecked( true ); + + binding.bind( widget ); + EXPECT_FALSE( widget->isChecked() ); + EXPECT_EQ( binding.getWidgets().size(), 1u ); + + eeDelete( widget ); + EXPECT_TRUE( binding.getWidgets().empty() ); +} + +UTEST( UIDataBind, supportsMultipleWidgetsAndDisconnectsWhenBindingDiesFirst ) { + UIApplication app( + WindowSettings( 320, 240, "eepp - UIDataBind Test", WindowStyle::Default, + WindowBackend::Default, 32 ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + bool value = true; + auto firstWidget = UICheckBox::New(); + auto secondWidget = UICheckBox::New(); + { + UIDataBind binding( &value, UnorderedSet{ firstWidget, secondWidget }, + UIDataBind::converterBool() ); + EXPECT_TRUE( firstWidget->isChecked() ); + EXPECT_TRUE( secondWidget->isChecked() ); + EXPECT_EQ( binding.getWidgets().size(), 2u ); + + binding.set( false ); + EXPECT_FALSE( firstWidget->isChecked() ); + EXPECT_FALSE( secondWidget->isChecked() ); + } + + firstWidget->setChecked( true ); + secondWidget->setChecked( true ); + EXPECT_FALSE( value ); + eeDelete( firstWidget ); + eeDelete( secondWidget ); +} diff --git a/src/tools/ecode/uibuildsettings.cpp b/src/tools/ecode/uibuildsettings.cpp index 926ea9c0f..ea3c0ee71 100644 --- a/src/tools/ecode/uibuildsettings.cpp +++ b/src/tools/ecode/uibuildsettings.cpp @@ -127,9 +127,9 @@ class UICustomOutputParserWindow : public UIWindow { UIDropDownList* cpTypeddl = find( "custom_parser_type" ); UIDataBind::Converter projectOutputParserTypesConverter( - []( const UIDataBind* databind, ProjectOutputParserTypes& val, + []( const PropertyDefinition* property, ProjectOutputParserTypes& val, const std::string& str ) -> bool { - auto v = StyleSheetProperty( databind->getPropertyDefinition(), str ).asString(); + auto v = StyleSheetProperty( property, str ).asString(); Uint32 idx; if ( String::fromString( idx, v ) && idx >= 0 && idx <= 2 ) { val = (ProjectOutputParserTypes)idx; @@ -137,7 +137,7 @@ class UICustomOutputParserWindow : public UIWindow { } return false; }, - [cpTypeddl]( const UIDataBind*, std::string& str, + [cpTypeddl]( const PropertyDefinition*, std::string& str, const ProjectOutputParserTypes& val ) -> bool { str = cpTypeddl->getListBox()->getItem( (Uint32)val )->getText(); return true; @@ -1096,6 +1096,10 @@ void UIBuildSettings::runRemove( bool all, UIDropDownList* runList, if ( runList->getListBox()->isEmpty() ) return; + auto runStep = find( "run_cont" )->findByClass( String::toString( 0 ) ); + // UIDataBind does not own its model pointers. Disconnect before erasing the selected step. + runStep->clearBindings(); + if ( all ) { runList->getListBox()->clear(); if ( panelRunListDDL ) @@ -1109,10 +1113,15 @@ void UIBuildSettings::runRemove( bool all, UIDropDownList* runList, panelRunListDDL->getListBox()->removeListBoxItem( name ); } - runUpdate( false, runList, panelRunListDDL ); + if ( mBuild.mRun.empty() ) + runStep->updateStep( 0, nullptr ); - if ( all ) + if ( all ) { runSelect(); + runUpdate( true, runList, panelRunListDDL ); + } else { + runUpdate( false, runList, panelRunListDDL ); + } } void UIBuildSettings::runUpdate( bool recreateList, UIDropDownList* runList,