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
+66 -122
View File
@@ -4,54 +4,10 @@ struct Person {
std::uint64_t id;
std::string name;
std::string surname;
bool operator==( const Person& other ) const = default;
};
class PeopleModel : public Model {
public:
PeopleModel( const std::vector<Person>& people, std::string filterStr = "" ) : mData( people ) {
filter( String::toLower( filterStr ) );
}
size_t rowCount( const ModelIndex& ) const override { return mData.size(); }
size_t columnCount( const ModelIndex& ) const override { return 1; }
std::string columnName( const size_t& ) const override { return ""; }
ModelIndex index( int row, int column,
const ModelIndex& parent = ModelIndex() ) const override {
if ( row >= (int)rowCount( parent ) || column >= (int)columnCount( parent ) )
return {};
return Model::index( row, column, parent );
}
Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const override {
if ( role == ModelRole::Display )
return Variant(
String::format( "%s, %s", getPerson( index ).surname, getPerson( index ).name ) );
return {};
}
void filter( const std::string& filterStr ) {
if ( filterStr.empty() )
return;
std::vector<Person> data;
for ( auto& people : mData )
if ( String::startsWith( String::toLower( people.surname ), filterStr ) )
data.emplace_back( std::move( people ) );
mData = std::move( data );
invalidate( Model::UpdateFlag::DontInvalidateIndexes );
}
void setPeople( const std::vector<Person>& people ) {
mData = people;
invalidate( Model::UpdateFlag::DontInvalidateIndexes );
}
const Person& getPerson( const ModelIndex& index ) const { return mData[index.row()]; }
protected:
std::vector<Person> mData;
};
// Reference https://eugenkiss.github.io/7guis/tasks#crud
// Reference https://eugenkiss.github.io/7guis/tasks/#crud
EE_MAIN_FUNC int main( int, char** ) {
UIApplication app( { 640, 480, "eepp - 7GUIs - CRUD" } );
UIWidget* vbox = app.getUI()->loadLayoutFromString( R"xml(
@@ -80,96 +36,84 @@ EE_MAIN_FUNC int main( int, char** ) {
</hbox>
</vbox>
)xml" );
std::vector<Person> people{
auto listView = vbox->find<UIListView>( "list" );
auto filterInput = vbox->find<UITextInput>( "filter" );
auto nameInput = vbox->find<UITextInput>( "name" );
auto surnameInput = vbox->find<UITextInput>( "surname" );
auto createButton = vbox->find<UIPushButton>( "create" );
auto updateButton = vbox->find<UIPushButton>( "update" );
auto deleteButton = vbox->find<UIPushButton>( "delete" );
ObservableVector<Person> people( {
{ 1, "Hans", "Emil" },
{ 2, "Max", "Mustermann" },
{ 3, "Roman", "Tisch" },
};
std::uint64_t nextId = people.back().id + 1;
auto listView = vbox->find<UIListView>( "list" );
auto filterView = vbox->find<UITextInput>( "filter" );
auto nameView = vbox->find<UITextInput>( "name" );
auto surnameView = vbox->find<UITextInput>( "surname" );
auto createBut = vbox->find<UIPushButton>( "create" );
auto updateBut = vbox->find<UIPushButton>( "update" );
auto deleteBut = vbox->find<UIPushButton>( "delete" );
auto model = std::make_shared<PeopleModel>( people );
} );
std::uint64_t nextId = people[people.size() - 1].id + 1;
auto model =
ObservableListModel<Person>::create( people, []( const Person& person, ModelRole role ) {
return role == ModelRole::Display
? Variant( String::format( "%s, %s", person.surname, person.name ) )
: Variant{};
} );
listView->setModel( model );
const auto updateModel = [&people, filterView, &model]( bool updatePeople, bool updateFilter ) {
if ( updatePeople || updateFilter )
model->setPeople( people );
if ( updateFilter )
model->filter( filterView->getText().toUtf8() );
UIProperty<std::string> filter( filterInput );
UIProperty<std::string> name( nameInput );
UIProperty<std::string> surname( surnameInput );
const auto clearInputs = [&] {
name = "";
surname = "";
};
const auto updateButs = [createBut, updateBut, deleteBut, listView]() {
createBut->setEnabled( !listView->getSelection().first().isValid() );
updateBut->setEnabled( listView->getSelection().first().isValid() );
deleteBut->setEnabled( listView->getSelection().first().isValid() );
};
const auto& clearInputs = [nameView, surnameView]() {
nameView->setText( "" );
surnameView->setText( "" );
};
const auto updateSelection = [&updateButs, listView, nameView, surnameView, &clearInputs]() {
updateButs();
if ( listView->getSelection().first().isValid() ) {
auto selPerson = static_cast<PeopleModel*>( listView->getModel() )
->getPerson( listView->getSelection().first() );
nameView->setText( selPerson.name );
surnameView->setText( selPerson.surname );
ObservableValue<bool> hasSelection( false );
auto updateButtonEnabled = bindReadOnlyValue( hasSelection, updateButton, "enabled" );
auto deleteButtonEnabled = bindReadOnlyValue( hasSelection, deleteButton, "enabled" );
listView->on( Event::OnSelectionChanged, [&]( const Event* ) {
auto selected = listView->getSelection().first();
hasSelection = selected.isValid();
if ( const Person* person = model->at( selected ) ) {
name = person->name;
surname = person->surname;
} else {
clearInputs();
}
};
listView->on( Event::OnSelectionChanged, [&updateSelection]( auto ) { updateSelection(); } );
filterView->on( Event::OnTextChanged,
[&updateModel, listView, &clearInputs, &updateButs, &model]( auto ) {
updateModel( true, true );
updateButs();
clearInputs();
listView->getSelection().clear( model->rowCount( {} ) == 0 );
if ( model->rowCount( {} ) > 0 )
listView->setSelection( model->index( 0, 0 ) );
} );
createBut->onClick( [&]( auto ) {
if ( nameView->getText().empty() || surnameView->getText().empty() ) {
} );
auto filterConnection = filter.observe( [&]( const std::string& prefix ) {
if ( prefix.empty() ) {
model->clearFilter();
} else {
model->setFilter( [prefix]( const Person& person ) {
return String::istartsWith( person.surname, prefix );
} );
}
if ( model->rowCount() > 0 )
listView->setSelection( model->index( 0 ) );
} );
createButton->onClick( [&]( const MouseEvent* ) {
if ( name.get().empty() || surname.get().empty() ) {
UIMessageBox::New( UIMessageBox::OK, "Complete name and surname" )->showWhenReady();
return;
}
people.emplace_back(
Person{ nextId++, nameView->getText().toUtf8(), surnameView->getText().toUtf8() } );
people.pushBack( { nextId++, name.get(), surname.get() } );
clearInputs();
updateModel( true, false );
filterView->setText( "" );
filter = "";
} );
const auto getSelectedPersonIt = [&]() -> std::vector<Person>::iterator {
auto p = static_cast<PeopleModel*>( listView->getModel() )
->getPerson( listView->getSelection().first() );
return std::find_if( people.begin(), people.end(),
[&p]( const Person& person ) { return p.id == person.id; } );
};
updateBut->onClick( [&]( auto ) {
auto found = getSelectedPersonIt();
if ( found != people.end() ) {
found->name = nameView->getText().toUtf8();
found->surname = surnameView->getText().toUtf8();
clearInputs();
updateModel( true, true );
}
updateButton->onClick( [&]( const MouseEvent* ) {
auto row = model->sourceRow( listView->getSelection().first() );
if ( row )
people.set( *row, { people[*row].id, name.get(), surname.get() } );
clearInputs();
} );
deleteBut->onClick( [&]( auto ) {
if ( !listView->getSelection().first().isValid() ) {
UIMessageBox::New( UIMessageBox::OK, "Select a person from the list" )->showWhenReady();
return;
}
auto found = getSelectedPersonIt();
if ( found != people.end() ) {
people.erase( found );
clearInputs();
updateModel( true, false );
filterView->setText( "" );
}
deleteButton->onClick( [&]( const MouseEvent* ) {
auto row = model->sourceRow( listView->getSelection().first() );
if ( row )
people.erase( *row );
clearInputs();
filter = "";
} );
updateButs();
return app.run();
}
@@ -2,13 +2,16 @@
#include <iomanip>
#include <sstream>
// Reference https://eugenkiss.github.io/7guis/tasks#flight
// Reference https://eugenkiss.github.io/7guis/tasks/#flight
EE_MAIN_FUNC int main( int, char** ) {
UIApplication app( { 440, 240, "eepp - 7GUIs - Flight Booker" } );
UIWidget* vbox = app.getUI()->loadLayoutFromString( R"xml(
<style>
.error_input {
.error_input,
.error_input:hover,
.error_input:focus {
color: var(--theme-error);
border-color: var(--theme-error);
}
</style>
<vbox layout_width="match_parent" layout_height="match_parent" padding="8dp">
@@ -21,56 +24,68 @@ EE_MAIN_FUNC int main( int, char** ) {
<PushButton id="book" layout_width="match_parent" text="Book" />
</vbox>
)xml" );
auto ddlType = vbox->find<UIDropDownList>( "type" );
auto dateFrom = vbox->find<UITextInput>( "date_from" );
auto dateTo = vbox->find<UITextInput>( "date_to" );
auto bookBut = vbox->find<UIPushButton>( "book" );
const auto covertDate = []( const String& dateStr ) -> std::optional<std::time_t> {
if ( std::count( dateStr.begin(), dateStr.end(), '.' ) != 2 )
auto flightTypeInput = vbox->find<UIDropDownList>( "type" );
auto departureInput = vbox->find<UITextInput>( "date_from" );
auto returnInput = vbox->find<UITextInput>( "date_to" );
auto bookButton = vbox->find<UIPushButton>( "book" );
const auto parseDate = []( const std::string& text ) -> std::optional<std::time_t> {
if ( std::count( text.begin(), text.end(), '.' ) != 2 )
return {};
std::tm time = {};
std::istringstream ss( dateStr );
ss >> std::get_time( &time, "%d.%m.%Y" );
if ( ss.fail() )
return {};
return std::mktime( &time );
std::tm date = {};
std::istringstream stream( text );
stream >> std::get_time( &date, "%d.%m.%Y" );
return stream.fail() ? std::optional<std::time_t>{}
: std::optional<std::time_t>{ std::mktime( &date ) };
};
const auto getCurrentDate = []() {
std::time_t now = std::time( nullptr );
std::tm* ltm = std::localtime( &now );
std::stringstream ss;
ss << std::put_time( ltm, "%d.%m.%Y" );
return ss.str();
const auto formatDate = []( std::time_t value ) {
std::stringstream stream;
stream << std::put_time( std::localtime( &value ), "%d.%m.%Y" );
return stream.str();
};
const auto updateDateInput = [&]( UITextInput* input,
const std::optional<std::time_t>& date ) -> bool {
if ( !input->isEnabled() || date ) {
input->removeClass( "error_input" );
return true;
} else
input->addClass( "error_input" );
return false;
};
const auto update = [&]() {
std::optional<std::time_t> fromDate = covertDate( dateFrom->getText() );
std::optional<std::time_t> toDate = covertDate( dateTo->getText() );
dateTo->setEnabled( ddlType->getListBox()->getItemSelectedIndex() != 0 );
bookBut->setEnabled( updateDateInput( dateFrom, fromDate ) &&
updateDateInput( dateTo, toDate ) &&
( ddlType->getListBox()->getItemSelectedIndex() == 0 ||
( fromDate && toDate && *fromDate < *toDate ) ) );
};
ddlType->on( Event::OnItemSelected, [&update]( auto ) { update(); } );
dateFrom->setText( getCurrentDate() )->on( Event::OnValueChange, [&update]( auto ) {
update();
using Date = std::optional<std::time_t>;
auto dateConverter = UIValueConverter<Date>(
[&]( const CSS::PropertyDefinition*, const std::string& text ) -> UIValueResult<Date> {
auto value = parseDate( text );
return value ? UIValueResult<Date>( value )
: UIValueResult<Date>::error( 1, "date must use DD.MM.YYYY" );
},
[&]( const CSS::PropertyDefinition*, const Date& value ) -> UIValueResult<std::string> {
return value ? formatDate( *value ) : std::string{};
} );
std::time_t today = std::time( nullptr );
UIProperty<std::string> flightType( "one-way flight", flightTypeInput );
UIProperty<Date> departureDate( today, departureInput, dateConverter );
UIProperty<Date> returnDate( today, returnInput, dateConverter );
UIBindingGroup form;
form += flightType;
form += departureDate;
form += returnDate;
form.onChange( [&] {
for ( auto widget : form.widgets() )
widget->removeClass( "error_input" );
for ( const auto& error : form.errors() )
if ( error.widget )
error.widget->addClass( "error_input" );
} );
dateTo->on( Event::OnValueChange, [&update]( auto ) { update(); } );
bookBut->setFocus()->onClick( [&]( auto ) {
String msg( String::format( "You just booked a %s on %s",
ddlType->getListBox()->getItemSelectedText().toUtf8(),
dateFrom->getText().toUtf8() ) );
UIMessageBox::New( UIMessageBox::OK, msg )->showWhenReady();
auto isReturnFlight = computedValue(
flightType, []( const std::string& type ) { return type == "return flight"; } );
auto returnInputEnabled = bindValue( isReturnFlight, returnInput, "enabled" );
auto canBook = computedValue(
form.validValue(), isReturnFlight, departureDate, returnDate,
[]( bool valid, bool returnFlight, const Date& departure, const Date& returning ) {
return valid && departure &&
( !returnFlight || ( returning && *returning >= *departure ) );
} );
auto bookEnabled = bindValue( canBook, bookButton, "enabled" );
bookButton->setFocus()->onClick( [&]( const MouseEvent* ) {
String message( String::format( "You just booked a %s on %s", flightType.get(),
departureInput->getText().toUtf8() ) );
UIMessageBox::New( UIMessageBox::OK, message )->showWhenReady();
} );
update();
return app.run();
}
@@ -0,0 +1,50 @@
#include <eepp/ee.hpp>
using namespace EE;
using namespace EE::UI;
using namespace EE::UI::Models;
// ObservableVector is intended for live collections. The list model remains attached while row
// operations are forwarded incrementally, so unaffected selection and persistent indexes survive.
EE_MAIN_FUNC int main( int, char** ) {
UIApplication app( { 480, 360, "eepp - Observable Collection" } );
auto root = app.getUI()->loadLayoutFromString( R"xml(
<vbox layout-width="match_parent" layout-height="match_parent" padding="12dp">
<TextView text="Build queue" font-size="18dp" margin-bottom="8dp" />
<ListView id="tasks" layout-width="match_parent" layout-height="0dp" layout-weight="1" />
<hbox margin-top="8dp">
<TextInput id="task" layout-width="0dp" layout-weight="1" hint="New task" />
<PushButton id="add" text="Add" margin-left="8dp" />
<PushButton id="complete" text="Mark complete" margin-left="8dp" />
<PushButton id="remove" text="Remove" margin-left="8dp" />
</hbox>
</vbox>
)xml" );
if ( !app.getWindow()->isOpen() )
return EXIT_FAILURE;
auto list = root->find<UIListView>( "tasks" );
auto input = root->find<UITextInput>( "task" );
ObservableVector<std::string> tasks( { "Build eepp", "Run unit tests", "Package release" } );
list->setModel( ObservableListModel<std::string>::create( tasks ) );
root->find( "add" )->onClick( [&]( const MouseEvent* ) {
auto text = input->getText().toUtf8();
if ( !text.empty() ) {
tasks.pushBack( std::move( text ) );
input->setText( "" )->setFocus();
}
} );
root->find( "complete" )->onClick( [&]( const MouseEvent* ) {
auto selected = list->getSelection().first();
if ( selected.isValid() )
tasks.set( selected.row(), "✅ " + tasks[selected.row()] );
} );
root->find( "remove" )->onClick( [&]( const MouseEvent* ) {
auto selected = list->getSelection().first();
if ( selected.isValid() )
tasks.erase( selected.row() );
} );
return app.run();
}
@@ -0,0 +1,118 @@
#include <eepp/ee.hpp>
// A settings form is a useful fit for the data-handling helpers: several independently validated
// fields share dirty/reset/save behavior, and Save is reachable from both a button and a shortcut.
EE_MAIN_FUNC int main( int, char** ) {
UIApplication app( { 520, 410, "eepp - UI Data Handling" } );
auto root = app.getUI()->loadLayoutFromString( R"xml(
<style>
.field-error,
.field-error:hover,
.field-error:focus {
color: var(--theme-error);
border-color: var(--theme-error);
}
.validation-message { color: var(--theme-error); }
</style>
<vbox layout_width="match_parent" layout_height="match_parent" padding="12dp">
<TextView text="Deployment settings" font-size="18dp" margin-bottom="12dp" />
<hbox margin-bottom="8dp">
<TextView text="Project" min-width="90dp" layout-gravity="center_vertical" />
<TextInput id="project" layout-width="0dp" layout-weight="1" />
</hbox>
<hbox margin-bottom="8dp">
<TextView text="Host" min-width="90dp" layout-gravity="center_vertical" />
<TextInput id="host" layout-width="0dp" layout-weight="1" />
</hbox>
<hbox margin-bottom="12dp">
<TextView text="Port" min-width="90dp" layout-gravity="center_vertical" />
<TextInput id="port" layout-width="0dp" layout-weight="1" />
</hbox>
<CheckBox id="automatic" text="Deploy automatically" margin-bottom="12dp" />
<TextView id="validation-message" class="validation-message" min-height="20dp"
margin-bottom="8dp" />
<TextView id="summary" margin-bottom="12dp" />
<hbox>
<PushButton id="save" text="Save (Ctrl+S)" margin-right="8dp" />
<PushButton id="reset" text="Reset" />
<TextView id="status" margin-left="12dp" layout-gravity="center_vertical" />
</hbox>
</vbox>
)xml" );
if ( !app.getWindow()->isOpen() )
return EXIT_FAILURE;
auto projectInput = root->find<UITextInput>( "project" );
auto hostInput = root->find<UITextInput>( "host" );
auto portInput = root->find<UITextInput>( "port" );
auto automaticInput = root->find<UICheckBox>( "automatic" );
auto validationMessage = root->find<UITextView>( "validation-message" );
auto saveButton = root->find<UIPushButton>( "save" );
auto resetButton = root->find<UIPushButton>( "reset" );
auto status = root->find<UITextView>( "status" );
ObservableValue<std::string> project( "eepp" );
ObservableValue<std::string> host( "localhost" );
ObservableValue<int> port( 8080 );
ObservableValue<bool> automatic( false );
auto requiredText = UIValueConverter<std::string>(
[]( const CSS::PropertyDefinition*,
const std::string& value ) -> UIValueResult<std::string> {
return value.empty() ? UIValueResult<std::string>::error( 100, "value is required" )
: UIValueResult<std::string>( value );
} );
auto validPort = UIValueConverter<int>(
[]( const CSS::PropertyDefinition*, const std::string& value ) -> UIValueResult<int> {
int parsed = 0;
if ( !String::fromString( parsed, value ) || parsed < 1 || parsed > 65535 )
return UIValueResult<int>::error( 101, "port must be between 1 and 65535" );
return parsed;
} );
UIBindingGroup form;
form += bindValue( project, projectInput, requiredText );
form += bindValue( host, hostInput, requiredText );
form += bindValue( port, portInput, validPort );
form += bindValue( automatic, automaticInput );
form.onChange( [&] {
for ( auto widget : form.widgets() )
widget->removeClass( "field-error" );
std::string message;
for ( const auto& error : form.errors() ) {
if ( error.widget )
error.widget->addClass( "field-error" );
if ( !message.empty() )
message += '\n';
if ( error.validation && error.validation->code == 100 )
message +=
error.widget == projectInput ? "Project is required." : "Host is required.";
else if ( error.validation && error.validation->code == 101 )
message += "Port must be between 1 and 65535.";
else
message += "Invalid value.";
}
validationMessage->setText( message );
} );
auto summary = computedValue(
project, host, port, automatic,
[]( const std::string& project, const std::string& host, int port, bool automatic ) {
return project + " deploys to " + host + ":" + String::toString( port ) +
( automatic ? " automatically" : " manually" );
} );
auto summaryBinding = bindValue( summary, root->find<UIWidget>( "summary" ) );
auto canSave = computedValue( form.validValue(), form.dirtyValue(),
[]( bool valid, bool dirty ) { return valid && dirty; } );
auto saveCommand = bindCommand(
[&] {
form.markClean();
status->setText( "Saved" );
},
canSave, *saveButton, *app.getUI(), { KEY_S, KeyMod::getDefaultModifier() } );
resetButton->onClick( [&]( const MouseEvent* ) {
form.reset();
status->setText( "Reset to last save" );
} );
return app.run();
}
+500 -1
View File
@@ -1,10 +1,20 @@
#include "utest.h"
#include <eepp/core/computedvalue.hpp>
#include <eepp/core/observablevalue.hpp>
#include <eepp/core/observablevector.hpp>
#include <eepp/scene/actionmanager.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/ui/models/observablelistmodel.hpp>
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/databinding/uibindinggroup.hpp>
#include <eepp/ui/uicheckbox.hpp>
#include <eepp/ui/databinding/uicommand.hpp>
#include <eepp/ui/databinding/uiobservedelivery.hpp>
#include <eepp/ui/databinding/uiproperty.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uitextinput.hpp>
#include <eepp/ui/uivaluebinding.hpp>
#include <eepp/ui/databinding/uivaluebinding.hpp>
#include <thread>
using namespace EE;
using namespace EE::UI;
@@ -66,6 +76,495 @@ UTEST( ObservableValue, notifiesObserversInRegistrationOrder ) {
EXPECT_EQ( order[2], 2 );
}
UTEST( ObservableValue, queuesLatestReentrantValue ) {
ObservableValue<int> value( 0 );
std::vector<int> observed;
auto updating = value.observe( [&]( const int& current ) {
observed.emplace_back( current );
if ( current == 1 ) {
value = 2;
value = 3;
}
} );
auto recording =
value.observe( [&]( const int& current ) { observed.emplace_back( current * 10 ); } );
value = 1;
ASSERT_EQ( observed.size(), 4u );
EXPECT_EQ( observed[0], 1 );
EXPECT_EQ( observed[1], 10 );
EXPECT_EQ( observed[2], 3 );
EXPECT_EQ( observed[3], 30 );
}
UTEST( ObservableValue, preservesSnapshotsWithoutCopyingCallbacks ) {
ObservableValue<int> value( 0 );
int firstCalls = 0;
int disconnectedCalls = 0;
int addedCalls = 0;
typename ObservableValue<int>::Connection disconnected;
typename ObservableValue<int>::Connection added;
auto first = value.observe( [&]( const int& current ) {
++firstCalls;
if ( current == 1 ) {
disconnected.disconnect();
added = value.observe( [&]( const int& ) { ++addedCalls; } );
value = 2;
}
} );
disconnected = value.observe( [&]( const int& ) { ++disconnectedCalls; } );
value = 1;
EXPECT_EQ( firstCalls, 2 );
EXPECT_EQ( disconnectedCalls, 1 );
EXPECT_EQ( addedCalls, 1 );
EXPECT_FALSE( static_cast<bool>( disconnected ) );
EXPECT_TRUE( static_cast<bool>( added ) );
}
UTEST( ComputedValue, derivesExplicitDependenciesAndSuppressesEqualResults ) {
ObservableValue<std::string> first( "Ada" );
ObservableValue<std::string> last( "Lovelace" );
auto fullName =
computedValue( first, last, []( const std::string& first, const std::string& last ) {
return first + " " + last;
} );
int notifications = 0;
auto connection = fullName.observe( [&]( const std::string& ) { ++notifications; } );
EXPECT_TRUE( fullName.get() == "Ada Lovelace" );
first = "Grace";
EXPECT_TRUE( fullName.get() == "Grace Lovelace" );
EXPECT_EQ( notifications, 1 );
first = "Grace";
EXPECT_EQ( notifications, 1 );
}
UTEST( ComputedValue, supportsChainsAndSafeDestructionOrders ) {
auto source = std::make_unique<ObservableValue<int>>( 2 );
auto doubled = computedValue( *source, []( int value ) { return value * 2; } );
auto label = computedValue( doubled, []( int value ) { return String::toString( value ); } );
auto connection = label.observe( []( const std::string& ) {} );
*source = 3;
EXPECT_TRUE( label.get() == "6" );
source.reset();
EXPECT_TRUE( label.get() == "6" );
EXPECT_TRUE( static_cast<bool>( connection ) );
}
UTEST( ComputedValue, bindsOneWayToWidget ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - ComputedValue Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto widget = UITextInput::New();
ObservableValue<int> count( 2 );
auto label = computedValue( count, []( int value ) { return String::toString( value * 2 ); } );
auto binding =
bindValue( label, widget, UIValueConverter<std::string>::converterString(), "text" );
EXPECT_TRUE( widget->getText() == "4" );
count = 3;
EXPECT_TRUE( widget->getText() == "6" );
eeDelete( widget );
EXPECT_FALSE( static_cast<bool>( binding ) );
}
UTEST( UIProperty, participatesInComputedValuesAndCommands ) {
UIProperty<int> count( 2 );
auto doubled = computedValue( count, []( int value ) { return value * 2; } );
EXPECT_EQ( doubled.get(), 4 );
count = 3;
EXPECT_EQ( doubled.get(), 6 );
UIProperty<bool> enabled( true );
int executions = 0;
UICommand command( [&] { ++executions; }, enabled );
EXPECT_TRUE( command.execute() );
enabled = false;
EXPECT_FALSE( command.execute() );
EXPECT_EQ( executions, 1 );
}
UTEST( UIValueConverter, customInputConversionUsesDefaultOutputConversion ) {
UIValueConverter<int> converter(
[]( const CSS::PropertyDefinition*, const std::string& value ) -> UIValueResult<int> {
int parsed = 0;
return String::fromString( parsed, value ) ? UIValueResult<int>( parsed )
: UIValueResult<int>::error( 1 );
} );
auto converted = converter.fromValue( nullptr, 42 );
ASSERT_TRUE( converted );
EXPECT_TRUE( *converted.value == "42" );
}
UTEST( UIBindingGroup, aggregatesValidationDirtyAndReset ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIBindingGroup Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto nameInput = UITextInput::New();
auto countInput = UITextInput::New();
ObservableValue<std::string> name( "Ada" );
int count = 2;
UIBindingGroup form;
form += bindValue( name, nameInput, UIValueConverter<std::string>::converterString(), "text",
Event::OnTextChanged );
form += UIDataBind<int>::New( &count, countInput, UIValueConverter<int>::converterDefault(),
"text", Event::OnTextChanged );
int changeNotifications = 0;
form.onChange( [&] { ++changeNotifications; } );
EXPECT_TRUE( form.isValid() );
EXPECT_FALSE( form.isDirty() );
auto widgets = form.widgets();
ASSERT_EQ( widgets.size(), 2u );
EXPECT_EQ( widgets[0], nameInput );
EXPECT_EQ( widgets[1], countInput );
name = "Grace";
EXPECT_TRUE( form.isDirty() );
form.reset();
EXPECT_TRUE( name.get() == "Ada" );
EXPECT_FALSE( form.isDirty() );
countInput->setText( "invalid" );
EXPECT_FALSE( form.isValid() );
EXPECT_TRUE( form.firstInvalidWidget() == countInput );
ASSERT_EQ( form.errors().size(), 1u );
countInput->setEnabled( false );
EXPECT_TRUE( form.isValid() );
EXPECT_TRUE( form.errors().empty() );
countInput->setEnabled( true );
EXPECT_FALSE( form.isValid() );
name = "Grace";
const int notificationsBeforeAggregateUnchanged = changeNotifications;
name = "Lovelace";
EXPECT_EQ( notificationsBeforeAggregateUnchanged + 1, changeNotifications );
form.clear();
EXPECT_TRUE( form.isValid() );
eeDelete( nameInput );
eeDelete( countInput );
}
UTEST( UIBindingGroup, publishesAggregateStateForComputedConsumers ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIBindingGroup Aggregate Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto input = UITextInput::New();
ObservableValue<std::string> value( "initial" );
UIBindingGroup form;
form += bindValue( value, input, UIValueConverter<std::string>::converterString(), "text",
Event::OnTextChanged );
auto canSave = computedValue( form.validValue(), form.dirtyValue(),
[]( bool valid, bool dirty ) { return valid && dirty; } );
EXPECT_FALSE( canSave.get() );
value = "changed";
EXPECT_TRUE( canSave.get() );
form.markClean();
EXPECT_FALSE( canSave.get() );
eeDelete( input );
}
UTEST( UIBindingGroup, tracksUIPropertyValidationDirtyStateAndLifetime ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIProperty Group Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto input = UITextInput::New();
auto mirrorInput = UITextInput::New();
UIValueConverter<int> validPort(
[]( const CSS::PropertyDefinition*, const std::string& value ) -> UIValueResult<int> {
int parsed = 0;
return String::fromString( parsed, value ) && parsed >= 1 && parsed <= 65535
? UIValueResult<int>( parsed )
: UIValueResult<int>::error( 1, "invalid port" );
} );
UIBindingGroup form;
{
UIProperty<int> port( 8080, UnorderedSet<UIWidget*>{ input, mirrorInput }, validPort );
form += port;
auto widgets = form.widgets();
ASSERT_EQ( widgets.size(), 2u );
EXPECT_TRUE( std::find( widgets.begin(), widgets.end(), input ) != widgets.end() );
EXPECT_TRUE( std::find( widgets.begin(), widgets.end(), mirrorInput ) != widgets.end() );
EXPECT_TRUE( form.isValid() );
EXPECT_FALSE( form.isDirty() );
port = 9000;
EXPECT_TRUE( form.isDirty() );
form.reset();
EXPECT_EQ( port.get(), 8080 );
EXPECT_FALSE( form.isDirty() );
input->setText( "9001" );
EXPECT_EQ( port.get(), 9001 );
EXPECT_TRUE( form.isDirty() );
input->setText( "invalid" );
EXPECT_FALSE( form.isValid() );
EXPECT_EQ( form.firstInvalidWidget(), input );
input->setEnabled( false );
EXPECT_TRUE( form.isValid() );
input->setEnabled( true );
EXPECT_FALSE( form.isValid() );
}
EXPECT_TRUE( form.isValid() );
EXPECT_FALSE( form.isDirty() );
EXPECT_TRUE( form.widgets().empty() );
EXPECT_TRUE( form.validValue().get() );
EXPECT_FALSE( form.dirtyValue().get() );
eeDelete( input );
eeDelete( mirrorInput );
}
UTEST( UICommand, synchronizesEnabledStateAndPreventsReentrantExecution ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UICommand Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto widget = UIWidget::New();
int executions = 0;
UICommand* commandPtr = nullptr;
UICommand command( [&] {
++executions;
commandPtr->execute();
} );
commandPtr = &command;
auto binding = bindCommand( command, *widget );
EXPECT_TRUE( command.execute() );
EXPECT_EQ( executions, 1 );
command.enabled() = false;
EXPECT_FALSE( widget->isEnabled() );
EXPECT_FALSE( command.execute() );
eeDelete( widget );
}
UTEST( UICommand, shortcutUsesSceneDispatchAndRestoresPreviousMapping ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UICommand Shortcut Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto shortcut = KeyBindings::Shortcut{ KEY_S, KeyMod::getDefaultModifier() };
app.getUI()->getKeyBindings().addKeybind( shortcut, "previous-save" );
int executions = 0;
UICommand command( [&] { ++executions; } );
{
auto binding = bindCommand( command, *app.getUI(), shortcut );
auto registered = app.getUI()->getKeyBindings().getCommandFromKeyBind( shortcut );
EXPECT_TRUE( registered != "previous-save" );
app.getUI()->executeKeyBindingCommand( registered );
EXPECT_EQ( executions, 1 );
command.enabled() = false;
app.getUI()->executeKeyBindingCommand( registered );
EXPECT_EQ( executions, 1 );
}
EXPECT_TRUE( app.getUI()->getKeyBindings().getCommandFromKeyBind( shortcut ) ==
"previous-save" );
}
UTEST( UICommand, followsComputedEnabledSource ) {
ObservableValue<bool> valid( true );
ObservableValue<bool> dirty( false );
auto canSave =
computedValue( valid, dirty, []( bool valid, bool dirty ) { return valid && dirty; } );
int executions = 0;
UICommand command( [&] { ++executions; }, canSave );
EXPECT_FALSE( command.execute() );
dirty = true;
EXPECT_TRUE( command.execute() );
EXPECT_EQ( executions, 1 );
valid = false;
EXPECT_FALSE( command.execute() );
}
UTEST( UICommand, compositeBindingOwnsCommandButtonAndShortcut ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UICommand Composite Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto button = UIWidget::New();
button->setParent( app.getUI()->getRoot() );
ObservableValue<bool> enabled( true );
int executions = 0;
auto shortcut = KeyBindings::Shortcut{ KEY_S, KeyMod::getDefaultModifier() };
auto binding = bindCommand( [&] { ++executions; }, enabled, *button, *app.getUI(), shortcut );
button->sendMouseEvent( Event::MouseClick, Vector2i::Zero, 0 );
EXPECT_EQ( executions, 1 );
auto registered = app.getUI()->getKeyBindings().getCommandFromKeyBind( shortcut );
app.getUI()->executeKeyBindingCommand( registered );
EXPECT_EQ( executions, 2 );
enabled = false;
EXPECT_FALSE( button->isEnabled() );
app.getUI()->executeKeyBindingCommand( registered );
EXPECT_EQ( executions, 2 );
}
UTEST( ObservableVector, emitsIncrementalChangesAndDrivesModel ) {
ObservableVector<std::string> values( { "a", "b" } );
auto model = Models::ObservableListModel<std::string>::create( values );
std::vector<ObservableVector<std::string>::Change> changes;
auto connection = values.observe( [&]( const auto& change ) { changes.push_back( change ); } );
values.insert( 1, "x" );
EXPECT_EQ( model->rowCount(), 3u );
EXPECT_TRUE( model->data( model->index( 1 ) ).toString() == "x" );
values.set( 1, "y" );
values.move( 1, 2 );
values.erase( 0 );
EXPECT_EQ( values.size(), 2u );
EXPECT_EQ( changes.size(), 8u );
}
UTEST( ObservableVector, preservesSnapshotsAcrossNestedNotifications ) {
ObservableVector<int> values( { 1 } );
int firstCalls = 0;
int disconnectedCalls = 0;
int addedCalls = 0;
bool nested = false;
typename ObservableVector<int>::Connection disconnected;
typename ObservableVector<int>::Connection added;
auto first = values.observe( [&]( const auto& change ) {
++firstCalls;
if ( !nested && change.type == ObservableVector<int>::ChangeType::Insert &&
change.phase == ObservableVector<int>::Phase::Before ) {
nested = true;
disconnected.disconnect();
added = values.observe( [&]( const auto& ) { ++addedCalls; } );
values.set( 0, 2 );
}
} );
disconnected = values.observe( [&]( const auto& ) { ++disconnectedCalls; } );
values.pushBack( 3 );
EXPECT_EQ( firstCalls, 4 );
EXPECT_EQ( disconnectedCalls, 1 );
EXPECT_EQ( addedCalls, 3 );
EXPECT_FALSE( static_cast<bool>( disconnected ) );
EXPECT_TRUE( static_cast<bool>( added ) );
}
UTEST( ObservableListModel, formatsFiltersAndMapsSourceRows ) {
ObservableVector<std::string> values( { "alpha", "beta", "alpine" } );
auto model = Models::ObservableListModel<std::string>::create(
values, []( const std::string& value, Models::ModelRole role ) {
return role == Models::ModelRole::Display ? Models::Variant( "item: " + value )
: Models::Variant{};
} );
model->setFilter(
[]( const std::string& value ) { return String::startsWith( value, "al" ); } );
ASSERT_EQ( model->rowCount(), 2u );
EXPECT_TRUE( model->data( model->index( 1 ) ).toString() == "item: alpine" );
ASSERT_TRUE( model->sourceRow( model->index( 1 ) ) );
EXPECT_EQ( *model->sourceRow( model->index( 1 ) ), 2u );
values.pushBack( "albatross" );
EXPECT_EQ( model->rowCount(), 3u );
model->clearFilter();
EXPECT_EQ( model->rowCount(), 4u );
}
UTEST( ObservableListModel, retainsSourceStorageAfterObservableVectorDestruction ) {
std::shared_ptr<Models::ObservableListModel<std::string>> model;
{
ObservableVector<std::string> values( { "alpha", "beta" } );
model = Models::ObservableListModel<std::string>::create( values );
EXPECT_EQ( model->rowCount(), 2u );
}
ASSERT_EQ( model->rowCount(), 2u );
EXPECT_TRUE( model->data( model->index( 0 ) ).toString() == "alpha" );
EXPECT_TRUE( model->data( model->index( 1 ) ).toString() == "beta" );
model.reset();
}
UTEST( UIThreadObservation, usesImmediateMainThreadFastPathAndExpiresSafely ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIThreadObservation Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto widget = UIWidget::New();
ObservableValue<int> value( 1 );
int delivered = 0;
auto observation =
observeOnUIThread( value, *app.getUI(), *widget,
[&]( UIWidget&, const int& current ) { delivered = current; } );
value = 2;
EXPECT_EQ( delivered, 2 );
eeDelete( widget );
EXPECT_FALSE( static_cast<bool>( observation ) );
}
UTEST( UIThreadObservation, callbackCanCloseEndpoint ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIThreadObservation Reentrancy Test",
WindowStyle::Default, WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto widget = UIWidget::New();
ObservableValue<int> value( 1 );
auto observation =
observeOnUIThread( value, *app.getUI(), *widget,
[&]( UIWidget& endpoint, const int& ) { eeDelete( &endpoint ); } );
value = 2;
EXPECT_FALSE( static_cast<bool>( observation ) );
}
UTEST( UIThreadObservation, workerDeliveryIsOrderedAndHonorsScopedLifetimes ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIThreadObservation Worker Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto widget = UIWidget::New();
ObservableValue<int> value( 1 );
std::vector<int> delivered;
auto observation =
observeOnUIThread( value, *app.getUI(), *widget, [&]( UIWidget&, const int& current ) {
delivered.push_back( current );
} );
std::thread producer( [&] {
value = 2;
value = 3;
} );
producer.join();
EXPECT_TRUE( delivered.empty() );
app.getUI()->getActionManager()->update( Time::Zero );
ASSERT_EQ( delivered.size(), 2u );
EXPECT_EQ( delivered[0], 2 );
EXPECT_EQ( delivered[1], 3 );
std::thread queuedBeforeClose( [&] { value = 4; } );
queuedBeforeClose.join();
eeDelete( widget );
app.getUI()->getActionManager()->update( Time::Zero );
EXPECT_EQ( delivered.size(), 2u );
EXPECT_FALSE( static_cast<bool>( observation ) );
auto disconnectedWidget = UIWidget::New();
ObservableValue<int> disconnectedValue( 1 );
int deliveredAfterDisconnect = 0;
auto disconnectedObservation =
observeOnUIThread( disconnectedValue, *app.getUI(), *disconnectedWidget,
[&]( UIWidget&, const int& ) { ++deliveredAfterDisconnect; } );
std::thread queuedBeforeDisconnect( [&] { disconnectedValue = 2; } );
queuedBeforeDisconnect.join();
disconnectedObservation.disconnect();
app.getUI()->getActionManager()->update( Time::Zero );
EXPECT_EQ( deliveredAfterDisconnect, 0 );
eeDelete( disconnectedWidget );
}
UTEST( UIValueBinding, synchronizesBothDirectionsAndHandlesEndpointLifetimes ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIValueBinding Test", WindowStyle::Default,
+45 -1
View File
@@ -2,7 +2,7 @@
#include <eepp/system/filesystem.hpp>
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/uicheckbox.hpp>
#include <eepp/ui/uiproperty.hpp>
#include <eepp/ui/databinding/uiproperty.hpp>
#include <eepp/ui/uitextinput.hpp>
using namespace EE;
@@ -102,6 +102,30 @@ UTEST( UIProperty, stringConcatenationPropagatesForStandardAndEEStrings ) {
EXPECT_TRUE( eeString + String( "!" ) == String( "hello eepp!" ) );
}
UTEST( UIProperty, observersCanDestroyPropertyDuringWidgetNotification ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIProperty Destruction Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto input = UITextInput::New();
auto property = std::make_unique<UIProperty<std::string>>( input );
bool observed = false;
bool changed = false;
property->changed( [&]( const std::string& value ) { changed = value == "destroy"; } );
auto connection = property->observe( [&]( const std::string& value ) {
observed = value == "destroy";
property.reset();
} );
input->setText( "destroy" );
EXPECT_TRUE( observed );
EXPECT_TRUE( changed );
EXPECT_TRUE( property == nullptr );
EXPECT_FALSE( static_cast<bool>( connection ) );
eeDelete( input );
}
UTEST( UIDataBind, defaultStringConverterReadsWidgetValue ) {
auto converter = UIDataBind<std::string>::converterDefault();
auto value = converter.toValue( nullptr, "widget value" );
@@ -109,6 +133,26 @@ UTEST( UIDataBind, defaultStringConverterReadsWidgetValue ) {
EXPECT_TRUE( *value.value == "widget value" );
}
UTEST( UIDataBind, sharedValueSurvivesBindingDestructionDuringNotification ) {
auto value = std::make_shared<std::string>( 256, 'a' );
std::weak_ptr<std::string> weakValue = value;
auto binding = std::make_unique<UIDataBind<std::string>>(
value, UnorderedSet<UIWidget*>{}, UIDataBind<std::string>::converterString() );
bool observed = false;
auto connection = binding->observe( [&]( const std::string& current ) {
observed = current.size() == 512;
value.reset();
binding.reset();
EXPECT_FALSE( weakValue.expired() );
} );
binding->set( std::string( 512, 'b' ) );
EXPECT_TRUE( observed );
EXPECT_TRUE( weakValue.expired() );
EXPECT_FALSE( static_cast<bool>( connection ) );
}
UTEST( UIDataBind, lateBoundWidgetReceivesValueAndCanDieFirst ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIDataBind Test", WindowStyle::Default,
+1 -1
View File
@@ -12,7 +12,7 @@
#include <eterm/terminal/terminaltypes.hpp>
#include <eterm/ui/uiterminal.hpp>
#include <nlohmann/json_fwd.hpp>
#include <eepp/thirdparty/nlohmann/json_fwd.hpp>
using namespace EE;
using namespace EE::Math;
@@ -9,7 +9,7 @@
#include <eepp/ui/widgetcommandexecuter.hpp>
#include <eepp/core/containers.hpp>
#include <nlohmann/json_fwd.hpp>
#include <eepp/thirdparty/nlohmann/json_fwd.hpp>
namespace EE { namespace UI {
class UIWidget;
@@ -7,7 +7,7 @@
#include <eepp/network/http.hpp>
#include <functional>
#include <memory>
#include <nlohmann/json_fwd.hpp>
#include <eepp/thirdparty/nlohmann/json_fwd.hpp>
#include <string>
namespace ecode {
@@ -1,7 +1,7 @@
#include <eepp/config.hpp>
#include <eepp/system/mutex.hpp>
#include <nlohmann/json_fwd.hpp>
#include <eepp/thirdparty/nlohmann/json_fwd.hpp>
using namespace EE::System;
+1 -1
View File
@@ -2,7 +2,7 @@
#define EE_UI_UIBUILDSETTINGS_HPP
#include "projectbuild.hpp"
#include <eepp/ui/uidatabind.hpp>
#include <eepp/ui/databinding/uidatabind.hpp>
#include <eepp/ui/uirelativelayout.hpp>
namespace EE { namespace UI {