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,