ui: add typed value conversion errors

Replace boolean output-parameter converters with UIValueResult so conversion failures can provide numeric codes and optional diagnostics without exposing partially converted values.
Expose observable converter error state from UIDataBind, UIProperty, and UIValueBinding.
Reject invalid widget input without changing the model or propagating it to sibling widgets, while keeping programmatic model updates authoritative.
Migrate ecode's custom output parser converter, expand binding tests, and document the data-handling architecture and next form-validation
phase.
This commit is contained in:
Martín Lucas Golini
2026-08-09 00:52:00 -03:00
parent 01d5614a71
commit b6cdc83818
11 changed files with 1227 additions and 680 deletions
+1
View File
@@ -174,6 +174,7 @@
#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>
+103 -57
View File
@@ -3,7 +3,6 @@
#include <eepp/core/containers.hpp>
#include <eepp/core/debug.hpp>
#include <eepp/system/log.hpp>
#include <eepp/ui/uivalueconverter.hpp>
#include <eepp/ui/uiwidget.hpp>
#include <memory>
@@ -18,6 +17,10 @@ namespace EE { namespace UI {
* object supplied at construction. Calling set() updates that object and propagates the converted
* value to every bound widget.
*
* The converter maps directly between T and the widget property string. Its toValue() callback
* 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.
@@ -50,7 +53,7 @@ template <typename T> class UIDataBind {
static std::unique_ptr<UIDataBind<T>>
New( T* t, const UnorderedSet<UIWidget*>& widgets,
const Converter& converter = UIDataBind<T>::converterDefault(),
const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
return std::unique_ptr<UIDataBind<T>>(
@@ -58,7 +61,7 @@ template <typename T> class UIDataBind {
}
static std::unique_ptr<UIDataBind<T>>
New( T* t, UIWidget* widget, const Converter& converter = UIDataBind<T>::converterDefault(),
New( T* t, UIWidget* widget, const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
return std::unique_ptr<UIDataBind<T>>(
@@ -72,21 +75,20 @@ template <typename T> class UIDataBind {
UIDataBind& operator=( UIDataBind&& ) = delete;
UIDataBind( T* t, const UnorderedSet<UIWidget*>& widgets,
const Converter& converter = UIDataBind<T>::converterDefault(),
const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
init( t, widgets, converter, valueKey, eventType );
}
UIDataBind( T* t, UIWidget* widget,
const Converter& converter = UIDataBind<T>::converterDefault(),
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 );
}
void init( T* t, const UnorderedSet<UIWidget*>& widgets,
const Converter& converter = UIDataBind<T>::converterDefault(),
const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
eeASSERT( t != nullptr );
@@ -104,29 +106,11 @@ template <typename T> class UIDataBind {
dataInitialized = true;
}
void set( const T& t ) {
eeASSERT( isInitialized() );
if ( dataInitialized && t == *data )
return;
inSetValue = true;
*data = t;
setValueChange();
inSetValue = false;
if ( onValueChangeCb )
onValueChangeCb( t );
}
/** Propagates the authoritative model value and reports formatting failures. */
UIValueValidationResult set( const T& t ) { return setData( t ); }
void set( T&& t ) {
eeASSERT( isInitialized() );
if ( dataInitialized && t == *data )
return;
inSetValue = true;
*data = std::move( t );
setValueChange();
inSetValue = false;
if ( onValueChangeCb )
onValueChangeCb( *data );
}
/** Propagates the authoritative model value and reports formatting failures. */
UIValueValidationResult set( T&& t ) { return setData( std::move( t ) ); }
const T& get() const {
eeASSERT( isInitialized() );
@@ -147,6 +131,8 @@ template <typename T> class UIDataBind {
connections.clear();
widgets.clear();
converter = Converter();
validation.clear();
validationEmitter = nullptr;
inSetValue = false;
dataInitialized = false;
property = nullptr;
@@ -161,9 +147,14 @@ template <typename T> class UIDataBind {
return;
bindListeners( widget );
widgets.insert( widget );
inSetValue = true;
widget->applyProperty( StyleSheetProperty( property, dataToString() ) );
inSetValue = false;
std::string string;
auto result = dataToString( string );
if ( result ) {
inSetValue = true;
widget->applyProperty( StyleSheetProperty( property, string ) );
inSetValue = false;
}
setValidationResult( std::move( result ) );
}
/** @brief Disconnects and removes @p widget from the synchronized widget set. */
@@ -172,9 +163,18 @@ template <typename T> class UIDataBind {
return;
connections.erase( widget );
widgets.erase( widget );
if ( validationEmitter == widget ) {
validationEmitter = nullptr;
validation.clear();
}
}
~UIDataBind() { reset(); }
~UIDataBind() {
// Do not publish a final "valid" transition while the binding itself is being destroyed.
// Validation connections expire safely with validation after widget listeners are removed.
connections.clear();
widgets.clear();
}
const PropertyDefinition* getPropertyDefinition() const { return property; }
@@ -182,7 +182,31 @@ template <typename T> class UIDataBind {
const UnorderedSet<UIWidget*>& getWidgets() const { return widgets; }
/** @return Observable converter error state for this binding. */
UIValueValidationState& validationState() { return validation; }
const UIValueValidationState& validationState() const { return validation; }
bool isValid() const { return validation.isValid(); }
protected:
template <typename U> UIValueValidationResult setData( U&& t ) {
eeASSERT( isInitialized() );
if ( dataInitialized && t == *data ) {
inSetValue = true;
auto result = setValueChange();
inSetValue = false;
setValidationResult( result );
return result;
}
inSetValue = true;
*data = std::forward<U>( t );
auto result = setValueChange();
inSetValue = false;
if ( onValueChangeCb )
onValueChangeCb( *data );
setValidationResult( result );
return result;
}
T* data{ nullptr };
UnorderedSet<UIWidget*> widgets;
UnorderedMap<UIWidget*, EventConnectionList> connections;
@@ -190,6 +214,8 @@ template <typename T> class UIDataBind {
bool dataInitialized{ false };
const PropertyDefinition* property{ nullptr };
Converter converter;
UIValueValidationState validation;
UIWidget* validationEmitter{ nullptr };
Event::EventType eventType{ Event::OnValueChange };
void bindListeners( UIWidget* widget ) {
@@ -201,17 +227,25 @@ template <typename T> class UIDataBind {
auto widget = event->getNode()->asType<UIWidget>();
connections.erase( widget );
widgets.erase( widget );
if ( validationEmitter == widget ) {
validationEmitter = nullptr;
validation.clear();
}
} );
}
std::string dataToString() const {
UIValueValidationResult dataToString( std::string& string ) const {
eeASSERT( isInitialized() );
std::string str;
if ( !converter.fromValue( property, str, *data ) ) {
Log::error( "UIDataBind::dataToString converter::fromValue: unable to convert value "
"to string." );
}
return str;
auto converted = converter.fromValue( property, *data );
if ( !converted )
return converted.validation;
string = std::move( *converted.value );
return UIValueValidationResult::success();
}
void setValidationResult( UIValueValidationResult result, UIWidget* emitter = nullptr ) {
validationEmitter = result ? nullptr : emitter;
validation.set( std::move( result ) );
}
void processValueChange( UIWidget* emitter ) {
@@ -219,28 +253,40 @@ template <typename T> class UIDataBind {
eeASSERT( emitter != nullptr );
if ( inSetValue )
return;
bool success = false;
T val;
success = converter.toValue( property, val, emitter->getPropertyString( property ) );
if ( success ) {
*data = val;
StyleSheetProperty prop( property, dataToString(), 0, false );
inSetValue = true;
for ( auto widget : widgets ) {
if ( widget != emitter )
widget->applyProperty( prop );
}
inSetValue = false;
if ( onValueChangeCb )
onValueChangeCb( val );
auto proposed = converter.toValue( property, emitter->getPropertyString( property ) );
if ( !proposed ) {
setValidationResult( std::move( proposed.validation ), emitter );
return;
}
auto canonicalString = converter.fromValue( property, *proposed.value );
if ( !canonicalString ) {
setValidationResult( std::move( canonicalString.validation ), emitter );
return;
}
*data = std::move( *proposed.value );
StyleSheetProperty prop( property, *canonicalString.value, 0, false );
inSetValue = true;
for ( auto widget : widgets ) {
if ( widget != emitter )
widget->applyProperty( prop );
}
inSetValue = false;
validationEmitter = nullptr;
validation.clear();
if ( onValueChangeCb )
onValueChangeCb( *data );
}
void setValueChange() {
StyleSheetProperty prop( property, dataToString(), 0, false );
UIValueValidationResult setValueChange() {
std::string string;
auto result = dataToString( string );
if ( !result )
return result;
StyleSheetProperty prop( property, string, 0, false );
for ( auto widget : widgets )
widget->applyProperty( prop );
return result;
}
};
+5 -1
View File
@@ -16,7 +16,7 @@ namespace EE { namespace UI {
*
* 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.
* directly. A custom UIValueConverter can provide presentation-specific parsing and formatting.
*
* @code
* UIProperty<double> celsius( 0.0, celsiusInput );
@@ -143,6 +143,10 @@ template <typename T> class UIProperty {
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 ) {
+34 -19
View File
@@ -2,7 +2,6 @@
#define EE_UI_UIVALUEBINDING_HPP
#include <eepp/core/observablevalue.hpp>
#include <eepp/system/log.hpp>
#include <eepp/ui/uivalueconverter.hpp>
#include <eepp/ui/uiwidget.hpp>
@@ -11,10 +10,12 @@ namespace EE { namespace UI {
/**
* @brief Move-only two-way binding between an ObservableValue and a UIWidget property.
*
* The binding synchronizes the observable's current value into the widget immediately. Later
* observable changes update the widget, and the selected widget event converts the property back
* into the observable. Destroying the binding disconnects both directions. Destroying either the
* observable or widget first is safe and does not keep that endpoint alive.
* 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.
@@ -50,6 +51,13 @@ template <typename T> class UIValueBinding {
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 {
@@ -57,6 +65,7 @@ template <typename T> class UIValueBinding {
UIWidget* widget{ nullptr };
const PropertyDefinition* property{ nullptr };
Converter converter;
UIValueValidationState validation;
bool synchronizing{ false };
typename ObservableValue<T>::Connection valueConnection;
EventConnectionList widgetConnections;
@@ -64,14 +73,15 @@ template <typename T> class UIValueBinding {
bool applyToWidget( const T& newValue ) {
if ( !widget )
return false;
std::string string;
if ( !converter.fromValue( property, string, newValue ) ) {
Log::error( "UIValueBinding: unable to convert observable value to string." );
auto converted = converter.fromValue( property, newValue );
if ( !converted ) {
validation.set( std::move( converted.validation ) );
return false;
}
synchronizing = true;
widget->applyProperty( StyleSheetProperty( property, string ) );
widget->applyProperty( StyleSheetProperty( property, *converted.value ) );
synchronizing = false;
validation.clear();
return true;
}
};
@@ -94,12 +104,15 @@ template <typename T> class UIValueBinding {
} );
state->widgetConnections += widget->connect( eventType, [weakState]( const Event* event ) {
if ( auto state = weakState.lock(); state && !state->synchronizing ) {
T newValue;
if ( state->converter.toValue(
state->property, newValue,
event->getNode()->asType<UIWidget>()->getPropertyString(
state->property ) ) &&
!state->value.set( std::move( newValue ) ) ) {
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();
}
@@ -109,6 +122,7 @@ template <typename T> class UIValueBinding {
if ( auto state = weakState.lock() ) {
state->widget = nullptr;
state->valueConnection.disconnect();
state->validation.clear();
state->widgetConnections.clear();
}
} );
@@ -121,10 +135,11 @@ template <typename T> class UIValueBinding {
/** @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 typename UIValueBinding<T>::Converter& converter = UIValueBinding<T>::converterDefault(),
const std::string& propertyName = "value", Event::EventType eventType = Event::OnValueChange ) {
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 );
}
+33 -29
View File
@@ -3,9 +3,9 @@
#include <eepp/core/string.hpp>
#include <eepp/ui/css/stylesheetproperty.hpp>
#include <eepp/ui/uivaluevalidation.hpp>
#include <functional>
#include <string>
#include <string_view>
#include <type_traits>
#include <utility>
@@ -20,18 +20,21 @@ namespace EE { namespace UI {
*
* @code
* auto converter = UIValueConverter<MyEnum>(
* []( const CSS::PropertyDefinition*, MyEnum& value, const std::string& text ) {
* return enumFromString( value, text );
* []( const CSS::PropertyDefinition*, const std::string& text ) {
* MyEnum value;
* return enumFromString( value, text ) ? UIValueResult<MyEnum>::success( value )
* : UIValueResult<MyEnum>::error( 1 );
* },
* []( const CSS::PropertyDefinition*, std::string& text, const MyEnum& value ) {
* text = enumToString( value );
* return true;
* []( const CSS::PropertyDefinition*, const MyEnum& value ) {
* return UIValueResult<std::string>::success( enumToString( value ) );
* } );
* @endcode
*/
template <typename T> struct UIValueConverter {
using ToValue = std::function<bool( const CSS::PropertyDefinition*, T&, const std::string& )>;
using FromValue = std::function<bool( const CSS::PropertyDefinition*, std::string&, const T& )>;
using ToValue =
std::function<UIValueResult<T>( const CSS::PropertyDefinition*, const std::string& )>;
using FromValue =
std::function<UIValueResult<std::string>( const CSS::PropertyDefinition*, const T& )>;
UIValueConverter() = default;
UIValueConverter( ToValue toValue, FromValue fromValue ) :
@@ -42,18 +45,22 @@ template <typename T> struct UIValueConverter {
static UIValueConverter converterDefault() {
return UIValueConverter(
[]( const CSS::PropertyDefinition* property, T& value, const std::string& string ) {
[]( const CSS::PropertyDefinition* property, const std::string& string ) {
if constexpr ( std::is_same_v<T, std::string> || std::is_same_v<T, String> ) {
value = T( string );
return true;
return UIValueResult<T>::success( T( string ) );
} else if constexpr ( std::is_same_v<T, bool> ) {
value = CSS::StyleSheetProperty( property, string ).asBool();
return true;
return UIValueResult<T>::success(
CSS::StyleSheetProperty( property, string ).asBool() );
} else {
return String::fromString( value, string );
T value;
return String::fromString( value, string )
? UIValueResult<T>::success( std::move( value ) )
: UIValueResult<T>::error( static_cast<Uint32>(
UIValueValidationError::ConversionFailed ) );
}
},
[]( const CSS::PropertyDefinition*, std::string& string, const T& value ) {
[]( const CSS::PropertyDefinition*, const T& value ) {
std::string string;
if constexpr ( std::is_same_v<T, std::string> ) {
string = value;
} else if constexpr ( std::is_same_v<T, String> ) {
@@ -67,34 +74,31 @@ template <typename T> struct UIValueConverter {
} else {
string = String::toString( value );
}
return true;
return UIValueResult<std::string>::success( std::move( string ) );
} );
}
static UIValueConverter converterString() {
return UIValueConverter(
[]( const CSS::PropertyDefinition*, T& value, const std::string& string ) {
value = T( string );
return true;
[]( const CSS::PropertyDefinition*, const std::string& string ) {
return UIValueResult<T>::success( T( string ) );
},
[]( const CSS::PropertyDefinition*, std::string& string, const T& value ) {
[]( const CSS::PropertyDefinition*, const T& value ) {
if constexpr ( std::is_same_v<T, String> )
string = value.toUtf8();
return UIValueResult<std::string>::success( value.toUtf8() );
else
string = value;
return true;
return UIValueResult<std::string>::success( value );
} );
}
static UIValueConverter converterBool() {
return UIValueConverter(
[]( const CSS::PropertyDefinition* property, T& value, const std::string& string ) {
value = CSS::StyleSheetProperty( property, string ).asBool();
return true;
[]( const CSS::PropertyDefinition* property, const std::string& string ) {
return UIValueResult<T>::success(
CSS::StyleSheetProperty( property, string ).asBool() );
},
[]( const CSS::PropertyDefinition*, std::string& string, const T& value ) {
string = value ? "true" : "false";
return true;
[]( const CSS::PropertyDefinition*, const T& value ) {
return UIValueResult<std::string>::success( value ? "true" : "false" );
} );
}
};
+150
View File
@@ -0,0 +1,150 @@
#ifndef EE_UI_UIVALUEVALIDATION_HPP
#define EE_UI_UIVALUEVALIDATION_HPP
#include <eepp/config.hpp>
#include <eepp/core/observablevalue.hpp>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <utility>
namespace EE { namespace UI {
/** Numeric error codes reserved by eepp's built-in value converters. */
enum class UIValueValidationError : Uint32 {
ConversionFailed = 1,
};
/**
* @brief Machine-readable result of converting or accepting a widget value.
*
* A numeric code is the normal way to identify an error. Applications can map that code, together
* with the binding or form context, to localized UI text. debugMessage is optional diagnostic
* information for logs, tests, and inspection; it should not be shown to users implicitly.
*
* Codes are owned by the subsystem or application defining the validation rule. Apart from values
* in UIValueValidationError, eepp does not require codes to be globally unique or stable for
* serialization. Code 0 is valid: the optional itself represents the absence of a code.
*/
struct UIValueValidationResult {
using Code = Uint32;
bool valid{ true };
std::optional<Code> code;
std::optional<std::string> debugMessage;
UIValueValidationResult() = default;
static UIValueValidationResult success() { return {}; }
static UIValueValidationResult error( Code code ) {
UIValueValidationResult result;
result.valid = false;
result.code = code;
return result;
}
static UIValueValidationResult error( Code code, std::string debugMessage ) {
UIValueValidationResult result = error( code );
result.debugMessage = std::move( debugMessage );
return result;
}
static UIValueValidationResult error( std::string debugMessage ) {
UIValueValidationResult result;
result.valid = false;
result.debugMessage = std::move( debugMessage );
return result;
}
explicit operator bool() const { return valid; }
bool operator==( const UIValueValidationResult& other ) const {
return valid == other.valid && code == other.code && debugMessage == other.debugMessage;
}
bool operator!=( const UIValueValidationResult& other ) const { return !( *this == other ); }
};
/**
* @brief A converted value together with its failure information.
*
* Successful results always contain a value. Failures contain no value and preserve the numeric
* code and optional diagnostic produced by a converter.
*/
template <typename T> struct UIValueResult {
std::optional<T> value;
UIValueValidationResult validation;
UIValueResult() = default;
UIValueResult( T value ) : value( std::move( value ) ) {}
static UIValueResult success( T value ) { return UIValueResult( std::move( value ) ); }
static UIValueResult error( UIValueValidationResult validation ) {
UIValueResult result;
result.validation = std::move( validation );
return result;
}
static UIValueResult error( UIValueValidationResult::Code code ) {
return error( UIValueValidationResult::error( code ) );
}
static UIValueResult error( UIValueValidationResult::Code code, std::string debugMessage ) {
return error( UIValueValidationResult::error( code, std::move( debugMessage ) ) );
}
explicit operator bool() const { return validation.valid && value.has_value(); }
};
/**
* @brief Observable current validation result shared by UI value binding implementations.
*
* Reading validity never allocates. Observer storage is created lazily by observe(), keeping the
* ordinary unobserved binding path inexpensive. Notifications are synchronous and use the same
* snapshot semantics as ObservableValue.
*/
class UIValueValidationState {
public:
using Callback = std::function<void( const UIValueValidationResult& result )>;
using Connection = ObservableValue<UIValueValidationResult>::Connection;
UIValueValidationState() = default;
UIValueValidationState( const UIValueValidationState& ) = delete;
UIValueValidationState& operator=( const UIValueValidationState& ) = delete;
UIValueValidationState( UIValueValidationState&& ) = delete;
UIValueValidationState& operator=( UIValueValidationState&& ) = delete;
bool isValid() const { return mResult.valid; }
const UIValueValidationResult& result() const { return mResult; }
const std::optional<UIValueValidationResult::Code>& code() const { return mResult.code; }
const std::optional<std::string>& debugMessage() const { return mResult.debugMessage; }
/** Observes later result changes. The current result is available through result(). */
Connection observe( Callback callback ) {
if ( !mObservable )
mObservable = std::make_unique<ObservableValue<UIValueValidationResult>>( mResult );
return mObservable->observe( std::move( callback ) );
}
/** Updates the current result and notifies observers only when it actually changed. */
void set( UIValueValidationResult result ) {
if ( mResult == result )
return;
mResult = std::move( result );
if ( mObservable )
mObservable->set( mResult );
}
void clear() { set( UIValueValidationResult::success() ); }
private:
UIValueValidationResult mResult;
std::unique_ptr<ObservableValue<UIValueValidationResult>> mObservable;
};
}} // namespace EE::UI
#endif