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:
Martín Lucas Golini
2026-08-22 01:00:04 -03:00
parent b4e8f0312c
commit 36e54fe2a3
42 changed files with 3818 additions and 1243 deletions
+154
View File
@@ -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
+4
View File
@@ -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>
+133 -20
View File
@@ -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;
};
+284
View File
@@ -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
+1
View File
@@ -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>
+1
View File
@@ -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>
+3
View File
@@ -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
View File
@@ -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
+304
View File
@@ -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 = &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
+367
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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;
-185
View File
@@ -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
-148
View File
@@ -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