mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-10-01 10:40:20 +03:00
Add reactive UI data binding and observable collections
Introduce observable and computed values, typed widget bindings, validation groups, UI properties, commands, and UI-thread delivery. Add observable vector and list model support for incrementally updated collections, with filtering and source-row mapping. Modernize the Flight Booker and CRUD examples and add focused data handling and collection examples demonstrating the new APIs. Organize the UI binding headers under ui/databinding, expand test coverage for notification and lifetime semantics, and document API selection, validation, ownership, threading, and performance behavior.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
#ifndef EE_CORE_COMPUTEDVALUE_HPP
|
||||
#define EE_CORE_COMPUTEDVALUE_HPP
|
||||
|
||||
#include <eepp/core/observablevalue.hpp>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
|
||||
namespace EE {
|
||||
|
||||
/**
|
||||
* @brief A read-only observable whose value is synchronously derived from explicit dependencies.
|
||||
*
|
||||
* Dependencies are observed in argument order. A dependency change updates its cached value and
|
||||
* recomputes the result before the dependency's set() returns. Destroying a dependency leaves the
|
||||
* computed value at its last result; destroying either endpoint safely expires scoped observers.
|
||||
* ComputedValue is single-threaded, like ObservableValue.
|
||||
*/
|
||||
template <typename T, typename Calculator, typename... Dependencies> class ComputedValue {
|
||||
private:
|
||||
using Values = std::tuple<typename Dependencies::ValueType...>;
|
||||
using Connections = std::tuple<typename Dependencies::Connection...>;
|
||||
|
||||
struct State {
|
||||
State( Calculator calculator, Values values ) :
|
||||
calculator( std::move( calculator ) ),
|
||||
values( std::move( values ) ),
|
||||
output( calculate() ) {}
|
||||
|
||||
T calculate() {
|
||||
return std::apply( [this]( const auto&... value ) { return calculator( value... ); },
|
||||
values );
|
||||
}
|
||||
|
||||
void recompute() { output.set( calculate() ); }
|
||||
|
||||
Calculator calculator;
|
||||
Values values;
|
||||
ObservableValue<T> output;
|
||||
};
|
||||
|
||||
template <std::size_t... Is>
|
||||
ComputedValue( Calculator calculator, std::index_sequence<Is...>,
|
||||
Dependencies&... dependencies ) :
|
||||
mState(
|
||||
std::make_shared<State>( std::move( calculator ), Values( dependencies.get()... ) ) ) {
|
||||
// The comma fold guarantees dependency registration follows argument order.
|
||||
( ( std::get<Is>( mConnections ) = connect<Is>( dependencies ) ), ... );
|
||||
}
|
||||
|
||||
template <std::size_t I, typename Dependency>
|
||||
typename Dependency::Connection connect( Dependency& dependency ) {
|
||||
std::weak_ptr<State> weakState = mState;
|
||||
return dependency.observe( [weakState]( const typename Dependency::ValueType& value ) {
|
||||
if ( auto state = weakState.lock() ) {
|
||||
std::get<I>( state->values ) = value;
|
||||
state->recompute();
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
public:
|
||||
using ValueType = T;
|
||||
using Callback = typename ObservableValue<T>::Callback;
|
||||
using Connection = typename ObservableValue<T>::Connection;
|
||||
|
||||
/**
|
||||
* @brief Creates a computed value and evaluates @p calculator once from the current
|
||||
* dependencies.
|
||||
*
|
||||
* The calculator receives the dependency values as const references in the same order in which
|
||||
* the dependencies are passed. Every dependency must outlive this object if further updates are
|
||||
* expected from it.
|
||||
*/
|
||||
ComputedValue( Calculator calculator, Dependencies&... dependencies ) :
|
||||
ComputedValue( std::move( calculator ), std::index_sequence_for<Dependencies...>{},
|
||||
dependencies... ) {}
|
||||
ComputedValue( const ComputedValue& ) = delete;
|
||||
ComputedValue& operator=( const ComputedValue& ) = delete;
|
||||
ComputedValue( ComputedValue&& ) noexcept = default;
|
||||
ComputedValue& operator=( ComputedValue&& ) noexcept = default;
|
||||
|
||||
/** @return The most recently calculated value. */
|
||||
const T& get() const { return mState->output.get(); }
|
||||
|
||||
const T& operator*() const { return get(); }
|
||||
|
||||
const T* operator->() const { return &get(); }
|
||||
|
||||
operator const T&() const { return get(); }
|
||||
|
||||
/**
|
||||
* @brief Observes later changes to the calculated value.
|
||||
* @return A scoped connection; destroying it disconnects the callback.
|
||||
*
|
||||
* The callback is not invoked immediately. Read get() when the initial value is needed.
|
||||
*/
|
||||
Connection observe( Callback callback ) {
|
||||
return mState->output.observe( std::move( callback ) );
|
||||
}
|
||||
|
||||
/** @return The number of observers currently attached to the calculated output. */
|
||||
std::size_t observerCount() const { return mState->output.observerCount(); }
|
||||
|
||||
/** @return The number of observable dependencies captured by this computed value. */
|
||||
static constexpr std::size_t dependencyCount() { return sizeof...( Dependencies ); }
|
||||
|
||||
private:
|
||||
std::shared_ptr<State> mState;
|
||||
Connections mConnections;
|
||||
};
|
||||
|
||||
template <typename Calculator, typename... Dependencies>
|
||||
/** @brief Creates a ComputedValue while deducing its result, calculator, and dependency types. */
|
||||
auto makeComputedValue( Calculator&& calculator, Dependencies&... dependencies ) {
|
||||
using StoredCalculator = std::decay_t<Calculator>;
|
||||
using Result = std::decay_t<
|
||||
std::invoke_result_t<StoredCalculator, const typename Dependencies::ValueType&...>>;
|
||||
return ComputedValue<Result, StoredCalculator, Dependencies...>(
|
||||
std::forward<Calculator>( calculator ), dependencies... );
|
||||
}
|
||||
|
||||
template <typename Dependency, typename Calculator>
|
||||
/** @brief Convenience form of makeComputedValue() for one dependency. */
|
||||
auto computedValue( Dependency& dependency, Calculator&& calculator ) {
|
||||
return makeComputedValue( std::forward<Calculator>( calculator ), dependency );
|
||||
}
|
||||
|
||||
template <typename Dependency1, typename Dependency2, typename Calculator>
|
||||
/** @brief Convenience form of makeComputedValue() for two dependencies. */
|
||||
auto computedValue( Dependency1& dependency1, Dependency2& dependency2, Calculator&& calculator ) {
|
||||
return makeComputedValue( std::forward<Calculator>( calculator ), dependency1, dependency2 );
|
||||
}
|
||||
|
||||
template <typename Dependency1, typename Dependency2, typename Dependency3, typename Calculator>
|
||||
/** @brief Convenience form of makeComputedValue() for three dependencies. */
|
||||
auto computedValue( Dependency1& dependency1, Dependency2& dependency2, Dependency3& dependency3,
|
||||
Calculator&& calculator ) {
|
||||
return makeComputedValue( std::forward<Calculator>( calculator ), dependency1, dependency2,
|
||||
dependency3 );
|
||||
}
|
||||
|
||||
template <typename Dependency1, typename Dependency2, typename Dependency3, typename Dependency4,
|
||||
typename Calculator>
|
||||
/** @brief Convenience form of makeComputedValue() for four dependencies. */
|
||||
auto computedValue( Dependency1& dependency1, Dependency2& dependency2, Dependency3& dependency3,
|
||||
Dependency4& dependency4, Calculator&& calculator ) {
|
||||
return makeComputedValue( std::forward<Calculator>( calculator ), dependency1, dependency2,
|
||||
dependency3, dependency4 );
|
||||
}
|
||||
|
||||
} // namespace EE
|
||||
|
||||
#endif
|
||||
@@ -1,13 +1,17 @@
|
||||
#ifndef EE_CORE_CORE_HPP
|
||||
#define EE_CORE_CORE_HPP
|
||||
|
||||
#include <eepp/core/computedvalue.hpp>
|
||||
#include <eepp/core/containers.hpp>
|
||||
#include <eepp/core/debug.hpp>
|
||||
#include <eepp/core/lrucache.hpp>
|
||||
#include <eepp/core/memorymanager.hpp>
|
||||
#include <eepp/core/noncopyable.hpp>
|
||||
#include <eepp/core/observablevalue.hpp>
|
||||
#include <eepp/core/observablevector.hpp>
|
||||
#include <eepp/core/overloaded.hpp>
|
||||
#include <eepp/core/retainsymbol.hpp>
|
||||
#include <eepp/core/small_function.hpp>
|
||||
#include <eepp/core/small_vector.hpp>
|
||||
#include <eepp/core/string.hpp>
|
||||
#include <eepp/core/utf.hpp>
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <eepp/config.hpp>
|
||||
#include <eepp/core/debug.hpp>
|
||||
#include <eepp/core/small_vector.hpp>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
namespace EE {
|
||||
@@ -42,29 +44,62 @@ template <typename T> class ObservableValue {
|
||||
struct Observer {
|
||||
Uint32 id;
|
||||
Callback callback;
|
||||
bool connected{ true };
|
||||
};
|
||||
using Observers = SmallVector<Observer, 4>;
|
||||
|
||||
explicit State( T value ) : value( std::move( value ) ) {}
|
||||
|
||||
void set( const T& newValue ) {
|
||||
void set( const T& newValue ) { setImpl( newValue ); }
|
||||
|
||||
void set( T&& newValue ) { setImpl( std::move( newValue ) ); }
|
||||
|
||||
template <typename U> void setImpl( U&& newValue ) {
|
||||
if ( notifying ) {
|
||||
if ( value == newValue ) {
|
||||
pendingValue.reset();
|
||||
} else if ( !pendingValue || *pendingValue != newValue ) {
|
||||
pendingValue = std::forward<U>( newValue );
|
||||
}
|
||||
return;
|
||||
}
|
||||
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 );
|
||||
value = std::forward<U>( newValue );
|
||||
notifying = true;
|
||||
Uint32 notificationCount = 0;
|
||||
do {
|
||||
// Keep the callback objects in their stable observer slots while invoking them. New
|
||||
// observers go into pendingObservers so growing the container cannot relocate a
|
||||
// std::function that is currently executing. Disconnected observers are tombstoned
|
||||
// until this pass ends, preserving snapshot semantics without copying callbacks.
|
||||
const std::size_t observerCount = observers.size();
|
||||
for ( std::size_t i = 0; i < observerCount; ++i )
|
||||
// Disconnections made during this delivery take effect on the next one.
|
||||
// The observer remains in place so callbacks are never copied here.
|
||||
observers[i].callback( value );
|
||||
observers.erase( std::remove_if( observers.begin(), observers.end(),
|
||||
[]( const Observer& observer ) {
|
||||
return !observer.connected;
|
||||
} ),
|
||||
observers.end() );
|
||||
for ( auto& observer : pendingObservers )
|
||||
observers.emplace_back( std::move( observer ) );
|
||||
pendingObservers.clear();
|
||||
if ( !pendingValue )
|
||||
break;
|
||||
value = std::move( *pendingValue );
|
||||
pendingValue.reset();
|
||||
// A bounded drain turns accidental observer cycles into a clear debug failure
|
||||
// instead of unbounded recursion (or an infinite release-build loop).
|
||||
if ( ++notificationCount == MaxReentrantNotifications ) {
|
||||
eeASSERTM( false, "ObservableValue observer cycle detected" );
|
||||
break;
|
||||
}
|
||||
} while ( true );
|
||||
notifying = false;
|
||||
pendingValue.reset();
|
||||
}
|
||||
|
||||
typename Observers::iterator find( Uint32 id ) {
|
||||
@@ -83,21 +118,50 @@ template <typename T> class ObservableValue {
|
||||
|
||||
bool contains( Uint32 id ) const {
|
||||
auto observer = find( id );
|
||||
return observer != observers.end() && observer->id == id;
|
||||
if ( observer != observers.end() && observer->id == id )
|
||||
return observer->connected;
|
||||
auto pending = std::lower_bound(
|
||||
pendingObservers.begin(), pendingObservers.end(), id,
|
||||
[]( const Observer& item, Uint32 observerId ) { return item.id < observerId; } );
|
||||
return pending != pendingObservers.end() && pending->id == id && pending->connected;
|
||||
}
|
||||
|
||||
void remove( Uint32 id ) {
|
||||
auto observer = find( id );
|
||||
if ( observer != observers.end() && observer->id == id )
|
||||
observers.erase( observer );
|
||||
if ( observer != observers.end() && observer->id == id ) {
|
||||
if ( notifying )
|
||||
observer->connected = false;
|
||||
else
|
||||
observers.erase( observer );
|
||||
return;
|
||||
}
|
||||
auto pending = std::lower_bound(
|
||||
pendingObservers.begin(), pendingObservers.end(), id,
|
||||
[]( const Observer& item, Uint32 observerId ) { return item.id < observerId; } );
|
||||
if ( pending != pendingObservers.end() && pending->id == id )
|
||||
pendingObservers.erase( pending );
|
||||
}
|
||||
|
||||
void add( Uint32 id, Callback callback ) {
|
||||
// Appending directly while a callback runs could reallocate observers and destroy the
|
||||
// executing std::function. Pending callbacks become visible on the next delivery.
|
||||
auto& destination = notifying ? pendingObservers : observers;
|
||||
destination.emplace_back( Observer{ id, std::move( callback ), true } );
|
||||
}
|
||||
|
||||
T value;
|
||||
static constexpr Uint32 MaxReentrantNotifications = 1024;
|
||||
Uint32 nextId{ 0 };
|
||||
Observers observers;
|
||||
// Separate inline storage avoids both callback copies and heap allocation for the usual
|
||||
// case of a few observers added during notification.
|
||||
Observers pendingObservers;
|
||||
std::optional<T> pendingValue;
|
||||
bool notifying{ false };
|
||||
};
|
||||
|
||||
public:
|
||||
using ValueType = T;
|
||||
using Callback = std::function<void( const T& )>;
|
||||
|
||||
/** @brief Move-only scoped ownership of one ObservableValue observer. */
|
||||
@@ -123,6 +187,7 @@ template <typename T> class ObservableValue {
|
||||
return *this;
|
||||
}
|
||||
|
||||
/** @brief Disconnects the observer. Calling this more than once is safe. */
|
||||
void disconnect() {
|
||||
if ( auto state = mState.lock() )
|
||||
state->remove( mId );
|
||||
@@ -144,11 +209,17 @@ template <typename T> class ObservableValue {
|
||||
Uint32 mId{ 0 };
|
||||
};
|
||||
|
||||
/** @brief Non-owning, lifetime-safe access used by adapters such as UIValueBinding. */
|
||||
/**
|
||||
* @brief Non-owning, lifetime-safe access used by adapters such as UIValueBinding.
|
||||
*
|
||||
* Operations fail harmlessly after the owning ObservableValue is destroyed. A WeakHandle does
|
||||
* not make cross-thread access safe.
|
||||
*/
|
||||
class WeakHandle {
|
||||
public:
|
||||
WeakHandle() = default;
|
||||
|
||||
/** @return true when the owner still exists and accepted the set operation. */
|
||||
bool set( const T& value ) const {
|
||||
if ( auto state = mState.lock() ) {
|
||||
state->set( value );
|
||||
@@ -157,6 +228,7 @@ template <typename T> class ObservableValue {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return true when the owner still exists and accepted the set operation. */
|
||||
bool set( T&& value ) const {
|
||||
if ( auto state = mState.lock() ) {
|
||||
state->set( std::move( value ) );
|
||||
@@ -165,6 +237,23 @@ template <typename T> class ObservableValue {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return A copy of the current value, or std::nullopt after the owner expires. */
|
||||
std::optional<T> get() const {
|
||||
if ( auto state = mState.lock() )
|
||||
return state->value;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
/** @return A scoped observer connection, or an empty connection after owner expiration. */
|
||||
Connection observe( Callback callback ) const {
|
||||
if ( auto state = mState.lock() ) {
|
||||
auto id = ++state->nextId;
|
||||
state->add( id, std::move( callback ) );
|
||||
return Connection( state, id );
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
explicit operator bool() const { return !mState.expired(); }
|
||||
|
||||
private:
|
||||
@@ -173,18 +262,26 @@ template <typename T> class ObservableValue {
|
||||
std::weak_ptr<State> mState;
|
||||
};
|
||||
|
||||
/** @brief Creates an observable containing a default-constructed value. */
|
||||
ObservableValue() : mState( std::make_shared<State>( T{} ) ) {}
|
||||
|
||||
/** @brief Creates an observable containing @p value. No notification is emitted. */
|
||||
explicit ObservableValue( T value ) : mState( std::make_shared<State>( std::move( value ) ) ) {}
|
||||
ObservableValue( const ObservableValue& ) = delete;
|
||||
ObservableValue& operator=( const ObservableValue& ) = delete;
|
||||
ObservableValue( ObservableValue&& ) noexcept = default;
|
||||
ObservableValue& operator=( ObservableValue&& ) noexcept = default;
|
||||
|
||||
/** @return A reference to the current value. */
|
||||
const T& get() const { return mState->value; }
|
||||
|
||||
/** @brief Replaces the value and synchronously notifies observers when it changed. */
|
||||
void set( const T& value ) {
|
||||
auto state = mState;
|
||||
state->set( value );
|
||||
}
|
||||
|
||||
/** @brief Move-replaces the value and synchronously notifies observers when it changed. */
|
||||
void set( T&& value ) {
|
||||
auto state = mState;
|
||||
state->set( std::move( value ) );
|
||||
@@ -201,17 +298,33 @@ template <typename T> class ObservableValue {
|
||||
}
|
||||
|
||||
const T& operator*() const { return get(); }
|
||||
|
||||
const T* operator->() const { return &get(); }
|
||||
|
||||
operator const T&() const { return get(); }
|
||||
|
||||
/**
|
||||
* @brief Observes subsequent value changes.
|
||||
* @return A scoped connection; destroying it disconnects the callback.
|
||||
*
|
||||
* Registration does not invoke @p callback with the current value. Reentrant set() calls are
|
||||
* queued and delivered after the current observer snapshot completes.
|
||||
*/
|
||||
Connection observe( Callback callback ) {
|
||||
auto id = ++mState->nextId;
|
||||
mState->observers.emplace_back( typename State::Observer{ id, std::move( callback ) } );
|
||||
mState->add( id, std::move( callback ) );
|
||||
return Connection( mState, id );
|
||||
}
|
||||
|
||||
/** @return A non-owning handle that expires safely with this observable. */
|
||||
WeakHandle weakHandle() const { return WeakHandle( mState ); }
|
||||
|
||||
/** @return The number of currently connected observers. */
|
||||
std::size_t observerCount() const { return mState->observers.size(); }
|
||||
|
||||
/** @return Whether observer callbacks are currently being delivered. */
|
||||
bool isNotifying() const { return mState->notifying; }
|
||||
|
||||
private:
|
||||
std::shared_ptr<State> mState;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
#ifndef EE_CORE_OBSERVABLEVECTOR_HPP
|
||||
#define EE_CORE_OBSERVABLEVECTOR_HPP
|
||||
|
||||
#include <eepp/core/observablevalue.hpp>
|
||||
#include <vector>
|
||||
|
||||
namespace EE {
|
||||
|
||||
/**
|
||||
* @brief A vector whose explicit mutations can incrementally update attached adapters.
|
||||
*
|
||||
* Use this when a collection remains live while a view is attached. Immutable option lists and
|
||||
* collections already managed by a specialized Model should continue using those simpler models.
|
||||
* Notifications are synchronous and the collection and its connections must be used from one
|
||||
* owning thread. See the ui_data_collections example for live insertion, updates, and removal.
|
||||
*/
|
||||
template <typename T> class ObservableVector {
|
||||
public:
|
||||
/** @brief The mutation represented by a Change notification. */
|
||||
enum class ChangeType { Insert, Remove, Move, Change, Reset };
|
||||
|
||||
/** @brief Whether a Change is emitted immediately before or after its mutation. */
|
||||
enum class Phase { Before, After };
|
||||
|
||||
/** @brief Describes one collection mutation for incremental consumers. */
|
||||
struct Change {
|
||||
ChangeType type;
|
||||
Phase phase;
|
||||
std::size_t index{ 0 };
|
||||
std::size_t count{ 0 };
|
||||
std::size_t target{ 0 };
|
||||
};
|
||||
using ValueType = std::vector<T>;
|
||||
using Callback = std::function<void( const Change& )>;
|
||||
|
||||
private:
|
||||
struct State {
|
||||
struct Observer {
|
||||
std::shared_ptr<Callback> callback;
|
||||
Uint64 removedGeneration{ 0 };
|
||||
Uint32 id;
|
||||
};
|
||||
std::vector<T> values;
|
||||
SmallVector<Observer, 4> observers;
|
||||
Uint64 notificationGeneration{ 0 };
|
||||
Uint64 activeGeneration{ 0 };
|
||||
Uint32 nextId{ 0 };
|
||||
Uint32 notificationDepth{ 0 };
|
||||
void notify( const Change& change ) {
|
||||
// Each nested delivery gets a generation. A connection removed in generation N remains
|
||||
// callable by snapshots from generation <= N, but is invisible to later nested or
|
||||
// future deliveries. This exactly preserves snapshot behavior without copying
|
||||
// std::function.
|
||||
const Uint64 parentGeneration = activeGeneration;
|
||||
const Uint64 generation = ++notificationGeneration;
|
||||
activeGeneration = generation;
|
||||
++notificationDepth;
|
||||
const std::size_t observerCount = observers.size();
|
||||
for ( std::size_t i = 0; i < observerCount; ++i ) {
|
||||
auto& observer = observers[i];
|
||||
if ( observer.removedGeneration == 0 || observer.removedGeneration >= generation ) {
|
||||
// Keep the target alive locally: a callback may grow and reallocate observers
|
||||
// or disconnect itself while it is executing. Copying shared_ptr never
|
||||
// allocates.
|
||||
auto callback = observer.callback;
|
||||
( *callback )( change );
|
||||
}
|
||||
}
|
||||
--notificationDepth;
|
||||
activeGeneration = parentGeneration;
|
||||
if ( notificationDepth == 0 )
|
||||
// No active snapshot can reference tombstoned observers now.
|
||||
observers.erase( std::remove_if( observers.begin(), observers.end(),
|
||||
[]( const Observer& observer ) {
|
||||
return observer.removedGeneration != 0;
|
||||
} ),
|
||||
observers.end() );
|
||||
}
|
||||
void remove( Uint32 id ) {
|
||||
auto it =
|
||||
std::find_if( observers.begin(), observers.end(),
|
||||
[id]( const Observer& observer ) { return observer.id == id; } );
|
||||
if ( it == observers.end() )
|
||||
return;
|
||||
if ( notificationDepth != 0 )
|
||||
it->removedGeneration = activeGeneration;
|
||||
else
|
||||
observers.erase( it );
|
||||
}
|
||||
};
|
||||
|
||||
public:
|
||||
/** @brief Move-only scoped ownership of one collection 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;
|
||||
}
|
||||
/** @brief Disconnects the observer. Calling this more than once is safe. */
|
||||
void disconnect() {
|
||||
if ( auto state = mState.lock() )
|
||||
state->remove( mId );
|
||||
mState.reset();
|
||||
mId = 0;
|
||||
}
|
||||
explicit operator bool() const {
|
||||
if ( auto state = mState.lock() ) {
|
||||
auto observer = std::find_if( state->observers.begin(), state->observers.end(),
|
||||
[mId = mId]( const typename State::Observer& item ) {
|
||||
return item.id == mId;
|
||||
} );
|
||||
return observer != state->observers.end() && observer->removedGeneration == 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
friend class ObservableVector<T>;
|
||||
Connection( const std::shared_ptr<State>& state, Uint32 id ) : mState( state ), mId( id ) {}
|
||||
std::weak_ptr<State> mState;
|
||||
Uint32 mId{ 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Shared read and observation access for adapters that may outlive this wrapper.
|
||||
*
|
||||
* Retaining this handle keeps the collection storage alive. Mutations remain available only
|
||||
* through ObservableVector, so the handle becomes a stable read-only snapshot once its owning
|
||||
* wrapper is destroyed.
|
||||
*/
|
||||
class SharedHandle {
|
||||
public:
|
||||
SharedHandle() = default;
|
||||
|
||||
/** @return Read-only access to the retained collection. */
|
||||
const std::vector<T>& get() const {
|
||||
eeASSERT( mState );
|
||||
return mState->values;
|
||||
}
|
||||
/** @return The retained value at @p index. No bounds checking is performed. */
|
||||
const T& operator[]( std::size_t index ) const { return get()[index]; }
|
||||
/** @return The number of retained values. */
|
||||
std::size_t size() const { return mState ? mState->values.size() : 0; }
|
||||
/** @return Whether this handle retains collection storage. */
|
||||
explicit operator bool() const { return static_cast<bool>( mState ); }
|
||||
|
||||
/** @return A scoped connection observing later mutations of the owning ObservableVector. */
|
||||
Connection observe( Callback callback ) const {
|
||||
if ( !mState )
|
||||
return {};
|
||||
auto id = ++mState->nextId;
|
||||
mState->observers.emplace_back( typename State::Observer{
|
||||
std::make_shared<Callback>( std::move( callback ) ), 0, id } );
|
||||
return Connection( mState, id );
|
||||
}
|
||||
|
||||
private:
|
||||
friend class ObservableVector<T>;
|
||||
explicit SharedHandle( std::shared_ptr<State> state ) : mState( std::move( state ) ) {}
|
||||
std::shared_ptr<State> mState;
|
||||
};
|
||||
|
||||
/** @brief Creates an empty observable collection. */
|
||||
ObservableVector() : mState( std::make_shared<State>() ) {}
|
||||
|
||||
/** @brief Creates an observable collection containing @p values without emitting a change. */
|
||||
explicit ObservableVector( std::vector<T> values ) : mState( std::make_shared<State>() ) {
|
||||
mState->values = std::move( values );
|
||||
}
|
||||
ObservableVector( const ObservableVector& ) = delete;
|
||||
ObservableVector& operator=( const ObservableVector& ) = delete;
|
||||
ObservableVector( ObservableVector&& ) noexcept = default;
|
||||
ObservableVector& operator=( ObservableVector&& ) noexcept = default;
|
||||
|
||||
/** @return Read-only access to the complete collection. */
|
||||
const std::vector<T>& get() const { return mState->values; }
|
||||
|
||||
/** @return The value at @p index. No bounds checking is performed. */
|
||||
const T& operator[]( std::size_t index ) const { return mState->values[index]; }
|
||||
|
||||
/** @return The number of values in the collection. */
|
||||
std::size_t size() const { return mState->values.size(); }
|
||||
|
||||
/** @return Whether the collection contains no values. */
|
||||
bool empty() const { return mState->values.empty(); }
|
||||
|
||||
/** @brief Inserts @p value before @p index and emits paired Before/After notifications. */
|
||||
void insert( std::size_t index, T value ) {
|
||||
eeASSERT( index <= size() );
|
||||
notify( ChangeType::Insert, Phase::Before, index, 1 );
|
||||
mState->values.insert( mState->values.begin() + index, std::move( value ) );
|
||||
notify( ChangeType::Insert, Phase::After, index, 1 );
|
||||
}
|
||||
|
||||
/** @brief Appends @p value and emits paired Before/After insertion notifications. */
|
||||
void pushBack( T value ) { insert( size(), std::move( value ) ); }
|
||||
|
||||
/** @brief Removes @p count values starting at @p index. A zero count is a no-op. */
|
||||
void erase( std::size_t index, std::size_t count = 1 ) {
|
||||
eeASSERT( index + count <= size() );
|
||||
if ( count == 0 )
|
||||
return;
|
||||
notify( ChangeType::Remove, Phase::Before, index, count );
|
||||
mState->values.erase( mState->values.begin() + index,
|
||||
mState->values.begin() + index + count );
|
||||
notify( ChangeType::Remove, Phase::After, index, count );
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Moves one value from @p from to @p to.
|
||||
*
|
||||
* @p to is the final index in the resulting collection. Moving to the same index is a no-op.
|
||||
*/
|
||||
void move( std::size_t from, std::size_t to ) {
|
||||
eeASSERT( from < size() && to < size() );
|
||||
if ( from == to )
|
||||
return;
|
||||
notify( ChangeType::Move, Phase::Before, from, 1, to );
|
||||
T value = std::move( mState->values[from] );
|
||||
mState->values.erase( mState->values.begin() + from );
|
||||
mState->values.insert( mState->values.begin() + to, std::move( value ) );
|
||||
notify( ChangeType::Move, Phase::After, from, 1, to );
|
||||
}
|
||||
|
||||
/** @brief Replaces the value at @p index unless it already compares equal to @p value. */
|
||||
void set( std::size_t index, T value ) {
|
||||
eeASSERT( index < size() );
|
||||
if ( mState->values[index] == value )
|
||||
return;
|
||||
notify( ChangeType::Change, Phase::Before, index, 1 );
|
||||
mState->values[index] = std::move( value );
|
||||
notify( ChangeType::Change, Phase::After, index, 1 );
|
||||
}
|
||||
|
||||
/** @brief Replaces the entire collection and emits paired Reset notifications. */
|
||||
void reset( std::vector<T> values ) {
|
||||
notify( ChangeType::Reset, Phase::Before );
|
||||
mState->values = std::move( values );
|
||||
notify( ChangeType::Reset, Phase::After );
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Observes subsequent collection mutations.
|
||||
* @return A scoped connection; destroying it disconnects the callback.
|
||||
*
|
||||
* Each non-empty mutation emits a Before notification followed by After. The callback is not
|
||||
* invoked for the collection's current contents when it is registered.
|
||||
*/
|
||||
Connection observe( Callback callback ) {
|
||||
auto id = ++mState->nextId;
|
||||
mState->observers.emplace_back( typename State::Observer{
|
||||
std::make_shared<Callback>( std::move( callback ) ), 0, id } );
|
||||
return Connection( mState, id );
|
||||
}
|
||||
|
||||
/** @return Shared read access that keeps collection storage alive independently of this object.
|
||||
*/
|
||||
SharedHandle sharedHandle() const { return SharedHandle( mState ); }
|
||||
|
||||
private:
|
||||
void notify( ChangeType type, Phase phase, std::size_t index = 0, std::size_t count = 0,
|
||||
std::size_t target = 0 ) {
|
||||
mState->notify( { type, phase, index, count, target } );
|
||||
}
|
||||
std::shared_ptr<State> mState;
|
||||
};
|
||||
|
||||
} // namespace EE
|
||||
|
||||
#endif
|
||||
@@ -43,6 +43,7 @@
|
||||
#include <eepp/graphics/renderer/rendererglshader.hpp>
|
||||
#include <eepp/graphics/renderer/rendererhelper.hpp>
|
||||
#include <eepp/graphics/rendermode.hpp>
|
||||
#include <eepp/graphics/resource.hpp>
|
||||
#include <eepp/graphics/resourcecatalog.hpp>
|
||||
#include <eepp/graphics/resourcescope.hpp>
|
||||
#include <eepp/graphics/richtext.hpp>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <eepp/scene/actions/tint.hpp>
|
||||
#include <eepp/scene/actions/visible.hpp>
|
||||
#include <eepp/scene/event.hpp>
|
||||
#include <eepp/scene/eventconnection.hpp>
|
||||
#include <eepp/scene/eventdispatcher.hpp>
|
||||
#include <eepp/scene/keyevent.hpp>
|
||||
#include <eepp/scene/mouseevent.hpp>
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
#include <eepp/system/cpu.hpp>
|
||||
#include <eepp/system/directorypack.hpp>
|
||||
#include <eepp/system/fileinfo.hpp>
|
||||
#include <eepp/system/filemapped.hpp>
|
||||
#include <eepp/system/filesystem.hpp>
|
||||
#include <eepp/system/functionstring.hpp>
|
||||
#include <eepp/system/inifile.hpp>
|
||||
#include <eepp/system/iostream.hpp>
|
||||
#include <eepp/system/iostreamdeflate.hpp>
|
||||
#include <eepp/system/iostreamfile.hpp>
|
||||
#include <eepp/system/iostreamfilemapped.hpp>
|
||||
#include <eepp/system/iostreaminflate.hpp>
|
||||
#include <eepp/system/iostreammemory.hpp>
|
||||
#include <eepp/system/iostreampak.hpp>
|
||||
@@ -39,6 +41,7 @@
|
||||
#include <eepp/system/scopedbuffer.hpp>
|
||||
#include <eepp/system/scopedop.hpp>
|
||||
#include <eepp/system/singleton.hpp>
|
||||
#include <eepp/system/singletondeclarations.hpp>
|
||||
#include <eepp/system/sys.hpp>
|
||||
#include <eepp/system/thread.hpp>
|
||||
#include <eepp/system/threadlocal.hpp>
|
||||
|
||||
+19
-5
@@ -10,9 +10,11 @@
|
||||
#include <eepp/ui/css/animationdefinition.hpp>
|
||||
#include <eepp/ui/css/drawableimageparser.hpp>
|
||||
#include <eepp/ui/css/elementdefinition.hpp>
|
||||
#include <eepp/ui/css/idnamemap.hpp>
|
||||
#include <eepp/ui/css/keyframesdefinition.hpp>
|
||||
#include <eepp/ui/css/mediaquery.hpp>
|
||||
#include <eepp/ui/css/propertydefinition.hpp>
|
||||
#include <eepp/ui/css/propertyids.hpp>
|
||||
#include <eepp/ui/css/propertyidset.hpp>
|
||||
#include <eepp/ui/css/propertyspecification.hpp>
|
||||
#include <eepp/ui/css/shorthanddefinition.hpp>
|
||||
@@ -31,6 +33,14 @@
|
||||
#include <eepp/ui/css/timingfunction.hpp>
|
||||
#include <eepp/ui/css/transitiondefinition.hpp>
|
||||
#include <eepp/ui/csslayouttypes.hpp>
|
||||
#include <eepp/ui/databinding/uibindinggroup.hpp>
|
||||
#include <eepp/ui/databinding/uicommand.hpp>
|
||||
#include <eepp/ui/databinding/uidatabind.hpp>
|
||||
#include <eepp/ui/databinding/uiobservedelivery.hpp>
|
||||
#include <eepp/ui/databinding/uiproperty.hpp>
|
||||
#include <eepp/ui/databinding/uivaluebinding.hpp>
|
||||
#include <eepp/ui/databinding/uivalueconverter.hpp>
|
||||
#include <eepp/ui/databinding/uivaluevalidation.hpp>
|
||||
#include <eepp/ui/doc/documentview.hpp>
|
||||
#include <eepp/ui/doc/foldrangeservice.hpp>
|
||||
#include <eepp/ui/doc/foldrangetype.hpp>
|
||||
@@ -48,9 +58,13 @@
|
||||
#include <eepp/ui/doc/textrange.hpp>
|
||||
#include <eepp/ui/doc/textundostack.hpp>
|
||||
#include <eepp/ui/drawableresolver.hpp>
|
||||
#include <eepp/ui/flexlayouter.hpp>
|
||||
#include <eepp/ui/gridlayouter.hpp>
|
||||
#include <eepp/ui/iconmanager.hpp>
|
||||
#include <eepp/ui/inlinelayouter.hpp>
|
||||
#include <eepp/ui/keyboardshortcut.hpp>
|
||||
#include <eepp/ui/layoutinvalidation.hpp>
|
||||
#include <eepp/ui/lineargradientdrawable.hpp>
|
||||
#include <eepp/ui/models/csspropertiesmodel.hpp>
|
||||
#include <eepp/ui/models/filesystemmodel.hpp>
|
||||
#include <eepp/ui/models/itemlistmodel.hpp>
|
||||
@@ -59,6 +73,7 @@
|
||||
#include <eepp/ui/models/modelindex.hpp>
|
||||
#include <eepp/ui/models/modelrole.hpp>
|
||||
#include <eepp/ui/models/modelselection.hpp>
|
||||
#include <eepp/ui/models/observablelistmodel.hpp>
|
||||
#include <eepp/ui/models/persistentmodelindex.hpp>
|
||||
#include <eepp/ui/models/sortingproxymodel.hpp>
|
||||
#include <eepp/ui/models/stringmapmodel.hpp>
|
||||
@@ -66,6 +81,7 @@
|
||||
#include <eepp/ui/models/widgettreemodel.hpp>
|
||||
#include <eepp/ui/mouseshortcut.hpp>
|
||||
#include <eepp/ui/nonelayouter.hpp>
|
||||
#include <eepp/ui/radialgradientdrawable.hpp>
|
||||
#include <eepp/ui/splitdirection.hpp>
|
||||
#include <eepp/ui/tablelayouter.hpp>
|
||||
#include <eepp/ui/tools/htmlformatter.hpp>
|
||||
@@ -77,6 +93,7 @@
|
||||
#include <eepp/ui/tools/uidocfindreplace.hpp>
|
||||
#include <eepp/ui/tools/uifontpickerdialog.hpp>
|
||||
#include <eepp/ui/tools/uiimageviewer.hpp>
|
||||
#include <eepp/ui/tools/uitabwidgetsplitter.hpp>
|
||||
#include <eepp/ui/tools/uiwidgetinspector.hpp>
|
||||
#include <eepp/ui/uiapplication.hpp>
|
||||
#include <eepp/ui/uibackgrounddrawable.hpp>
|
||||
@@ -86,7 +103,6 @@
|
||||
#include <eepp/ui/uicodeeditor.hpp>
|
||||
#include <eepp/ui/uicombobox.hpp>
|
||||
#include <eepp/ui/uiconsole.hpp>
|
||||
#include <eepp/ui/uidatabind.hpp>
|
||||
#include <eepp/ui/uidropdown.hpp>
|
||||
#include <eepp/ui/uidropdownlist.hpp>
|
||||
#include <eepp/ui/uidropdownmodellist.hpp>
|
||||
@@ -100,6 +116,7 @@
|
||||
#include <eepp/ui/uihtmlimage.hpp>
|
||||
#include <eepp/ui/uihtmlinput.hpp>
|
||||
#include <eepp/ui/uihtmllistitem.hpp>
|
||||
#include <eepp/ui/uihtmlliststyle.hpp>
|
||||
#include <eepp/ui/uihtmltable.hpp>
|
||||
#include <eepp/ui/uihtmltextarea.hpp>
|
||||
#include <eepp/ui/uihtmltextinput.hpp>
|
||||
@@ -126,6 +143,7 @@
|
||||
#include <eepp/ui/uimenuseparator.hpp>
|
||||
#include <eepp/ui/uimenusubmenu.hpp>
|
||||
#include <eepp/ui/uimessagebox.hpp>
|
||||
#include <eepp/ui/uimodelcreator.hpp>
|
||||
#include <eepp/ui/uimultimodelview.hpp>
|
||||
#include <eepp/ui/uinode.hpp>
|
||||
#include <eepp/ui/uinodedrawable.hpp>
|
||||
@@ -133,7 +151,6 @@
|
||||
#include <eepp/ui/uiplacementutils.hpp>
|
||||
#include <eepp/ui/uipopupmenu.hpp>
|
||||
#include <eepp/ui/uiprogressbar.hpp>
|
||||
#include <eepp/ui/uiproperty.hpp>
|
||||
#include <eepp/ui/uipushbutton.hpp>
|
||||
#include <eepp/ui/uiradiobutton.hpp>
|
||||
#include <eepp/ui/uirelativelayout.hpp>
|
||||
@@ -172,9 +189,6 @@
|
||||
#include <eepp/ui/uitooltip.hpp>
|
||||
#include <eepp/ui/uitouchdraggablewidget.hpp>
|
||||
#include <eepp/ui/uitreeview.hpp>
|
||||
#include <eepp/ui/uivaluebinding.hpp>
|
||||
#include <eepp/ui/uivalueconverter.hpp>
|
||||
#include <eepp/ui/uivaluevalidation.hpp>
|
||||
#include <eepp/ui/uiviewpager.hpp>
|
||||
#include <eepp/ui/uiwebview.hpp>
|
||||
#include <eepp/ui/uiwidget.hpp>
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
#ifndef EE_UI_UIBINDINGGROUP_HPP
|
||||
#define EE_UI_UIBINDINGGROUP_HPP
|
||||
|
||||
#include <eepp/core/small_vector.hpp>
|
||||
#include <eepp/ui/databinding/uidatabind.hpp>
|
||||
#include <eepp/ui/databinding/uiproperty.hpp>
|
||||
#include <eepp/ui/databinding/uivaluebinding.hpp>
|
||||
#include <memory>
|
||||
|
||||
namespace EE { namespace UI {
|
||||
|
||||
/**
|
||||
* @brief Owns heterogeneous bindings and exposes aggregate form state.
|
||||
*
|
||||
* Dirty state compares live values with explicit per-binding baselines. The group owns bindings,
|
||||
* but never owns their widgets or model values. Disabled widgets are excluded from aggregate
|
||||
* validation. clear() and destruction disconnect all bindings.
|
||||
*
|
||||
* @code
|
||||
* UIBindingGroup form;
|
||||
* form += bindValue( name, nameInput, requiredName );
|
||||
* form += bindValue( port, portInput, validPort );
|
||||
* auto canSave = computedValue( form.validValue(), form.dirtyValue(),
|
||||
* []( bool valid, bool dirty ) { return valid && dirty; } );
|
||||
* @endcode
|
||||
*/
|
||||
class UIBindingGroup {
|
||||
private:
|
||||
template <typename T> struct PropertyEntry;
|
||||
|
||||
public:
|
||||
/** @brief Identifies one invalid binding and its current validation result. */
|
||||
struct Error {
|
||||
/** Insertion index of the invalid binding in this group. */
|
||||
std::size_t index{ 0 };
|
||||
/** Bound widget when one is still connected, otherwise nullptr. */
|
||||
UIWidget* widget{ nullptr };
|
||||
/** Validation state owned by the binding and valid until the group changes. */
|
||||
const UIValueValidationResult* validation{ nullptr };
|
||||
};
|
||||
/** Inline-backed error collection sized for ordinary forms. */
|
||||
using Errors = SmallVector<Error, 4>;
|
||||
/** Inline-backed widget collection sized for ordinary forms. */
|
||||
using Widgets = SmallVector<UIWidget*, 4>;
|
||||
using Callback = std::function<void()>;
|
||||
|
||||
UIBindingGroup() = default;
|
||||
UIBindingGroup( const UIBindingGroup& ) = delete;
|
||||
UIBindingGroup& operator=( const UIBindingGroup& ) = delete;
|
||||
UIBindingGroup( UIBindingGroup&& ) = delete;
|
||||
UIBindingGroup& operator=( UIBindingGroup&& ) = delete;
|
||||
~UIBindingGroup() = default;
|
||||
|
||||
/**
|
||||
* @brief Adds and takes ownership of a typed binding.
|
||||
* @return This group, allowing several bindings to be added in one expression.
|
||||
*/
|
||||
template <typename T> UIBindingGroup& hold( UIValueBinding<T>&& binding ) {
|
||||
add( std::make_unique<ObservableEntry<T>>( std::move( binding ), this ) );
|
||||
return *this;
|
||||
}
|
||||
|
||||
template <typename T> UIBindingGroup& operator+=( UIValueBinding<T>&& binding ) {
|
||||
return hold( std::move( binding ) );
|
||||
}
|
||||
|
||||
/** @brief Adds and takes ownership of a legacy UIDataBind. */
|
||||
template <typename T> UIBindingGroup& hold( std::unique_ptr<UIDataBind<T>> binding ) {
|
||||
add( std::make_unique<RawEntry<T>>( std::move( binding ), this ) );
|
||||
return *this;
|
||||
}
|
||||
|
||||
template <typename T> UIBindingGroup& operator+=( std::unique_ptr<UIDataBind<T>> binding ) {
|
||||
return hold( std::move( binding ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Tracks a UIProperty without taking ownership of it.
|
||||
*
|
||||
* The entry expires safely if @p property is destroyed first. Its current value becomes the
|
||||
* initial clean baseline.
|
||||
*/
|
||||
template <typename T> UIBindingGroup& hold( UIProperty<T>& property ) {
|
||||
add( std::make_unique<PropertyEntry<T>>( property, this ) );
|
||||
return *this;
|
||||
}
|
||||
|
||||
/** @brief Convenience form of hold(UIProperty<T>&). */
|
||||
template <typename T> UIBindingGroup& operator+=( UIProperty<T>& property ) {
|
||||
return hold( property );
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true when every binding attached to an enabled widget is valid.
|
||||
*
|
||||
* Disabled fields are intentionally ignored, which supports conditional form sections.
|
||||
*/
|
||||
bool isValid() const {
|
||||
for ( const auto& entry : mEntries )
|
||||
if ( !entry->isValid() )
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return true when at least one value differs from its current clean baseline. */
|
||||
bool isDirty() const {
|
||||
for ( const auto& entry : mEntries )
|
||||
if ( entry->isDirty() )
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return Observable aggregate validity, suitable for computed values and commands. */
|
||||
ObservableValue<bool>& validValue() { return mValid; }
|
||||
|
||||
/** @return Observable aggregate dirty state, suitable for computed values and commands. */
|
||||
ObservableValue<bool>& dirtyValue() { return mDirty; }
|
||||
|
||||
/** @return Invalid enabled bindings in insertion order. */
|
||||
Errors errors() const {
|
||||
Errors result;
|
||||
for ( std::size_t i = 0; i < mEntries.size(); ++i )
|
||||
if ( !mEntries[i]->isValid() )
|
||||
result.push_back( { i, mEntries[i]->widget(), &mEntries[i]->validation() } );
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Connected widgets grouped by binding insertion order.
|
||||
*
|
||||
* The order within a legacy multi-widget UIDataBind or UIProperty is unspecified.
|
||||
*/
|
||||
Widgets widgets() const {
|
||||
Widgets result;
|
||||
for ( const auto& entry : mEntries )
|
||||
entry->appendWidgets( result );
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @return The first invalid enabled widget in insertion order, or nullptr. */
|
||||
UIWidget* firstInvalidWidget() const {
|
||||
for ( const auto& entry : mEntries )
|
||||
if ( !entry->isValid() && entry->widget() )
|
||||
return entry->widget();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/** @brief Makes every binding's current value its new clean/reset baseline. */
|
||||
void markClean() {
|
||||
for ( auto& entry : mEntries )
|
||||
entry->markClean();
|
||||
notifyIfChanged();
|
||||
}
|
||||
|
||||
/** @brief Restores every binding to the baseline recorded at insertion or markClean(). */
|
||||
void reset() {
|
||||
for ( auto& entry : mEntries )
|
||||
entry->reset();
|
||||
notifyIfChanged();
|
||||
}
|
||||
|
||||
/** @brief Destroys all owned bindings and resets aggregate state. */
|
||||
void clear() {
|
||||
mEntries.clear();
|
||||
notifyIfChanged();
|
||||
}
|
||||
|
||||
/** @return The number of bindings owned by the group. */
|
||||
std::size_t size() const { return mEntries.size(); }
|
||||
|
||||
/**
|
||||
* @brief Replaces the callback invoked after a relevant group event.
|
||||
*
|
||||
* A non-empty callback is invoked once immediately, then after value, validation,
|
||||
* enabled-state, baseline, or membership changes. Use validValue()/dirtyValue() when only
|
||||
* aggregate transitions matter, since those observables suppress equal values.
|
||||
*/
|
||||
void onChange( Callback callback ) {
|
||||
mCallback = std::move( callback );
|
||||
if ( mCallback )
|
||||
mCallback();
|
||||
}
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
virtual ~Entry() = default;
|
||||
virtual bool isValid() const = 0;
|
||||
virtual bool isDirty() const = 0;
|
||||
virtual UIWidget* widget() const = 0;
|
||||
virtual void appendWidgets( Widgets& widgets ) const = 0;
|
||||
virtual const UIValueValidationResult& validation() const = 0;
|
||||
virtual void markClean() = 0;
|
||||
virtual void reset() = 0;
|
||||
ObservableValue<UIValueValidationResult>::Connection validationConnection;
|
||||
EventConnectionList enabledConnections;
|
||||
};
|
||||
|
||||
template <typename T> struct ObservableEntry : Entry {
|
||||
ObservableEntry( UIValueBinding<T>&& binding, UIBindingGroup* group ) :
|
||||
binding( std::move( binding ) ), baseline( this->binding.value() ) {
|
||||
if ( auto validation = this->binding.validationState() )
|
||||
this->validationConnection = validation->observe(
|
||||
[group]( const UIValueValidationResult& ) { group->notifyIfChanged(); } );
|
||||
valueConnection =
|
||||
this->binding.observeValue( [group]( const T& ) { group->notifyIfChanged(); } );
|
||||
if ( auto widget = this->binding.widget() )
|
||||
this->enabledConnections += widget->connect(
|
||||
Event::OnEnabledChange, [group]( const Event* ) { group->notifyIfChanged(); } );
|
||||
}
|
||||
bool isValid() const override {
|
||||
auto widget = binding.widget();
|
||||
return !widget || !widget->isEnabled() || binding.isValid();
|
||||
}
|
||||
bool isDirty() const override { return baseline != binding.value(); }
|
||||
UIWidget* widget() const override { return binding.widget(); }
|
||||
void appendWidgets( Widgets& widgets ) const override {
|
||||
if ( auto boundWidget = widget() )
|
||||
widgets.push_back( boundWidget );
|
||||
}
|
||||
const UIValueValidationResult& validation() const override {
|
||||
static const UIValueValidationResult valid;
|
||||
auto state = binding.validationState();
|
||||
return state ? state->result() : valid;
|
||||
}
|
||||
void markClean() override { baseline = binding.value(); }
|
||||
void reset() override {
|
||||
if ( baseline )
|
||||
binding.setValue( *baseline );
|
||||
}
|
||||
UIValueBinding<T> binding;
|
||||
std::optional<T> baseline;
|
||||
typename ObservableValue<T>::Connection valueConnection;
|
||||
};
|
||||
|
||||
template <typename T> struct RawEntry : Entry {
|
||||
RawEntry( std::unique_ptr<UIDataBind<T>> binding, UIBindingGroup* group ) :
|
||||
binding( std::move( binding ) ), baseline( this->binding->get() ) {
|
||||
this->validationConnection = this->binding->validationState().observe(
|
||||
[group]( const UIValueValidationResult& ) { group->notifyIfChanged(); } );
|
||||
auto previous = std::move( this->binding->onValueChangeCb );
|
||||
this->binding->onValueChangeCb = [group,
|
||||
previous = std::move( previous )]( const T& value ) {
|
||||
if ( previous )
|
||||
previous( value );
|
||||
group->notifyIfChanged();
|
||||
};
|
||||
for ( auto widget : this->binding->getWidgets() )
|
||||
this->enabledConnections += widget->connect(
|
||||
Event::OnEnabledChange, [group]( const Event* ) { group->notifyIfChanged(); } );
|
||||
}
|
||||
bool isValid() const override {
|
||||
if ( binding->isValid() )
|
||||
return true;
|
||||
if ( auto emitter = binding->getValidationEmitter() )
|
||||
return !emitter->isEnabled();
|
||||
for ( auto widget : binding->getWidgets() )
|
||||
if ( widget && widget->isEnabled() )
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
bool isDirty() const override { return baseline != binding->get(); }
|
||||
UIWidget* widget() const override {
|
||||
if ( auto emitter = binding->getValidationEmitter(); emitter && emitter->isEnabled() )
|
||||
return emitter;
|
||||
for ( auto widget : binding->getWidgets() )
|
||||
if ( widget && widget->isEnabled() )
|
||||
return widget;
|
||||
return nullptr;
|
||||
}
|
||||
void appendWidgets( Widgets& widgets ) const override {
|
||||
widgets.insert( widgets.end(), binding->getWidgets().begin(),
|
||||
binding->getWidgets().end() );
|
||||
}
|
||||
const UIValueValidationResult& validation() const override {
|
||||
return binding->validationState().result();
|
||||
}
|
||||
void markClean() override { baseline = binding->get(); }
|
||||
void reset() override { binding->set( baseline ); }
|
||||
std::unique_ptr<UIDataBind<T>> binding;
|
||||
T baseline;
|
||||
};
|
||||
|
||||
template <typename T> struct PropertyEntry : Entry {
|
||||
PropertyEntry( UIProperty<T>& property, UIBindingGroup* group ) :
|
||||
handle( property.weakHandle() ),
|
||||
baseline( handle.get() ),
|
||||
currentValidation( handle.validation() ) {
|
||||
this->validationConnection =
|
||||
handle.observeValidation( [this, group]( const UIValueValidationResult& result ) {
|
||||
currentValidation = result;
|
||||
group->notifyIfChanged();
|
||||
} );
|
||||
valueConnection = handle.observe( [group]( const T& ) { group->notifyIfChanged(); } );
|
||||
lifetimeConnection =
|
||||
handle.observeLifetime( [group]( const bool& ) { group->notifyIfChanged(); } );
|
||||
handle.forEachWidget( [this, group]( UIWidget* widget ) {
|
||||
this->enabledConnections += widget->connect(
|
||||
Event::OnEnabledChange, [group]( const Event* ) { group->notifyIfChanged(); } );
|
||||
} );
|
||||
}
|
||||
bool isValid() const override {
|
||||
if ( !handle )
|
||||
return true;
|
||||
if ( currentValidation.valid )
|
||||
return true;
|
||||
if ( auto emitter = handle.validationEmitter() )
|
||||
return !emitter->isEnabled();
|
||||
return handle.firstEnabledWidget() == nullptr;
|
||||
}
|
||||
bool isDirty() const override {
|
||||
auto current = handle.get();
|
||||
return baseline && current && *baseline != *current;
|
||||
}
|
||||
UIWidget* widget() const override {
|
||||
if ( auto emitter = handle.validationEmitter(); emitter && emitter->isEnabled() )
|
||||
return emitter;
|
||||
return handle.firstEnabledWidget();
|
||||
}
|
||||
void appendWidgets( Widgets& widgets ) const override { handle.appendWidgets( widgets ); }
|
||||
const UIValueValidationResult& validation() const override { return currentValidation; }
|
||||
void markClean() override { baseline = handle.get(); }
|
||||
void reset() override {
|
||||
if ( baseline )
|
||||
handle.set( *baseline );
|
||||
}
|
||||
typename UIProperty<T>::WeakHandle handle;
|
||||
std::optional<T> baseline;
|
||||
UIValueValidationResult currentValidation;
|
||||
typename UIProperty<T>::Connection valueConnection;
|
||||
ObservableValue<bool>::Connection lifetimeConnection;
|
||||
};
|
||||
|
||||
void add( std::unique_ptr<Entry> entry ) {
|
||||
mEntries.emplace_back( std::move( entry ) );
|
||||
notifyIfChanged();
|
||||
}
|
||||
|
||||
void notifyIfChanged() {
|
||||
bool valid = true;
|
||||
bool dirty = false;
|
||||
for ( const auto& entry : mEntries ) {
|
||||
if ( valid && !entry->isValid() )
|
||||
valid = false;
|
||||
if ( !dirty && entry->isDirty() )
|
||||
dirty = true;
|
||||
if ( !valid && dirty )
|
||||
break;
|
||||
}
|
||||
if ( valid != mLastValid ) {
|
||||
mLastValid = valid;
|
||||
mValid = valid;
|
||||
}
|
||||
if ( dirty != mLastDirty ) {
|
||||
mLastDirty = dirty;
|
||||
mDirty = dirty;
|
||||
}
|
||||
if ( mCallback )
|
||||
mCallback();
|
||||
}
|
||||
|
||||
// Most forms contain only a few fields; keep their entry ownership entirely inline.
|
||||
SmallVector<std::unique_ptr<Entry>, 4> mEntries;
|
||||
Callback mCallback;
|
||||
bool mLastValid{ true };
|
||||
bool mLastDirty{ false };
|
||||
ObservableValue<bool> mValid{ true };
|
||||
ObservableValue<bool> mDirty{ false };
|
||||
};
|
||||
|
||||
}} // namespace EE::UI
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,304 @@
|
||||
#ifndef EE_UI_UICOMMAND_HPP
|
||||
#define EE_UI_UICOMMAND_HPP
|
||||
|
||||
#include <atomic>
|
||||
#include <eepp/core/observablevalue.hpp>
|
||||
#include <eepp/ui/keyboardshortcut.hpp>
|
||||
#include <eepp/ui/uiwidget.hpp>
|
||||
|
||||
namespace EE { namespace UI {
|
||||
|
||||
template <typename Target> class UICommandShortcutBinding;
|
||||
|
||||
/**
|
||||
* @brief One action shared by UI endpoints and keyboard shortcuts.
|
||||
*
|
||||
* @code
|
||||
* auto save = bindCommand(
|
||||
* [&] { saveDocument(); }, canSave, *saveButton, *uiScene,
|
||||
* { KEY_S, KeyMod::getDefaultModifier() } );
|
||||
* @endcode
|
||||
*
|
||||
* For a single button with no other representation, an ordinary onClick() callback remains the
|
||||
* clearer choice.
|
||||
*/
|
||||
class UICommand {
|
||||
private:
|
||||
struct SourceConnectionBase {
|
||||
virtual ~SourceConnectionBase() = default;
|
||||
};
|
||||
|
||||
template <typename Connection> struct SourceConnection final : SourceConnectionBase {
|
||||
explicit SourceConnection( Connection connection ) :
|
||||
connection( std::move( connection ) ) {}
|
||||
Connection connection;
|
||||
};
|
||||
|
||||
struct State {
|
||||
explicit State( std::function<void()> execute ) : execute( std::move( execute ) ) {}
|
||||
bool tryExecute() {
|
||||
if ( !enabled.get() || executing )
|
||||
return false;
|
||||
executing = true;
|
||||
execute();
|
||||
executing = false;
|
||||
return true;
|
||||
}
|
||||
std::function<void()> execute;
|
||||
ObservableValue<bool> enabled{ true };
|
||||
// Enabled sources expose different scoped connection classes. Erasure happens once when
|
||||
// constructing the command and has no execution- or notification-path cost.
|
||||
std::unique_ptr<SourceConnectionBase> enabledSourceConnection;
|
||||
bool executing{ false };
|
||||
};
|
||||
|
||||
public:
|
||||
/** @brief Creates an always-enabled command that invokes @p execute. */
|
||||
explicit UICommand( std::function<void()> execute ) :
|
||||
mState( std::make_shared<State>( std::move( execute ) ) ) {}
|
||||
template <typename Source>
|
||||
/**
|
||||
* @brief Creates a command whose enabled state follows @p enabled.
|
||||
*
|
||||
* The source must expose get() and observe() for bool values and must outlive the command if
|
||||
* further enabled-state updates are expected.
|
||||
*/
|
||||
UICommand( std::function<void()> execute, Source& enabled ) :
|
||||
mState( std::make_shared<State>( std::move( execute ) ) ) {
|
||||
mState->enabled = enabled.get();
|
||||
std::weak_ptr<State> weakState = mState;
|
||||
auto enabledConnection = enabled.observe( [weakState]( const bool& value ) {
|
||||
if ( auto state = weakState.lock() )
|
||||
state->enabled = value;
|
||||
} );
|
||||
mState->enabledSourceConnection =
|
||||
std::make_unique<SourceConnection<decltype( enabledConnection )>>(
|
||||
std::move( enabledConnection ) );
|
||||
}
|
||||
UICommand( const UICommand& ) = delete;
|
||||
UICommand& operator=( const UICommand& ) = delete;
|
||||
UICommand( UICommand&& ) noexcept = default;
|
||||
UICommand& operator=( UICommand&& ) noexcept = default;
|
||||
|
||||
/**
|
||||
* @brief Attempts to run the action.
|
||||
* @return true when it ran, or false when disabled or already executing.
|
||||
*/
|
||||
bool execute() {
|
||||
auto state = mState;
|
||||
return state && state->tryExecute();
|
||||
}
|
||||
|
||||
/** @return Observable enabled state shared by every endpoint bound to this command. */
|
||||
ObservableValue<bool>& enabled() { return mState->enabled; }
|
||||
|
||||
private:
|
||||
std::shared_ptr<State> mState;
|
||||
friend class UICommandBinding;
|
||||
template <typename Target> friend class UICommandShortcutBinding;
|
||||
};
|
||||
|
||||
/** @brief Scoped synchronization of a command with one widget endpoint. */
|
||||
class UICommandBinding {
|
||||
public:
|
||||
UICommandBinding() = default;
|
||||
|
||||
/**
|
||||
* @brief Binds @p widget clicks and enabled state to @p command.
|
||||
*
|
||||
* Keep this object alive while the endpoint should remain active. The widget is not retained.
|
||||
*/
|
||||
UICommandBinding( UICommand& command, UIWidget& widget ) {
|
||||
auto commandState = command.mState;
|
||||
auto state = std::make_shared<BindingState>();
|
||||
state->widget = &widget;
|
||||
widget.setEnabled( commandState->enabled.get() );
|
||||
std::weak_ptr<UICommand::State> weakCommand = commandState;
|
||||
std::weak_ptr<BindingState> weakBinding = state;
|
||||
state->connections += widget.connect( Event::MouseClick, [weakCommand]( const Event* ) {
|
||||
if ( auto command = weakCommand.lock() )
|
||||
command->tryExecute();
|
||||
} );
|
||||
state->connections += widget.connect( Event::OnClose, [weakBinding]( const Event* ) {
|
||||
if ( auto binding = weakBinding.lock() ) {
|
||||
binding->widget = nullptr;
|
||||
binding->enabledConnection.disconnect();
|
||||
}
|
||||
} );
|
||||
state->enabledConnection =
|
||||
commandState->enabled.observe( [weakBinding]( const bool& enabled ) {
|
||||
if ( auto binding = weakBinding.lock(); binding && binding->widget )
|
||||
binding->widget->setEnabled( enabled );
|
||||
} );
|
||||
mState = std::move( state );
|
||||
}
|
||||
UICommandBinding( const UICommandBinding& ) = delete;
|
||||
UICommandBinding& operator=( const UICommandBinding& ) = delete;
|
||||
UICommandBinding( UICommandBinding&& ) noexcept = default;
|
||||
UICommandBinding& operator=( UICommandBinding&& ) noexcept = default;
|
||||
|
||||
private:
|
||||
struct BindingState {
|
||||
UIWidget* widget{ nullptr };
|
||||
EventConnectionList connections;
|
||||
ObservableValue<bool>::Connection enabledConnection;
|
||||
};
|
||||
std::shared_ptr<BindingState> mState;
|
||||
};
|
||||
|
||||
/** @return A scoped binding between an existing command and one clickable widget. */
|
||||
inline UICommandBinding bindCommand( UICommand& command, UIWidget& widget ) {
|
||||
return UICommandBinding( command, widget );
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Scoped binding of a command to a shortcut consumed by UISceneNode or UIWindow.
|
||||
*
|
||||
* The previous command mapped to the shortcut is restored when this binding disconnects. The
|
||||
* generated command registration and shortcut are removed safely when the target closes first.
|
||||
*/
|
||||
template <typename Target> class UICommandShortcutBinding {
|
||||
public:
|
||||
UICommandShortcutBinding() = default;
|
||||
|
||||
/**
|
||||
* @brief Registers @p shortcut on @p target as another endpoint for @p command.
|
||||
*
|
||||
* If the shortcut was already mapped, that mapping is restored when this binding disconnects.
|
||||
*/
|
||||
UICommandShortcutBinding( UICommand& command, Target& target,
|
||||
const KeyBindings::Shortcut& shortcut ) {
|
||||
auto state = std::make_shared<State>();
|
||||
state->target = ⌖
|
||||
state->shortcut = shortcut;
|
||||
state->previousCommand = target.getKeyBindings().getCommandFromKeyBind( shortcut );
|
||||
state->commandName = "eepp-ui-command-" + String::toString( ++sNextCommandId );
|
||||
std::weak_ptr<UICommand::State> weakCommand = command.mState;
|
||||
target.setKeyBindingCommand( state->commandName, [weakCommand] {
|
||||
if ( auto command = weakCommand.lock() )
|
||||
command->tryExecute();
|
||||
} );
|
||||
target.getKeyBindings().addKeybind( shortcut, state->commandName );
|
||||
std::weak_ptr<State> weakState = state;
|
||||
state->targetConnection = target.connect( Event::OnClose, [weakState]( const Event* ) {
|
||||
if ( auto state = weakState.lock() )
|
||||
state->target = nullptr;
|
||||
} );
|
||||
mState = std::move( state );
|
||||
}
|
||||
~UICommandShortcutBinding() { disconnect(); }
|
||||
UICommandShortcutBinding( const UICommandShortcutBinding& ) = delete;
|
||||
UICommandShortcutBinding& operator=( const UICommandShortcutBinding& ) = delete;
|
||||
UICommandShortcutBinding( UICommandShortcutBinding&& other ) noexcept :
|
||||
mState( std::move( other.mState ) ) {}
|
||||
UICommandShortcutBinding& operator=( UICommandShortcutBinding&& other ) noexcept {
|
||||
if ( this != &other ) {
|
||||
disconnect();
|
||||
mState = std::move( other.mState );
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
/** @brief Removes this shortcut endpoint and restores any previous mapping. */
|
||||
void disconnect() {
|
||||
if ( !mState )
|
||||
return;
|
||||
if ( mState->target ) {
|
||||
auto& keyBindings = mState->target->getKeyBindings();
|
||||
if ( keyBindings.getCommandFromKeyBind( mState->shortcut ) == mState->commandName ) {
|
||||
keyBindings.removeKeybind( mState->shortcut );
|
||||
if ( !mState->previousCommand.empty() )
|
||||
keyBindings.addKeybind( mState->shortcut, mState->previousCommand );
|
||||
}
|
||||
mState->target->removeKeyBindingCommand( mState->commandName );
|
||||
}
|
||||
mState.reset();
|
||||
}
|
||||
explicit operator bool() const { return mState && mState->target; }
|
||||
|
||||
private:
|
||||
struct State {
|
||||
Target* target{ nullptr };
|
||||
KeyBindings::Shortcut shortcut;
|
||||
std::string commandName;
|
||||
std::string previousCommand;
|
||||
EventConnection targetConnection;
|
||||
};
|
||||
inline static std::atomic<Uint64> sNextCommandId{ 0 };
|
||||
std::shared_ptr<State> mState;
|
||||
};
|
||||
|
||||
template <typename Target>
|
||||
/** @return A scoped binding between an existing command and a keyboard shortcut. */
|
||||
UICommandShortcutBinding<Target> bindCommand( UICommand& command, Target& target,
|
||||
const KeyBindings::Shortcut& shortcut ) {
|
||||
return UICommandShortcutBinding<Target>( command, target, shortcut );
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Owns a command together with its primary widget and shortcut bindings.
|
||||
*
|
||||
* This is the concise form for the common case where an action is exposed by one clickable widget
|
||||
* and one default shortcut. Keep the returned object alive for as long as both bindings are needed.
|
||||
*/
|
||||
template <typename ShortcutTarget> class UICommandBindingSet {
|
||||
public:
|
||||
/** @brief Creates an always-enabled command with widget and shortcut endpoints. */
|
||||
UICommandBindingSet( std::function<void()> execute, UIWidget& widget,
|
||||
ShortcutTarget& shortcutTarget, const KeyBindings::Shortcut& shortcut ) :
|
||||
mCommand( std::move( execute ) ),
|
||||
mWidgetBinding( mCommand, widget ),
|
||||
mShortcutBinding( mCommand, shortcutTarget, shortcut ) {}
|
||||
|
||||
template <typename Source>
|
||||
/** @brief Creates a command following @p enabled with widget and shortcut endpoints. */
|
||||
UICommandBindingSet( std::function<void()> execute, Source& enabled, UIWidget& widget,
|
||||
ShortcutTarget& shortcutTarget, const KeyBindings::Shortcut& shortcut ) :
|
||||
mCommand( std::move( execute ), enabled ),
|
||||
mWidgetBinding( mCommand, widget ),
|
||||
mShortcutBinding( mCommand, shortcutTarget, shortcut ) {}
|
||||
|
||||
UICommandBindingSet( const UICommandBindingSet& ) = delete;
|
||||
UICommandBindingSet& operator=( const UICommandBindingSet& ) = delete;
|
||||
UICommandBindingSet( UICommandBindingSet&& ) noexcept = default;
|
||||
UICommandBindingSet& operator=( UICommandBindingSet&& ) noexcept = default;
|
||||
|
||||
/** @return The owned command for explicit execution or additional endpoint bindings. */
|
||||
UICommand& command() { return mCommand; }
|
||||
|
||||
/** @return The owned command. */
|
||||
const UICommand& command() const { return mCommand; }
|
||||
|
||||
private:
|
||||
UICommand mCommand;
|
||||
UICommandBinding mWidgetBinding;
|
||||
UICommandShortcutBinding<ShortcutTarget> mShortcutBinding;
|
||||
};
|
||||
|
||||
template <typename ShortcutTarget>
|
||||
/**
|
||||
* @brief Creates an always-enabled command with a primary widget and shortcut.
|
||||
* @return A scoped object that owns the command and both endpoint bindings.
|
||||
*/
|
||||
UICommandBindingSet<ShortcutTarget> bindCommand( std::function<void()> execute, UIWidget& widget,
|
||||
ShortcutTarget& shortcutTarget,
|
||||
const KeyBindings::Shortcut& shortcut ) {
|
||||
return UICommandBindingSet<ShortcutTarget>( std::move( execute ), widget, shortcutTarget,
|
||||
shortcut );
|
||||
}
|
||||
|
||||
template <typename Source, typename ShortcutTarget>
|
||||
/**
|
||||
* @brief Creates a conditionally enabled command with a primary widget and shortcut.
|
||||
* @return A scoped object that owns the command and both endpoint bindings.
|
||||
*/
|
||||
UICommandBindingSet<ShortcutTarget> bindCommand( std::function<void()> execute, Source& enabled,
|
||||
UIWidget& widget, ShortcutTarget& shortcutTarget,
|
||||
const KeyBindings::Shortcut& shortcut ) {
|
||||
return UICommandBindingSet<ShortcutTarget>( std::move( execute ), enabled, widget,
|
||||
shortcutTarget, shortcut );
|
||||
}
|
||||
|
||||
}} // namespace EE::UI
|
||||
|
||||
#endif
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
#include <eepp/core/containers.hpp>
|
||||
#include <eepp/core/debug.hpp>
|
||||
#include <eepp/ui/uivalueconverter.hpp>
|
||||
#include <eepp/ui/databinding/uivalueconverter.hpp>
|
||||
#include <eepp/ui/uiwidget.hpp>
|
||||
#include <memory>
|
||||
#include <variant>
|
||||
@@ -21,9 +21,10 @@ namespace EE { namespace UI {
|
||||
* decides whether widget input is acceptable. Values passed to set() are authoritative model state
|
||||
* and are formatted through fromValue().
|
||||
*
|
||||
* @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.
|
||||
* Raw-pointer bindings do not own their value. The external object must outlive the UIDataBind and
|
||||
* every synchronous callback delivery, or reset() must be called before it is destroyed. The
|
||||
* shared_ptr overload retains the value through binding lifetime and callback delivery without
|
||||
* copying T. UIProperty uses that retained form automatically.
|
||||
*
|
||||
* 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
|
||||
@@ -43,11 +44,47 @@ namespace EE { namespace UI {
|
||||
*/
|
||||
template <typename T> class UIDataBind {
|
||||
public:
|
||||
using ValueType = T;
|
||||
using Converter = UIValueConverter<T>;
|
||||
using Callback = typename ObservableValue<T>::Callback;
|
||||
using Connection = typename ObservableValue<Uint64>::Connection;
|
||||
|
||||
/**
|
||||
* @brief Assignment-compatible callback storage retained without copying its callable target.
|
||||
*
|
||||
* Allocation, when required, happens when the callback is assigned. Notification only copies
|
||||
* the shared handle, keeping self-destruction safe without allocating in the delivery path.
|
||||
*/
|
||||
class CallbackSlot {
|
||||
public:
|
||||
CallbackSlot() = default;
|
||||
CallbackSlot( const CallbackSlot& ) = default;
|
||||
CallbackSlot( CallbackSlot&& ) noexcept = default;
|
||||
CallbackSlot& operator=( const CallbackSlot& ) = default;
|
||||
CallbackSlot& operator=( CallbackSlot&& ) noexcept = default;
|
||||
|
||||
CallbackSlot& operator=( Callback callback ) {
|
||||
mCallback = callback ? std::make_shared<Callback>( std::move( callback ) ) : nullptr;
|
||||
return *this;
|
||||
}
|
||||
|
||||
explicit operator bool() const { return static_cast<bool>( mCallback ); }
|
||||
|
||||
void operator()( const T& value ) const {
|
||||
if ( mCallback )
|
||||
( *mCallback )( value );
|
||||
}
|
||||
|
||||
std::shared_ptr<Callback> retain() const { return mCallback; }
|
||||
|
||||
private:
|
||||
std::shared_ptr<Callback> mCallback;
|
||||
};
|
||||
|
||||
// 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(); }
|
||||
|
||||
@@ -68,6 +105,26 @@ template <typename T> class UIDataBind {
|
||||
new UIDataBind<T>( t, widget, converter, valueKey, eventType ) );
|
||||
}
|
||||
|
||||
/** @brief Creates a binding that retains @p value through callback delivery. */
|
||||
static std::unique_ptr<UIDataBind<T>>
|
||||
New( std::shared_ptr<T> value, const UnorderedSet<UIWidget*>& widgets,
|
||||
const Converter& converter = Converter::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) {
|
||||
return std::unique_ptr<UIDataBind<T>>(
|
||||
new UIDataBind<T>( std::move( value ), widgets, converter, valueKey, eventType ) );
|
||||
}
|
||||
|
||||
/** @brief Creates a binding that retains @p value through callback delivery. */
|
||||
static std::unique_ptr<UIDataBind<T>>
|
||||
New( std::shared_ptr<T> value, UIWidget* widget,
|
||||
const Converter& converter = Converter::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) {
|
||||
return std::unique_ptr<UIDataBind<T>>(
|
||||
new UIDataBind<T>( std::move( value ), widget, converter, valueKey, eventType ) );
|
||||
}
|
||||
|
||||
UIDataBind() = default;
|
||||
UIDataBind( const UIDataBind& ) = delete;
|
||||
UIDataBind& operator=( const UIDataBind& ) = delete;
|
||||
@@ -84,26 +141,87 @@ template <typename T> class UIDataBind {
|
||||
UIDataBind( T* t, UIWidget* widget, const Converter& converter = Converter::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) {
|
||||
init( t, { widget }, converter, valueKey, eventType );
|
||||
init( t, widget, converter, valueKey, eventType );
|
||||
}
|
||||
|
||||
UIDataBind( std::shared_ptr<T> value, const UnorderedSet<UIWidget*>& widgets,
|
||||
const Converter& converter = Converter::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) {
|
||||
init( std::move( value ), widgets, converter, valueKey, eventType );
|
||||
}
|
||||
|
||||
UIDataBind( std::shared_ptr<T> value, UIWidget* widget,
|
||||
const Converter& converter = Converter::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) {
|
||||
init( std::move( value ), widget, converter, valueKey, eventType );
|
||||
}
|
||||
|
||||
void init( T* t, UIWidget* widget, const Converter& converter = Converter::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) {
|
||||
eeASSERT( widget != nullptr );
|
||||
prepareInitialization( t, converter, valueKey, eventType );
|
||||
widgets.insert( widget );
|
||||
bindListeners( widget );
|
||||
finishInitialization();
|
||||
}
|
||||
|
||||
void init( T* t, const UnorderedSet<UIWidget*>& widgets,
|
||||
const Converter& converter = Converter::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;
|
||||
prepareInitialization( t, converter, valueKey, eventType );
|
||||
// Insert explicitly instead of assigning the unordered_dense set. Besides avoiding a full
|
||||
// table copy, this sidesteps GCC's incorrect -Warray-bounds diagnosis in unordered_dense's
|
||||
// vector copy assignment.
|
||||
this->widgets.reserve( widgets.size() );
|
||||
for ( auto widget : widgets ) {
|
||||
eeASSERT( widget != nullptr );
|
||||
this->widgets.insert( widget );
|
||||
bindListeners( widget );
|
||||
}
|
||||
set( *data );
|
||||
dataInitialized = true;
|
||||
finishInitialization();
|
||||
}
|
||||
|
||||
/** @brief Reinitializes the binding while retaining @p value. */
|
||||
void init( std::shared_ptr<T> value, const UnorderedSet<UIWidget*>& widgets,
|
||||
const Converter& converter = Converter::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) {
|
||||
eeASSERT( value );
|
||||
T* data = value.get();
|
||||
init( data, widgets, converter, valueKey, eventType );
|
||||
retainedOwner = std::move( value );
|
||||
}
|
||||
|
||||
/** @brief Reinitializes the binding for one widget while retaining @p value. */
|
||||
void init( std::shared_ptr<T> value, UIWidget* widget,
|
||||
const Converter& converter = Converter::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) {
|
||||
eeASSERT( widget != nullptr );
|
||||
eeASSERT( value );
|
||||
T* data = value.get();
|
||||
init( data, widget, converter, valueKey, eventType );
|
||||
retainedOwner = std::move( value );
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Reinitializes the binding with externally retained storage.
|
||||
*
|
||||
* @p owner must keep @p value alive. This supports values embedded in a larger shared state
|
||||
* without constructing an aliasing shared_ptr or allocating a separate value control block.
|
||||
*/
|
||||
template <typename Widgets>
|
||||
void initRetained( T* value, std::shared_ptr<void> owner, Widgets&& widgets,
|
||||
const Converter& converter = Converter::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) {
|
||||
eeASSERT( owner );
|
||||
init( value, std::forward<Widgets>( widgets ), converter, valueKey, eventType );
|
||||
retainedOwner = std::move( owner );
|
||||
}
|
||||
|
||||
/** Propagates the authoritative model value and reports formatting failures. */
|
||||
@@ -117,6 +235,30 @@ template <typename T> class UIDataBind {
|
||||
return *data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Observes later changes made through this binding.
|
||||
* @return A scoped connection, or an empty connection when the binding is not initialized.
|
||||
*
|
||||
* Direct writes through the external pointer cannot be observed; use set() for model-originated
|
||||
* changes that must be published. Notifications publish an integer revision internally, so T is
|
||||
* never copied for observer delivery. Shared bindings retain their value in each observer
|
||||
* adapter; raw bindings require the external value to survive the complete callback sequence.
|
||||
*/
|
||||
Connection observe( Callback callback ) {
|
||||
if ( !isInitialized() )
|
||||
return {};
|
||||
if ( !changeSignal )
|
||||
changeSignal = std::make_shared<ObservableValue<Uint64>>( 0 );
|
||||
auto owner = retainedOwner;
|
||||
T* observedData = data;
|
||||
return changeSignal->observe( [callback = std::move( callback ), owner = std::move( owner ),
|
||||
observedData]( const Uint64& ) {
|
||||
// The captured owner exists solely to retain the storage containing observedData.
|
||||
(void)owner;
|
||||
callback( *observedData );
|
||||
} );
|
||||
}
|
||||
|
||||
/** @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;
|
||||
@@ -133,6 +275,9 @@ template <typename T> class UIDataBind {
|
||||
converter = Converter();
|
||||
validation.clear();
|
||||
validationEmitter = nullptr;
|
||||
changeSignal.reset();
|
||||
retainedOwner.reset();
|
||||
nextNotificationRevision = 1;
|
||||
inSetValue = false;
|
||||
dataInitialized = false;
|
||||
property = nullptr;
|
||||
@@ -178,12 +323,19 @@ template <typename T> class UIDataBind {
|
||||
|
||||
const PropertyDefinition* getPropertyDefinition() const { return property; }
|
||||
|
||||
std::function<void( const T& newVal )> onValueChangeCb;
|
||||
CallbackSlot onValueChangeCb;
|
||||
|
||||
const UnorderedSet<UIWidget*>& getWidgets() const { return widgets; }
|
||||
|
||||
/**
|
||||
* @return The widget that produced the current input error, or nullptr for valid state or a
|
||||
* model-to-widget formatting error.
|
||||
*/
|
||||
UIWidget* getValidationEmitter() const { return validationEmitter; }
|
||||
|
||||
/** @return Observable converter error state for this binding. */
|
||||
UIValueValidationState& validationState() { return validation; }
|
||||
|
||||
const UIValueValidationState& validationState() const { return validation; }
|
||||
bool isValid() const { return validation.isValid(); }
|
||||
|
||||
@@ -201,9 +353,8 @@ template <typename T> class UIDataBind {
|
||||
*data = std::forward<U>( t );
|
||||
auto result = setValueChange();
|
||||
inSetValue = false;
|
||||
if ( onValueChangeCb )
|
||||
onValueChangeCb( *data );
|
||||
setValidationResult( result );
|
||||
notifyValueChange();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -217,6 +368,29 @@ template <typename T> class UIDataBind {
|
||||
UIValueValidationState validation;
|
||||
UIWidget* validationEmitter{ nullptr };
|
||||
Event::EventType eventType{ Event::OnValueChange };
|
||||
// Observation publishes a revision instead of cloning T. Observer adapters read from retained
|
||||
// storage, or from the caller-owned pointer under the raw binding's lifetime contract.
|
||||
std::shared_ptr<ObservableValue<Uint64>> changeSignal;
|
||||
// Ownership and access are intentionally separate. A type-erased owner can retain either a T
|
||||
// allocated directly or a T embedded in a larger shared state without an aliasing shared_ptr.
|
||||
std::shared_ptr<void> retainedOwner;
|
||||
Uint64 nextNotificationRevision{ 1 };
|
||||
|
||||
void prepareInitialization( T* value, const Converter& valueConverter,
|
||||
const std::string& valueKey,
|
||||
const Event::EventType& valueEventType ) {
|
||||
eeASSERT( value != nullptr );
|
||||
reset();
|
||||
data = value;
|
||||
property = StyleSheetSpecification::instance()->getProperty( valueKey );
|
||||
converter = valueConverter;
|
||||
eventType = valueEventType;
|
||||
}
|
||||
|
||||
void finishInitialization() {
|
||||
set( *data );
|
||||
dataInitialized = true;
|
||||
}
|
||||
|
||||
void bindListeners( UIWidget* widget ) {
|
||||
auto& widgetConnections = connections[widget];
|
||||
@@ -274,8 +448,35 @@ template <typename T> class UIDataBind {
|
||||
inSetValue = false;
|
||||
validationEmitter = nullptr;
|
||||
validation.clear();
|
||||
if ( onValueChangeCb )
|
||||
onValueChangeCb( *data );
|
||||
notifyValueChange();
|
||||
}
|
||||
|
||||
void notifyValueChange() {
|
||||
auto observed = changeSignal;
|
||||
auto callback = onValueChangeCb.retain();
|
||||
T* observedData = data;
|
||||
if ( !observed ) {
|
||||
if ( callback ) {
|
||||
auto owner = retainedOwner;
|
||||
( *callback )( *observedData );
|
||||
(void)owner;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ObservableValue suppresses equal assignments. A monotonically increasing revision turns
|
||||
// each binding change into a distinct signal without copying the bound T into the signal.
|
||||
const Uint64 revision = nextNotificationRevision++;
|
||||
if ( !callback ) {
|
||||
observed->set( revision );
|
||||
return;
|
||||
}
|
||||
auto owner = retainedOwner;
|
||||
// An observer may destroy this binding. The retained callback and value handles survive the
|
||||
// observable notification without allocation. Raw bindings instead require the caller to
|
||||
// keep their external value alive during the complete delivery.
|
||||
observed->set( revision );
|
||||
(void)owner;
|
||||
( *callback )( *observedData );
|
||||
}
|
||||
|
||||
UIValueValidationResult setValueChange() {
|
||||
@@ -0,0 +1,140 @@
|
||||
#ifndef EE_UI_UIOBSERVEDELIVERY_HPP
|
||||
#define EE_UI_UIOBSERVEDELIVERY_HPP
|
||||
|
||||
#include <eepp/core/observablevalue.hpp>
|
||||
#include <eepp/scene/node.hpp>
|
||||
#include <eepp/ui/uiwidget.hpp>
|
||||
#include <mutex>
|
||||
|
||||
namespace EE { namespace UI {
|
||||
|
||||
/**
|
||||
* @brief Scoped non-blocking delivery of observable changes to the UI thread.
|
||||
*
|
||||
* The scheduler must outlive this connection and is normally the owning UISceneNode. The endpoint
|
||||
* widget is not retained; queued work becomes a no-op after it closes. Source mutation remains the
|
||||
* producer's synchronization responsibility because ObservableValue itself is single-threaded.
|
||||
* In particular, construct and disconnect this observation only while the producer is stopped or
|
||||
* otherwise synchronized; observer registration and removal must not race source mutation.
|
||||
*/
|
||||
template <typename T> class UIThreadObservation {
|
||||
public:
|
||||
using Callback = std::function<void( UIWidget&, const T& )>;
|
||||
|
||||
UIThreadObservation() = default;
|
||||
UIThreadObservation( const UIThreadObservation& ) = delete;
|
||||
UIThreadObservation& operator=( const UIThreadObservation& ) = delete;
|
||||
UIThreadObservation( UIThreadObservation&& ) noexcept = default;
|
||||
UIThreadObservation& operator=( UIThreadObservation&& ) noexcept = default;
|
||||
|
||||
/**
|
||||
* @brief Observes @p source and queues @p callback on @p scheduler's UI thread.
|
||||
*
|
||||
* Delivery preserves source notification order. The callback receives the endpoint only while
|
||||
* it remains alive. Keep the returned observation alive and ensure the scheduler outlives it.
|
||||
*/
|
||||
template <typename Source>
|
||||
UIThreadObservation( Source& source, Node& scheduler, UIWidget& endpoint, Callback callback ) {
|
||||
auto state = std::make_shared<State>();
|
||||
state->endpoint = &endpoint;
|
||||
state->callback = std::move( callback );
|
||||
std::weak_ptr<State> weakState = state;
|
||||
state->endpointConnection = endpoint.connect( Event::OnClose, [weakState]( const Event* ) {
|
||||
if ( auto state = weakState.lock() ) {
|
||||
std::lock_guard<std::mutex> lock( state->mutex );
|
||||
state->endpoint = nullptr;
|
||||
}
|
||||
} );
|
||||
auto sourceConnection =
|
||||
source.observe( [weakState, scheduler = &scheduler]( const T& value ) {
|
||||
if ( auto state = weakState.lock() ) {
|
||||
std::lock_guard<std::mutex> lock( state->mutex );
|
||||
if ( !state->endpoint )
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// Runnable uses SmallFunction<48>, so common small values travel inline with no
|
||||
// per-delivery allocation. Large values use the scheduler's existing heap fallback.
|
||||
scheduler->ensureMainThread( [weakState, delivery = T( value )] {
|
||||
if ( auto state = weakState.lock() ) {
|
||||
UIWidget* endpoint = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock( state->mutex );
|
||||
endpoint = state->endpoint;
|
||||
}
|
||||
// Delivery runs on the UI thread, so the endpoint cannot close between this
|
||||
// check and the callback except from within the callback itself.
|
||||
if ( endpoint )
|
||||
state->callback( *endpoint, delivery );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
state->sourceConnection = std::make_unique<SourceConnection<decltype( sourceConnection )>>(
|
||||
std::move( sourceConnection ) );
|
||||
mState = std::move( state );
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Stops future delivery and invalidates already queued callbacks.
|
||||
*
|
||||
* Synchronize with the source producer before calling this; see the class thread-safety notes.
|
||||
*/
|
||||
void disconnect() {
|
||||
if ( mState ) {
|
||||
mState->sourceConnection->disconnect();
|
||||
std::lock_guard<std::mutex> lock( mState->mutex );
|
||||
mState->endpoint = nullptr;
|
||||
}
|
||||
mState.reset();
|
||||
}
|
||||
|
||||
/** @return Whether the source is connected and the endpoint remains alive. */
|
||||
explicit operator bool() const {
|
||||
if ( !mState || !mState->sourceConnection || !mState->sourceConnection->connected() )
|
||||
return false;
|
||||
std::lock_guard<std::mutex> lock( mState->mutex );
|
||||
return mState->endpoint != nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
struct SourceConnectionBase {
|
||||
virtual ~SourceConnectionBase() = default;
|
||||
virtual void disconnect() = 0;
|
||||
virtual bool connected() const = 0;
|
||||
};
|
||||
|
||||
template <typename Connection> struct SourceConnection final : SourceConnectionBase {
|
||||
explicit SourceConnection( Connection connection ) :
|
||||
connection( std::move( connection ) ) {}
|
||||
void disconnect() override { connection.disconnect(); }
|
||||
bool connected() const override { return static_cast<bool>( connection ); }
|
||||
Connection connection;
|
||||
};
|
||||
|
||||
struct State {
|
||||
mutable std::mutex mutex;
|
||||
UIWidget* endpoint{ nullptr };
|
||||
Callback callback;
|
||||
EventConnection endpointConnection;
|
||||
// Source types expose different scoped connection classes. Type erasure happens once when
|
||||
// constructing the observation and adds no work or allocation to value delivery.
|
||||
std::unique_ptr<SourceConnectionBase> sourceConnection;
|
||||
};
|
||||
std::shared_ptr<State> mState;
|
||||
};
|
||||
|
||||
template <typename Source, typename Callback>
|
||||
/**
|
||||
* @brief Creates a scoped UI-thread observation while deducing the source value type.
|
||||
* @see UIThreadObservation
|
||||
*/
|
||||
auto observeOnUIThread( Source& source, Node& scheduler, UIWidget& endpoint, Callback&& callback ) {
|
||||
using T = typename Source::ValueType;
|
||||
return UIThreadObservation<T>( source, scheduler, endpoint,
|
||||
std::forward<Callback>( callback ) );
|
||||
}
|
||||
|
||||
}} // namespace EE::UI
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,367 @@
|
||||
#ifndef EE_UI_UIPROPERTY_HPP
|
||||
#define EE_UI_UIPROPERTY_HPP
|
||||
|
||||
#include <eepp/ui/databinding/uidatabind.hpp>
|
||||
#include <type_traits>
|
||||
|
||||
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 declaring a separate model value and binding, and owns its UIDataBind
|
||||
* directly. A custom UIValueConverter can provide presentation-specific parsing and formatting.
|
||||
*
|
||||
* UIProperty also implements the common observable-source interface (ValueType, get(), and
|
||||
* observe()), so it can directly feed ComputedValue and UICommand. UIBindingGroup can track a
|
||||
* property with `form += property`, including its validation, dirty state, and connected widgets.
|
||||
*
|
||||
* @code
|
||||
* UIProperty<double> celsius( 0.0, celsiusInput );
|
||||
* UIProperty<double> fahrenheit( 32.0, fahrenheitInput );
|
||||
* celsius.changed( [&fahrenheit]( double value ) {
|
||||
* fahrenheit = value * 9.0 / 5.0 + 32.0;
|
||||
* } );
|
||||
* @endcode
|
||||
*/
|
||||
template <typename T> class UIProperty {
|
||||
private:
|
||||
struct LifetimeState {
|
||||
LifetimeState( UIProperty<T>* property, T value ) :
|
||||
value( std::move( value ) ), property( property ) {}
|
||||
T value;
|
||||
UIProperty<T>* property{ nullptr };
|
||||
// Most UIProperty instances are not held by UIBindingGroup. Allocate the lifetime
|
||||
// observable only when a consumer actually subscribes to destruction.
|
||||
std::unique_ptr<ObservableValue<bool>> alive;
|
||||
};
|
||||
|
||||
public:
|
||||
using ValueType = T;
|
||||
using Callback = typename UIDataBind<T>::Callback;
|
||||
using Connection = typename UIDataBind<T>::Connection;
|
||||
using ValidationConnection = typename UIValueValidationState::Connection;
|
||||
|
||||
/**
|
||||
* @brief Lifetime-safe, non-owning access used by containers such as UIBindingGroup.
|
||||
*
|
||||
* Every operation becomes a harmless no-op or empty result after the UIProperty is destroyed.
|
||||
* Like UIProperty itself, this handle is restricted to the widgets' owning UI thread.
|
||||
*/
|
||||
class WeakHandle {
|
||||
public:
|
||||
WeakHandle() = default;
|
||||
|
||||
/** @return A copy of the value, or std::nullopt after expiration or binding reset. */
|
||||
std::optional<T> get() const {
|
||||
auto state = mState.lock();
|
||||
return state && state->property && state->property->databind().isInitialized()
|
||||
? std::optional<T>( state->property->get() )
|
||||
: std::nullopt;
|
||||
}
|
||||
|
||||
/** @return true when the live property was assigned @p value. */
|
||||
bool set( const T& value ) const {
|
||||
auto state = mState.lock();
|
||||
if ( !state || !state->property || !state->property->databind().isInitialized() )
|
||||
return false;
|
||||
*state->property = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return A scoped value observer connection, or an empty connection after expiration. */
|
||||
Connection observe( Callback callback ) const {
|
||||
auto state = mState.lock();
|
||||
return state && state->property && state->property->databind().isInitialized()
|
||||
? state->property->observe( std::move( callback ) )
|
||||
: Connection{};
|
||||
}
|
||||
|
||||
/** @return A scoped connection notified when the property is about to expire. */
|
||||
ObservableValue<bool>::Connection
|
||||
observeLifetime( ObservableValue<bool>::Callback callback ) const {
|
||||
auto state = mState.lock();
|
||||
if ( !state )
|
||||
return {};
|
||||
if ( !state->alive )
|
||||
state->alive = std::make_unique<ObservableValue<bool>>( true );
|
||||
return state->alive->observe( std::move( callback ) );
|
||||
}
|
||||
|
||||
/** @return A scoped validation observer, or an empty connection after expiration. */
|
||||
ValidationConnection observeValidation( UIValueValidationState::Callback callback ) const {
|
||||
auto state = mState.lock();
|
||||
return state && state->property && state->property->databind().isInitialized()
|
||||
? state->property->databind().validationState().observe(
|
||||
std::move( callback ) )
|
||||
: ValidationConnection{};
|
||||
}
|
||||
|
||||
/** @return Current validation, or success after expiration. */
|
||||
UIValueValidationResult validation() const {
|
||||
auto state = mState.lock();
|
||||
return state && state->property && state->property->databind().isInitialized()
|
||||
? state->property->validationState().result()
|
||||
: UIValueValidationResult::success();
|
||||
}
|
||||
|
||||
/** @return The widget that produced the current input error, or nullptr. */
|
||||
UIWidget* validationEmitter() const {
|
||||
auto state = mState.lock();
|
||||
return state && state->property && state->property->databind().isInitialized()
|
||||
? state->property->databind().getValidationEmitter()
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
/** @return All currently connected widgets, or an empty vector after expiration. */
|
||||
std::vector<UIWidget*> widgets() const {
|
||||
auto state = mState.lock();
|
||||
if ( !state || !state->property || !state->property->databind().isInitialized() )
|
||||
return {};
|
||||
const auto& widgets = state->property->databind().getWidgets();
|
||||
return { widgets.begin(), widgets.end() };
|
||||
}
|
||||
|
||||
/** Appends connected widgets without creating an intermediate collection. */
|
||||
template <typename Container> void appendWidgets( Container& destination ) const {
|
||||
auto state = mState.lock();
|
||||
if ( !state || !state->property || !state->property->databind().isInitialized() )
|
||||
return;
|
||||
const auto& widgets = state->property->databind().getWidgets();
|
||||
destination.insert( destination.end(), widgets.begin(), widgets.end() );
|
||||
}
|
||||
|
||||
/** Invokes @p callback for every connected widget without allocating a collection. */
|
||||
template <typename WidgetCallback> void forEachWidget( WidgetCallback&& callback ) const {
|
||||
auto state = mState.lock();
|
||||
if ( !state || !state->property || !state->property->databind().isInitialized() )
|
||||
return;
|
||||
for ( auto widget : state->property->databind().getWidgets() )
|
||||
callback( widget );
|
||||
}
|
||||
|
||||
/** @return The first enabled connected widget, or nullptr. */
|
||||
UIWidget* firstEnabledWidget() const {
|
||||
auto state = mState.lock();
|
||||
if ( !state || !state->property || !state->property->databind().isInitialized() )
|
||||
return nullptr;
|
||||
for ( auto widget : state->property->databind().getWidgets() )
|
||||
if ( widget && widget->isEnabled() )
|
||||
return widget;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
explicit operator bool() const {
|
||||
auto state = mState.lock();
|
||||
return state && state->property && state->property->databind().isInitialized();
|
||||
}
|
||||
|
||||
private:
|
||||
friend class UIProperty<T>;
|
||||
explicit WeakHandle( const std::shared_ptr<LifetimeState>& state ) : mState( state ) {}
|
||||
std::weak_ptr<LifetimeState> mState;
|
||||
};
|
||||
|
||||
UIProperty( const UIProperty& ) = delete;
|
||||
UIProperty& operator=( const UIProperty& ) = delete;
|
||||
UIProperty( UIProperty&& ) = delete;
|
||||
UIProperty& operator=( UIProperty&& ) = delete;
|
||||
~UIProperty() {
|
||||
mLifetime->property = nullptr;
|
||||
if ( mLifetime->alive )
|
||||
*mLifetime->alive = false;
|
||||
}
|
||||
|
||||
UIProperty( T defaultValue, UIWidget* widget,
|
||||
const typename EE::UI::UIDataBind<T>::Converter& converter =
|
||||
EE::UI::UIDataBind<T>::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) :
|
||||
mLifetime( std::make_shared<LifetimeState>( this, std::move( defaultValue ) ) ) {
|
||||
initializeBinding( widget, converter, valueKey, eventType );
|
||||
}
|
||||
|
||||
UIProperty( T defaultValue, const UnorderedSet<UIWidget*>& widgets = {},
|
||||
const typename EE::UI::UIDataBind<T>::Converter& converter =
|
||||
EE::UI::UIDataBind<T>::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) :
|
||||
mLifetime( std::make_shared<LifetimeState>( this, std::move( defaultValue ) ) ) {
|
||||
initializeBinding( widgets, converter, valueKey, eventType );
|
||||
}
|
||||
|
||||
UIProperty( const UnorderedSet<UIWidget*>& widgets = {},
|
||||
const typename EE::UI::UIDataBind<T>::Converter& converter =
|
||||
EE::UI::UIDataBind<T>::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) :
|
||||
mLifetime( std::make_shared<LifetimeState>( this, T{} ) ) {
|
||||
initializeBinding( widgets, converter, valueKey, eventType );
|
||||
}
|
||||
|
||||
UIProperty( UIWidget* widget,
|
||||
const typename EE::UI::UIDataBind<T>::Converter& converter =
|
||||
EE::UI::UIDataBind<T>::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) :
|
||||
mLifetime( std::make_shared<LifetimeState>( this, T{} ) ) {
|
||||
initializeBinding( widget, converter, valueKey, eventType );
|
||||
}
|
||||
|
||||
UIProperty& operator=( const T& newVal ) {
|
||||
mBindedData.set( newVal );
|
||||
return *this;
|
||||
}
|
||||
|
||||
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 <typename U = T,
|
||||
std::enable_if_t<(std::is_arithmetic_v<U> && !std::is_same_v<U, bool>) ||
|
||||
std::is_same_v<U, std::string> || std::is_same_v<U, String>,
|
||||
int> = 0>
|
||||
UIProperty& operator+=( const T& operand ) {
|
||||
return *this = value() + operand;
|
||||
}
|
||||
|
||||
template <
|
||||
typename U = T,
|
||||
std::enable_if_t<std::is_same_v<U, std::string> || std::is_same_v<U, String>, int> = 0>
|
||||
T operator+( const T& operand ) const {
|
||||
return value() + operand;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
UIProperty& operator-=( const T& operand ) {
|
||||
return *this = value() - operand;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
UIProperty& operator*=( const T& operand ) {
|
||||
return *this = value() * operand;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
UIProperty& operator/=( const T& operand ) {
|
||||
return *this = value() / operand;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
UIProperty& operator++() {
|
||||
return *this += 1;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
T operator++( int ) {
|
||||
T previous = value();
|
||||
++( *this );
|
||||
return previous;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
UIProperty& operator--() {
|
||||
return *this -= 1;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
T operator--( int ) {
|
||||
T previous = value();
|
||||
--( *this );
|
||||
return previous;
|
||||
}
|
||||
/** @} */
|
||||
|
||||
/** @return The current synchronized value. */
|
||||
const T& value() const { return mBindedData.get(); }
|
||||
|
||||
/** @return The current synchronized value; enables the common observable-source interface. */
|
||||
const T& get() const { return value(); }
|
||||
|
||||
/**
|
||||
* @brief Observes later model- or widget-originated value changes.
|
||||
* @return A scoped connection; destroying it disconnects the callback.
|
||||
*/
|
||||
Connection observe( Callback callback ) { return mBindedData.observe( std::move( callback ) ); }
|
||||
|
||||
/** @return A non-owning handle that expires safely when this property is destroyed. */
|
||||
WeakHandle weakHandle() { return WeakHandle( mLifetime ); }
|
||||
|
||||
const UIDataBind<T>& databind() const { return mBindedData; }
|
||||
|
||||
UIDataBind<T>& databind() { return mBindedData; }
|
||||
|
||||
/** @return Current converter error state. */
|
||||
const UIValueValidationState& validationState() const { return mBindedData.validationState(); }
|
||||
|
||||
/** @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;
|
||||
}
|
||||
|
||||
const T& operator*() const noexcept { return value(); }
|
||||
|
||||
const T* operator->() const noexcept { return &value(); }
|
||||
|
||||
operator const T&() const noexcept { return value(); }
|
||||
|
||||
/** @brief Sets the callback invoked after the synchronized value changes. */
|
||||
UIProperty& changed( const std::function<void( const T& newVal )>& fn ) {
|
||||
mBindedData.onValueChangeCb = fn;
|
||||
return *this;
|
||||
}
|
||||
|
||||
UIProperty& changed( std::function<void( const T& newVal )>&& fn ) {
|
||||
mBindedData.onValueChangeCb = std::move( fn );
|
||||
return *this;
|
||||
}
|
||||
|
||||
protected:
|
||||
template <typename Widgets>
|
||||
void initializeBinding( Widgets&& widgets,
|
||||
const typename EE::UI::UIDataBind<T>::Converter& converter,
|
||||
const std::string& valueKey, const Event::EventType& eventType ) {
|
||||
// Retain the complete lifetime state while accessing its embedded value directly. Keeping
|
||||
// ownership separate avoids both an extra value allocation and an aliasing shared_ptr.
|
||||
mBindedData.initRetained( &mLifetime->value, mLifetime, std::forward<Widgets>( widgets ),
|
||||
converter, valueKey, eventType );
|
||||
}
|
||||
|
||||
// Declaration order is intentional: the binding aliases mLifetime's allocation and must be
|
||||
// destroyed before the final owning reference is released.
|
||||
std::shared_ptr<LifetimeState> mLifetime;
|
||||
UIDataBind<T> mBindedData;
|
||||
};
|
||||
|
||||
}} // namespace EE::UI
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,319 @@
|
||||
#ifndef EE_UI_UIVALUEBINDING_HPP
|
||||
#define EE_UI_UIVALUEBINDING_HPP
|
||||
|
||||
#include <eepp/core/computedvalue.hpp>
|
||||
#include <eepp/core/observablevalue.hpp>
|
||||
#include <eepp/ui/databinding/uivalueconverter.hpp>
|
||||
#include <eepp/ui/uiwidget.hpp>
|
||||
|
||||
namespace EE { namespace UI {
|
||||
|
||||
/**
|
||||
* @brief Move-only two-way binding between an ObservableValue and a UIWidget property.
|
||||
*
|
||||
* The converter maps directly between T and the widget property string. Its toValue() callback
|
||||
* decides whether widget input may enter the model. Model-originated values are authoritative and
|
||||
* are formatted through fromValue().
|
||||
*
|
||||
* 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<std::string> userName{ "Ada" };
|
||||
* auto binding = bindValue( userName, textInput,
|
||||
* UIValueConverter<std::string>::converterString(),
|
||||
* "text", Event::OnTextChanged );
|
||||
* userName = "Grace"; // Updates textInput without coupling the model to UIWidget.
|
||||
* @endcode
|
||||
*/
|
||||
template <typename T> class UIValueBinding {
|
||||
public:
|
||||
using Converter = UIValueConverter<T>;
|
||||
|
||||
/** @return The standard converter for the bound value type. */
|
||||
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;
|
||||
|
||||
/**
|
||||
* @brief Starts synchronizing @p value with a property of @p widget.
|
||||
*
|
||||
* The current model value is applied to the widget immediately. Later @p eventType events parse
|
||||
* the widget property back into the model.
|
||||
*/
|
||||
UIValueBinding( ObservableValue<T>& value, UIWidget* widget,
|
||||
const Converter& converter = converterDefault(),
|
||||
const std::string& propertyName = "value",
|
||||
Event::EventType eventType = Event::OnValueChange ) {
|
||||
connect( value, widget, converter, propertyName, eventType );
|
||||
}
|
||||
|
||||
/** @brief Stops synchronization in both directions. Calling this repeatedly is safe. */
|
||||
void disconnect() { mState.reset(); }
|
||||
|
||||
/** @return Whether both model and widget endpoints are still alive and connected. */
|
||||
explicit operator bool() const { return mState && mState->widget && mState->value; }
|
||||
|
||||
/** @return Whether both model and widget endpoints are still alive and connected. */
|
||||
bool isConnected() const { return static_cast<bool>( *this ); }
|
||||
|
||||
/** @return Whether the most recent conversion or validation succeeded. */
|
||||
bool isValid() const { return !mState || mState->validation.isValid(); }
|
||||
|
||||
/** @return The bound widget, or nullptr after disconnection or widget destruction. */
|
||||
UIWidget* widget() const { return mState ? mState->widget : nullptr; }
|
||||
|
||||
/** @return A copy of the model value, or std::nullopt after disconnection. */
|
||||
std::optional<T> value() const { return mState ? mState->value.get() : std::nullopt; }
|
||||
|
||||
/** @return true when the connected model still exists and was assigned @p value. */
|
||||
bool setValue( const T& value ) { return mState && mState->value.set( value ); }
|
||||
|
||||
/** @return A scoped observer connection to later model changes, or an empty connection. */
|
||||
typename ObservableValue<T>::Connection
|
||||
observeValue( typename ObservableValue<T>::Callback callback ) {
|
||||
return mState ? mState->value.observe( std::move( callback ) )
|
||||
: typename ObservableValue<T>::Connection{};
|
||||
}
|
||||
|
||||
/** @return Observable conversion and input-validation state. */
|
||||
UIValueValidationState* validationState() { return mState ? &mState->validation : nullptr; }
|
||||
|
||||
const UIValueValidationState* validationState() const {
|
||||
return mState ? &mState->validation : nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
struct State {
|
||||
typename ObservableValue<T>::WeakHandle value;
|
||||
UIWidget* widget{ nullptr };
|
||||
const PropertyDefinition* property{ nullptr };
|
||||
Converter converter;
|
||||
UIValueValidationState validation;
|
||||
bool synchronizing{ false };
|
||||
typename ObservableValue<T>::Connection valueConnection;
|
||||
EventConnectionList widgetConnections;
|
||||
|
||||
bool applyToWidget( const T& newValue ) {
|
||||
if ( !widget )
|
||||
return false;
|
||||
auto converted = converter.fromValue( property, newValue );
|
||||
if ( !converted ) {
|
||||
validation.set( std::move( converted.validation ) );
|
||||
return false;
|
||||
}
|
||||
synchronizing = true;
|
||||
widget->applyProperty( StyleSheetProperty( property, *converted.value ) );
|
||||
synchronizing = false;
|
||||
validation.clear();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
void connect( ObservableValue<T>& value, UIWidget* widget, const Converter& converter,
|
||||
const std::string& propertyName, Event::EventType eventType ) {
|
||||
eeASSERT( widget != nullptr );
|
||||
auto state = std::make_shared<State>();
|
||||
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<State> 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 ) {
|
||||
auto proposed = state->converter.toValue(
|
||||
state->property,
|
||||
event->getNode()->asType<UIWidget>()->getPropertyString( state->property ) );
|
||||
if ( !proposed ) {
|
||||
state->validation.set( std::move( proposed.validation ) );
|
||||
return;
|
||||
}
|
||||
state->validation.clear();
|
||||
if ( !state->value.set( std::move( *proposed.value ) ) ) {
|
||||
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->validation.clear();
|
||||
state->widgetConnections.clear();
|
||||
}
|
||||
} );
|
||||
state->applyToWidget( value.get() );
|
||||
mState = std::move( state );
|
||||
}
|
||||
|
||||
std::shared_ptr<State> mState;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Move-only one-way binding from a read-only observable to a widget property.
|
||||
*
|
||||
* Sources must provide ValueType, get(), observe(), and an ObservableValue-compatible Connection.
|
||||
* The binding applies the current source value immediately and retains neither endpoint. Use this
|
||||
* for ComputedValue outputs or whenever widget edits must not update the source.
|
||||
*/
|
||||
template <typename T> class UIReadOnlyValueBinding {
|
||||
public:
|
||||
using Converter = UIValueConverter<T>;
|
||||
|
||||
UIReadOnlyValueBinding() = default;
|
||||
UIReadOnlyValueBinding( const UIReadOnlyValueBinding& ) = delete;
|
||||
UIReadOnlyValueBinding& operator=( const UIReadOnlyValueBinding& ) = delete;
|
||||
UIReadOnlyValueBinding( UIReadOnlyValueBinding&& ) noexcept = default;
|
||||
UIReadOnlyValueBinding& operator=( UIReadOnlyValueBinding&& ) noexcept = default;
|
||||
|
||||
/** @brief Starts one-way synchronization from @p source to a property of @p widget. */
|
||||
template <typename Source>
|
||||
UIReadOnlyValueBinding( Source& source, UIWidget* widget,
|
||||
const Converter& converter = Converter::converterDefault(),
|
||||
const std::string& propertyName = "value" ) {
|
||||
connect( source, widget, converter, propertyName );
|
||||
}
|
||||
|
||||
/** @brief Stops synchronization. Calling this repeatedly is safe. */
|
||||
void disconnect() { mState.reset(); }
|
||||
|
||||
/** @return Whether the source connection and widget endpoint remain active. */
|
||||
explicit operator bool() const {
|
||||
return mState && mState->widget && static_cast<bool>( mState->sourceConnection );
|
||||
}
|
||||
|
||||
/** @return Whether formatting the most recent source value succeeded. */
|
||||
bool isValid() const { return !mState || mState->validation.isValid(); }
|
||||
|
||||
/** @return Formatting validation state, or nullptr for an empty binding. */
|
||||
const UIValueValidationState* validationState() const {
|
||||
return mState ? &mState->validation : nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
struct State {
|
||||
UIWidget* widget{ nullptr };
|
||||
const PropertyDefinition* property{ nullptr };
|
||||
Converter converter;
|
||||
UIValueValidationState validation;
|
||||
typename ObservableValue<T>::Connection sourceConnection;
|
||||
EventConnection widgetConnection;
|
||||
|
||||
void applyToWidget( const T& value ) {
|
||||
if ( !widget )
|
||||
return;
|
||||
auto converted = converter.fromValue( property, value );
|
||||
if ( !converted ) {
|
||||
validation.set( std::move( converted.validation ) );
|
||||
return;
|
||||
}
|
||||
widget->applyProperty( StyleSheetProperty( property, *converted.value ) );
|
||||
validation.clear();
|
||||
}
|
||||
};
|
||||
|
||||
template <typename Source>
|
||||
void connect( Source& source, UIWidget* widget, const Converter& converter,
|
||||
const std::string& propertyName ) {
|
||||
eeASSERT( widget != nullptr );
|
||||
auto state = std::make_shared<State>();
|
||||
state->widget = widget;
|
||||
state->property = StyleSheetSpecification::instance()->getProperty( propertyName );
|
||||
state->converter = converter;
|
||||
eeASSERT( state->property != nullptr );
|
||||
eeASSERT( state->converter.fromValue );
|
||||
|
||||
std::weak_ptr<State> weakState = state;
|
||||
state->sourceConnection = source.observe( [weakState]( const T& value ) {
|
||||
if ( auto state = weakState.lock() )
|
||||
state->applyToWidget( value );
|
||||
} );
|
||||
state->widgetConnection = widget->connect( Event::OnClose, [weakState]( const Event* ) {
|
||||
if ( auto state = weakState.lock() ) {
|
||||
state->widget = nullptr;
|
||||
state->sourceConnection.disconnect();
|
||||
state->validation.clear();
|
||||
}
|
||||
} );
|
||||
state->applyToWidget( source.get() );
|
||||
mState = std::move( state );
|
||||
}
|
||||
|
||||
std::shared_ptr<State> mState;
|
||||
};
|
||||
|
||||
/** @brief Creates a scoped two-way binding between @p value and @p widget. */
|
||||
template <typename T>
|
||||
UIValueBinding<T>
|
||||
bindValue( ObservableValue<T>& value, UIWidget* widget,
|
||||
const UIValueConverter<T>& converter = UIValueConverter<T>::converterDefault(),
|
||||
const std::string& propertyName = "value",
|
||||
Event::EventType eventType = Event::OnValueChange ) {
|
||||
return UIValueBinding<T>( value, widget, converter, propertyName, eventType );
|
||||
}
|
||||
|
||||
/** @brief Creates a two-way binding to a non-default widget property using default conversion. */
|
||||
template <typename T>
|
||||
UIValueBinding<T> bindValue( ObservableValue<T>& value, UIWidget* widget,
|
||||
const std::string& propertyName,
|
||||
Event::EventType eventType = Event::OnValueChange ) {
|
||||
return UIValueBinding<T>( value, widget, UIValueConverter<T>::converterDefault(), propertyName,
|
||||
eventType );
|
||||
}
|
||||
|
||||
/** @brief Creates a scoped one-way binding from a computed value to a widget. */
|
||||
template <typename T, typename Calculator, typename... Dependencies>
|
||||
UIReadOnlyValueBinding<T>
|
||||
bindValue( ComputedValue<T, Calculator, Dependencies...>& value, UIWidget* widget,
|
||||
const UIValueConverter<T>& converter = UIValueConverter<T>::converterDefault(),
|
||||
const std::string& propertyName = "value" ) {
|
||||
return UIReadOnlyValueBinding<T>( value, widget, converter, propertyName );
|
||||
}
|
||||
|
||||
/** @brief Creates a read-only binding to a non-default property using default conversion. */
|
||||
template <typename T, typename Calculator, typename... Dependencies>
|
||||
UIReadOnlyValueBinding<T> bindValue( ComputedValue<T, Calculator, Dependencies...>& value,
|
||||
UIWidget* widget, const std::string& propertyName ) {
|
||||
return UIReadOnlyValueBinding<T>( value, widget, UIValueConverter<T>::converterDefault(),
|
||||
propertyName );
|
||||
}
|
||||
|
||||
/** @brief Creates a scoped one-way binding from any observable source to a widget. */
|
||||
template <typename Source>
|
||||
auto bindReadOnlyValue( Source& value, UIWidget* widget,
|
||||
const UIValueConverter<typename Source::ValueType>& converter =
|
||||
UIValueConverter<typename Source::ValueType>::converterDefault(),
|
||||
const std::string& propertyName = "value" ) {
|
||||
using T = typename Source::ValueType;
|
||||
return UIReadOnlyValueBinding<T>( value, widget, converter, propertyName );
|
||||
}
|
||||
|
||||
/** @brief Creates a one-way binding to a non-default property using default conversion. */
|
||||
template <typename Source>
|
||||
auto bindReadOnlyValue( Source& value, UIWidget* widget, const std::string& propertyName ) {
|
||||
using T = typename Source::ValueType;
|
||||
return UIReadOnlyValueBinding<T>( value, widget, UIValueConverter<T>::converterDefault(),
|
||||
propertyName );
|
||||
}
|
||||
|
||||
}} // namespace EE::UI
|
||||
|
||||
#endif
|
||||
+16
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
#include <eepp/core/string.hpp>
|
||||
#include <eepp/ui/css/stylesheetproperty.hpp>
|
||||
#include <eepp/ui/uivaluevalidation.hpp>
|
||||
#include <eepp/ui/databinding/uivaluevalidation.hpp>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
@@ -31,18 +31,32 @@ namespace EE { namespace UI {
|
||||
* @endcode
|
||||
*/
|
||||
template <typename T> struct UIValueConverter {
|
||||
/** @brief Parses a widget property string into the model type. */
|
||||
using ToValue =
|
||||
std::function<UIValueResult<T>( const CSS::PropertyDefinition*, const std::string& )>;
|
||||
|
||||
/** @brief Formats a model value as a widget property string. */
|
||||
using FromValue =
|
||||
std::function<UIValueResult<std::string>( const CSS::PropertyDefinition*, const T& )>;
|
||||
|
||||
UIValueConverter() = default;
|
||||
|
||||
/**
|
||||
* @brief Creates a converter with custom parsing and the default formatter for @p T.
|
||||
*
|
||||
* This is the common form for validation rules that only constrain text entering the model.
|
||||
*/
|
||||
explicit UIValueConverter( ToValue toValue ) :
|
||||
UIValueConverter( std::move( toValue ), converterDefault().fromValue ) {}
|
||||
|
||||
/** @brief Creates a converter with custom parsing and formatting policies. */
|
||||
UIValueConverter( ToValue toValue, FromValue fromValue ) :
|
||||
toValue( std::move( toValue ) ), fromValue( std::move( fromValue ) ) {}
|
||||
|
||||
ToValue toValue;
|
||||
FromValue fromValue;
|
||||
|
||||
/** @return The standard string conversion policy for @p T. */
|
||||
static UIValueConverter converterDefault() {
|
||||
return UIValueConverter(
|
||||
[]( const CSS::PropertyDefinition* property, const std::string& string ) {
|
||||
@@ -78,6 +92,7 @@ template <typename T> struct UIValueConverter {
|
||||
} );
|
||||
}
|
||||
|
||||
/** @return A converter that preserves text verbatim for string-compatible @p T. */
|
||||
static UIValueConverter converterString() {
|
||||
return UIValueConverter(
|
||||
[]( const CSS::PropertyDefinition*, const std::string& string ) {
|
||||
@@ -0,0 +1,183 @@
|
||||
#ifndef EE_UI_MODELS_OBSERVABLELISTMODEL_HPP
|
||||
#define EE_UI_MODELS_OBSERVABLELISTMODEL_HPP
|
||||
|
||||
#include <eepp/core/observablevector.hpp>
|
||||
#include <eepp/ui/models/model.hpp>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <type_traits>
|
||||
|
||||
namespace EE { namespace UI { namespace Models {
|
||||
|
||||
/**
|
||||
* @brief One-column model adapter for an ObservableVector.
|
||||
*
|
||||
* Unfiltered sources preserve incremental model notifications. A filtered projection rebuilds its
|
||||
* row mapping when source membership can change. Custom formatters allow domain types to remain
|
||||
* independent from Variant. The model retains the source storage, so it remains safe when a view
|
||||
* outlives the ObservableVector wrapper. Mutations naturally stop when that wrapper is destroyed.
|
||||
*
|
||||
* Filtering changes model row numbers. Use sourceRow() or at() before mutating the source from a
|
||||
* selection in the filtered view.
|
||||
*
|
||||
* @code
|
||||
* ObservableVector<Person> people;
|
||||
* auto model = ObservableListModel<Person>::create(
|
||||
* people, []( const Person& person, ModelRole role ) {
|
||||
* return role == ModelRole::Display ? Variant( person.name ) : Variant{};
|
||||
* } );
|
||||
* model->setFilter( []( const Person& person ) { return person.active; } );
|
||||
* @endcode
|
||||
*/
|
||||
template <typename T> class ObservableListModel final : public Model {
|
||||
public:
|
||||
/** @brief Converts one source item and role into model data. */
|
||||
using Formatter = std::function<Variant( const T&, ModelRole )>;
|
||||
|
||||
/** @brief Returns true when a source item belongs in the visible projection. */
|
||||
using Predicate = std::function<bool( const T& )>;
|
||||
|
||||
/** @brief Creates a one-column adapter using Variant's standard conversion for Display data. */
|
||||
static std::shared_ptr<ObservableListModel> create( ObservableVector<T>& source ) {
|
||||
return std::make_shared<ObservableListModel>( source );
|
||||
}
|
||||
|
||||
/** @brief Creates a one-column adapter whose data() is supplied by @p formatter. */
|
||||
static std::shared_ptr<ObservableListModel> create( ObservableVector<T>& source,
|
||||
Formatter formatter ) {
|
||||
return std::make_shared<ObservableListModel>( source, std::move( formatter ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Creates an adapter over @p source, optionally using @p formatter for all roles.
|
||||
*
|
||||
* The source storage is retained for the model's lifetime.
|
||||
*/
|
||||
explicit ObservableListModel( ObservableVector<T>& source, Formatter formatter = {} ) :
|
||||
mSource( source.sharedHandle() ), mFormatter( std::move( formatter ) ) {
|
||||
mConnection = mSource.observe(
|
||||
[this]( const typename ObservableVector<T>::Change& change ) { onChange( change ); } );
|
||||
}
|
||||
~ObservableListModel() { mConnection.disconnect(); }
|
||||
|
||||
/** @return The number of visible rows in the current projection. */
|
||||
size_t rowCount( const ModelIndex& = ModelIndex() ) const {
|
||||
return mPredicate ? mRows.size() : mSource.size();
|
||||
}
|
||||
|
||||
/** @return One; ObservableListModel is a flat, one-column model. */
|
||||
size_t columnCount( const ModelIndex& = ModelIndex() ) const { return 1; }
|
||||
|
||||
/** @return A valid index for a visible row in column zero, or an invalid index. */
|
||||
ModelIndex index( int row, int column = 0, const ModelIndex& parent = ModelIndex() ) const {
|
||||
if ( row < 0 || column != 0 || static_cast<std::size_t>( row ) >= rowCount( parent ) )
|
||||
return {};
|
||||
return Model::index( row, column, parent );
|
||||
}
|
||||
|
||||
/** @return Formatted data for @p index and @p role, or an empty Variant when unavailable. */
|
||||
Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const {
|
||||
const T* value = at( index );
|
||||
if ( !value )
|
||||
return {};
|
||||
if ( mFormatter )
|
||||
return mFormatter( *value, role );
|
||||
if ( role != ModelRole::Display )
|
||||
return {};
|
||||
if constexpr ( std::is_constructible_v<Variant, const T&> )
|
||||
return Variant( *value );
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Replaces the visible filter and invalidates all model indexes.
|
||||
*
|
||||
* An empty predicate is equivalent to no filter. While filtered, source mutations rebuild the
|
||||
* projection and invalidate indexes instead of emitting incremental row notifications.
|
||||
*/
|
||||
void setFilter( Predicate predicate ) {
|
||||
mPredicate = std::move( predicate );
|
||||
rebuildRows();
|
||||
invalidate( InvalidateAllIndexes );
|
||||
}
|
||||
|
||||
/** @brief Removes the active filter and exposes all source rows. */
|
||||
void clearFilter() {
|
||||
if ( !mPredicate )
|
||||
return;
|
||||
mPredicate = {};
|
||||
mRows.clear();
|
||||
invalidate( InvalidateAllIndexes );
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The ObservableVector row represented by @p index, or std::nullopt for an invalid or
|
||||
* foreign index.
|
||||
*/
|
||||
std::optional<std::size_t> sourceRow( const ModelIndex& index ) const {
|
||||
if ( !index.isValid() || index.model() != this || index.row() < 0 ||
|
||||
static_cast<std::size_t>( index.row() ) >= rowCount() )
|
||||
return {};
|
||||
return mPredicate ? mRows[index.row()] : static_cast<std::size_t>( index.row() );
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The source item represented by @p index, or nullptr for an invalid or foreign index.
|
||||
* @warning The pointer is invalidated by mutations that reallocate or remove vector elements.
|
||||
*/
|
||||
const T* at( const ModelIndex& index ) const {
|
||||
auto row = sourceRow( index );
|
||||
return row ? &mSource[*row] : nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
void rebuildRows() {
|
||||
mRows.clear();
|
||||
if ( !mPredicate )
|
||||
return;
|
||||
for ( std::size_t i = 0; i < mSource.size(); ++i )
|
||||
if ( mPredicate( mSource[i] ) )
|
||||
mRows.push_back( i );
|
||||
}
|
||||
void onChange( const typename ObservableVector<T>::Change& change ) {
|
||||
using ChangeType = typename ObservableVector<T>::ChangeType;
|
||||
using Phase = typename ObservableVector<T>::Phase;
|
||||
if ( mPredicate ) {
|
||||
if ( change.phase == Phase::After ) {
|
||||
rebuildRows();
|
||||
invalidate( InvalidateAllIndexes );
|
||||
}
|
||||
return;
|
||||
}
|
||||
const int first = static_cast<int>( change.index );
|
||||
const int last = static_cast<int>( change.index + change.count - 1 );
|
||||
if ( change.phase == Phase::Before ) {
|
||||
if ( change.type == ChangeType::Insert )
|
||||
beginInsertRows( {}, first, last );
|
||||
else if ( change.type == ChangeType::Remove )
|
||||
beginDeleteRows( {}, first, last );
|
||||
else if ( change.type == ChangeType::Move )
|
||||
beginMoveRows( {}, first, last, {}, static_cast<int>( change.target ) );
|
||||
} else {
|
||||
if ( change.type == ChangeType::Insert )
|
||||
endInsertRows();
|
||||
else if ( change.type == ChangeType::Remove )
|
||||
endDeleteRows();
|
||||
else if ( change.type == ChangeType::Move )
|
||||
endMoveRows();
|
||||
else if ( change.type == ChangeType::Change )
|
||||
invalidate( DontInvalidateIndexes );
|
||||
else if ( change.type == ChangeType::Reset )
|
||||
invalidate( InvalidateAllIndexes );
|
||||
}
|
||||
}
|
||||
typename ObservableVector<T>::SharedHandle mSource;
|
||||
Formatter mFormatter;
|
||||
Predicate mPredicate;
|
||||
std::vector<std::size_t> mRows;
|
||||
typename ObservableVector<T>::Connection mConnection;
|
||||
};
|
||||
|
||||
}}} // namespace EE::UI::Models
|
||||
|
||||
#endif
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <eepp/ui/base.hpp>
|
||||
#include <eepp/ui/doc/textdocument.hpp>
|
||||
#include <eepp/ui/uicodeeditor.hpp>
|
||||
#include <eepp/ui/uidatabind.hpp>
|
||||
#include <eepp/ui/databinding/uidatabind.hpp>
|
||||
#include <eepp/ui/uilinearlayout.hpp>
|
||||
#include <eepp/ui/uiselectbutton.hpp>
|
||||
#include <eepp/ui/uitextinput.hpp>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#include <eepp/ui/uisplitter.hpp>
|
||||
#include <eepp/ui/uitabwidget.hpp>
|
||||
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
#include <eepp/thirdparty/nlohmann/json_fwd.hpp>
|
||||
|
||||
namespace EE { namespace Graphics {
|
||||
class Drawable;
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
#include <eepp/ui/uidatabind.hpp>
|
||||
#include <type_traits>
|
||||
|
||||
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. A custom UIValueConverter can provide presentation-specific parsing and formatting.
|
||||
*
|
||||
* @code
|
||||
* UIProperty<double> celsius( 0.0, celsiusInput );
|
||||
* UIProperty<double> fahrenheit( 32.0, fahrenheitInput );
|
||||
* celsius.changed( [&fahrenheit]( double value ) {
|
||||
* fahrenheit = value * 9.0 / 5.0 + 32.0;
|
||||
* } );
|
||||
* @endcode
|
||||
*/
|
||||
template <typename T> class UIProperty {
|
||||
public:
|
||||
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<T>::Converter& converter =
|
||||
EE::UI::UIDataBind<T>::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) :
|
||||
mValue( std::move( defaultValue ) ),
|
||||
mBindedData( &mValue, widget, converter, valueKey, eventType ) {}
|
||||
|
||||
UIProperty( T defaultValue, const UnorderedSet<UIWidget*>& widgets = {},
|
||||
const typename EE::UI::UIDataBind<T>::Converter& converter =
|
||||
EE::UI::UIDataBind<T>::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) :
|
||||
mValue( std::move( defaultValue ) ),
|
||||
mBindedData( &mValue, widgets, converter, valueKey, eventType ) {}
|
||||
|
||||
UIProperty( const UnorderedSet<UIWidget*>& widgets = {},
|
||||
const typename EE::UI::UIDataBind<T>::Converter& converter =
|
||||
EE::UI::UIDataBind<T>::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) :
|
||||
mBindedData( &mValue, widgets, converter, valueKey, eventType ) {}
|
||||
|
||||
UIProperty( UIWidget* widget,
|
||||
const typename EE::UI::UIDataBind<T>::Converter& converter =
|
||||
EE::UI::UIDataBind<T>::converterDefault(),
|
||||
const std::string& valueKey = "value",
|
||||
const Event::EventType& eventType = Event::OnValueChange ) :
|
||||
mBindedData( &mValue, widget, converter, valueKey, eventType ) {}
|
||||
|
||||
UIProperty& operator=( const T& newVal ) {
|
||||
mBindedData.set( newVal );
|
||||
return *this;
|
||||
}
|
||||
|
||||
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 <typename U = T,
|
||||
std::enable_if_t<(std::is_arithmetic_v<U> && !std::is_same_v<U, bool>) ||
|
||||
std::is_same_v<U, std::string> || std::is_same_v<U, String>,
|
||||
int> = 0>
|
||||
UIProperty& operator+=( const T& operand ) {
|
||||
return *this = value() + operand;
|
||||
}
|
||||
|
||||
template <
|
||||
typename U = T,
|
||||
std::enable_if_t<std::is_same_v<U, std::string> || std::is_same_v<U, String>, int> = 0>
|
||||
T operator+( const T& operand ) const {
|
||||
return value() + operand;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
UIProperty& operator-=( const T& operand ) {
|
||||
return *this = value() - operand;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
UIProperty& operator*=( const T& operand ) {
|
||||
return *this = value() * operand;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
UIProperty& operator/=( const T& operand ) {
|
||||
return *this = value() / operand;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
UIProperty& operator++() {
|
||||
return *this += 1;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
T operator++( int ) {
|
||||
T previous = value();
|
||||
++( *this );
|
||||
return previous;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
UIProperty& operator--() {
|
||||
return *this -= 1;
|
||||
}
|
||||
|
||||
template <typename U = T,
|
||||
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
|
||||
T operator--( int ) {
|
||||
T previous = value();
|
||||
--( *this );
|
||||
return previous;
|
||||
}
|
||||
/** @} */
|
||||
|
||||
const T& value() const { return mBindedData.get(); }
|
||||
|
||||
const UIDataBind<T>& databind() const { return mBindedData; }
|
||||
UIDataBind<T>& databind() { return mBindedData; }
|
||||
|
||||
/** @return Current converter error state. */
|
||||
const UIValueValidationState& validationState() const { return mBindedData.validationState(); }
|
||||
|
||||
/** @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;
|
||||
}
|
||||
|
||||
const T& operator*() const noexcept { return value(); }
|
||||
|
||||
const T* operator->() const noexcept { return &value(); }
|
||||
|
||||
operator const T&() const noexcept { return value(); }
|
||||
|
||||
/** @brief Sets the callback invoked after the synchronized value changes. */
|
||||
UIProperty& changed( const std::function<void( const T& newVal )>& fn ) {
|
||||
mBindedData.onValueChangeCb = fn;
|
||||
return *this;
|
||||
}
|
||||
|
||||
UIProperty& changed( std::function<void( const T& newVal )>&& fn ) {
|
||||
mBindedData.onValueChangeCb = std::move( fn );
|
||||
return *this;
|
||||
}
|
||||
|
||||
protected:
|
||||
T mValue{};
|
||||
UIDataBind<T> mBindedData;
|
||||
};
|
||||
|
||||
}} // namespace EE::UI
|
||||
@@ -1,148 +0,0 @@
|
||||
#ifndef EE_UI_UIVALUEBINDING_HPP
|
||||
#define EE_UI_UIVALUEBINDING_HPP
|
||||
|
||||
#include <eepp/core/observablevalue.hpp>
|
||||
#include <eepp/ui/uivalueconverter.hpp>
|
||||
#include <eepp/ui/uiwidget.hpp>
|
||||
|
||||
namespace EE { namespace UI {
|
||||
|
||||
/**
|
||||
* @brief Move-only two-way binding between an ObservableValue and a UIWidget property.
|
||||
*
|
||||
* The converter maps directly between T and the widget property string. Its toValue() callback
|
||||
* decides whether widget input may enter the model. Model-originated values are authoritative and
|
||||
* are formatted through fromValue().
|
||||
*
|
||||
* 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<std::string> userName{ "Ada" };
|
||||
* auto binding = bindValue( userName, textInput,
|
||||
* UIValueConverter<std::string>::converterString(),
|
||||
* "text", Event::OnTextChanged );
|
||||
* userName = "Grace"; // Updates textInput without coupling the model to UIWidget.
|
||||
* @endcode
|
||||
*/
|
||||
template <typename T> class UIValueBinding {
|
||||
public:
|
||||
using Converter = UIValueConverter<T>;
|
||||
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<T>& 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; }
|
||||
bool isValid() const { return !mState || mState->validation.isValid(); }
|
||||
|
||||
/** @return Observable conversion and input-validation state. */
|
||||
UIValueValidationState* validationState() { return mState ? &mState->validation : nullptr; }
|
||||
const UIValueValidationState* validationState() const {
|
||||
return mState ? &mState->validation : nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
struct State {
|
||||
typename ObservableValue<T>::WeakHandle value;
|
||||
UIWidget* widget{ nullptr };
|
||||
const PropertyDefinition* property{ nullptr };
|
||||
Converter converter;
|
||||
UIValueValidationState validation;
|
||||
bool synchronizing{ false };
|
||||
typename ObservableValue<T>::Connection valueConnection;
|
||||
EventConnectionList widgetConnections;
|
||||
|
||||
bool applyToWidget( const T& newValue ) {
|
||||
if ( !widget )
|
||||
return false;
|
||||
auto converted = converter.fromValue( property, newValue );
|
||||
if ( !converted ) {
|
||||
validation.set( std::move( converted.validation ) );
|
||||
return false;
|
||||
}
|
||||
synchronizing = true;
|
||||
widget->applyProperty( StyleSheetProperty( property, *converted.value ) );
|
||||
synchronizing = false;
|
||||
validation.clear();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
void connect( ObservableValue<T>& value, UIWidget* widget, const Converter& converter,
|
||||
const std::string& propertyName, Event::EventType eventType ) {
|
||||
eeASSERT( widget != nullptr );
|
||||
auto state = std::make_shared<State>();
|
||||
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<State> 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 ) {
|
||||
auto proposed = state->converter.toValue(
|
||||
state->property,
|
||||
event->getNode()->asType<UIWidget>()->getPropertyString( state->property ) );
|
||||
if ( !proposed ) {
|
||||
state->validation.set( std::move( proposed.validation ) );
|
||||
return;
|
||||
}
|
||||
state->validation.clear();
|
||||
if ( !state->value.set( std::move( *proposed.value ) ) ) {
|
||||
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->validation.clear();
|
||||
state->widgetConnections.clear();
|
||||
}
|
||||
} );
|
||||
state->applyToWidget( value.get() );
|
||||
mState = std::move( state );
|
||||
}
|
||||
|
||||
std::shared_ptr<State> mState;
|
||||
};
|
||||
|
||||
/** @brief Creates a scoped two-way binding between @p value and @p widget. */
|
||||
template <typename T>
|
||||
UIValueBinding<T>
|
||||
bindValue( ObservableValue<T>& value, UIWidget* widget,
|
||||
const UIValueConverter<T>& converter = UIValueConverter<T>::converterDefault(),
|
||||
const std::string& propertyName = "value",
|
||||
Event::EventType eventType = Event::OnValueChange ) {
|
||||
return UIValueBinding<T>( value, widget, converter, propertyName, eventType );
|
||||
}
|
||||
|
||||
}} // namespace EE::UI
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user