mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-10-01 10:40:20 +03:00
Add reactive UI data binding and observable collections
Introduce observable and computed values, typed widget bindings, validation groups, UI properties, commands, and UI-thread delivery. Add observable vector and list model support for incrementally updated collections, with filtering and source-row mapping. Modernize the Flight Booker and CRUD examples and add focused data handling and collection examples demonstrating the new APIs. Organize the UI binding headers under ui/databinding, expand test coverage for notification and lifetime semantics, and document API selection, validation, ownership, threading, and performance behavior.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user