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
@@ -1,682 +0,0 @@
# UI Data Handling Modernization Plan
Status: Stage 1 implemented and under review; Stage 2 is next, 2026-08-09.
Baseline commit: `01d5614a7 ui: add scoped event and observable value bindings`
## Goal
Build a modern, predictable data-handling layer for eepp's retained-mode UI without imposing a
React-like component model, virtual DOM, immutable application state, or mandatory reactive
architecture.
The system must preserve direct widget manipulation and the existing model/view APIs while making
safe value synchronization, validation, derived state, commands, background delivery, forms, and
diagnostics available as composable C++ tools.
The target layering is:
```text
Application state UI-local state Existing raw state
ObservableValue<T> UIProperty<T> T*
| | |
UIValueBinding<T> UIDataBind<T> <--------------+
| |
+------------- UIValueConverter<T> +
|
UIWidget
Collections -> Model adapters -> UIAbstractView
Commands ---------------------> buttons / menus / shortcuts
```
The current classes retain distinct purposes:
- `EventConnection`: scoped lifetime for a `Node` listener. It does not replace widget-level
`Event::OnClose` lifecycle notification.
- `UIDataBind<T>`: low-level adaptation of an externally owned `T*`. The caller controls and must
prove the value lifetime.
- `UIProperty<T>`: inexpensive owned UI-local value and bidirectional widget binding.
- `ObservableValue<T>`: UI-independent owned state whose observer types are unknown to the model.
- `UIValueBinding<T>`: scoped bidirectional adapter between an `ObservableValue<T>` and a widget.
- `UIValueConverter<T>`: conversion policy shared by raw and observable bindings.
- Existing `Model` / view classes: structured and potentially large collection presentation.
Do not collapse these classes merely to share implementation. Their ownership and coupling models
are intentionally different.
## Decisions Already Made
1. Typed conversion results and observable field error state were completed in Stage 1.
2. Layout-update batching is out of scope. eepp already queues/coalesces layout invalidation, so a
generic observable transaction would add complexity without a demonstrated problem.
3. Scripting is out of scope for this roadmap. The C++ lifetime, validation, command, collection,
and inspection foundations come first.
4. The system remains retained-mode. Reactive features update persistent widgets and models; they
do not reconstruct a virtual widget tree.
5. All current event, observable, binding, and widget mutation remains single-threaded unless an
explicit UI-thread delivery adapter is used.
6. New facilities must be opt-in. Existing event handlers, widget setters, and custom `Model`
implementations remain valid and are often the clearest solution.
7. Every scoped observer or binding must be safe when either endpoint is destroyed first.
8. Conversion, validation, and binding errors must be inspectable. Silent failure is not an
acceptable final design.
## Priority Order
1. Rich conversion and validation results.
2. Form/binding groups and aggregate validation.
3. Computed/derived observable values.
4. Explicit UI-thread observation and delivery.
5. Commands and reactive command state.
6. Observable collections and incremental model adapters.
7. Binding/observable inspection tooling.
This order is dependency-driven: form groups consume validation state; commands benefit from
computed values; inspection should understand every final primitive rather than being repeatedly
redesigned.
---
# Stage 1: Typed Conversion and Field Error State
Status: implemented locally; build and 924-test ASAN suite pass.
## Objective
Replace output-parameter converters and their bare `bool` result with typed results that either
contain an accepted value or identify a failure. Expose the current error state from both
`UIDataBind` and `UIValueBinding` without adding a second validator pass or a general-purpose
binding pipeline.
`UIValueConverter<T>` is intentionally the only input policy. Its `toValue()` callback performs
whatever parsing and field-local acceptance a use case needs, while `fromValue()` formats
authoritative model values. More specialized composition belongs in application code until a
repeated concrete use case justifies another shared abstraction.
Validation errors should be machine-readable first. UI code normally maps a stable numeric error
code to localized text; the optional string is a technical diagnostic for logs, tests, and
inspection rather than the default user-facing message. This stage must not force converters to
allocate an error string on success or on ordinary coded failures.
## Implemented result type
The public result and observable error state live in:
```text
include/eepp/ui/uivaluevalidation.hpp
```
Implemented result shape:
```cpp
struct UIValueValidationResult {
using Code = Uint32;
bool valid{ true };
std::optional<Code> code;
std::optional<std::string> debugMessage;
static UIValueValidationResult success();
static UIValueValidationResult error( Code code );
static UIValueValidationResult error( Code code, std::string debugMessage );
static UIValueValidationResult error( std::string debugMessage );
explicit operator bool() const { return valid; }
};
```
`Code` is intentionally numeric and `0` is not overloaded to mean “no code”; the disengaged
`std::optional` represents absence. Codes are defined by the subsystem or application that owns
the acceptance rule. `UIValueValidationError` reserves documented values for built-in converter
failures. Codes are not otherwise globally unique or stable for serialization unless a later API
introduces an error domain.
An error may have only a code, only a diagnostic, or both. Coded errors are the normal application
path. Diagnostic-only errors remain useful for ad-hoc acceptance rules and converter migration, but UI
presentation must not depend on English diagnostic text.
The selected public name is `UIValueValidationResult`: converter failures can represent syntax,
range, or other field-local acceptance errors without introducing nearly identical result types.
Success and code-only errors are allocation-free; diagnostics allocate only when supplied.
## Converter migration
Change `UIValueConverter<T>` callbacks from:
```cpp
std::function<bool( const PropertyDefinition*, T&, const std::string& )>
std::function<bool( const PropertyDefinition*, std::string&, const T& )>
```
to:
```cpp
std::function<UIValueResult<T>( const PropertyDefinition*, const std::string& )>
std::function<UIValueResult<std::string>( const PropertyDefinition*, const T& )>
```
Returning values instead of mutating output parameters prevents failed converters from leaking
partial output and makes the binding's early-return behavior explicit.
This is intentionally source-breaking for custom converters. A legacy `return false` is ambiguous
once conversion must return a typed value and could accidentally become a successful boolean or
numeric value. Migrate custom converters explicitly and do not retain duplicate output-parameter
adapters. Infallible converters may return a raw `T`; fallible converters use
`UIValueResult<T>::error()`.
Default parse failures should use documented core error codes and may additionally produce useful
diagnostics containing the rejected text and expected type/category when practical. Do not
localize low-level converter diagnostics; preserve technical text suitable for logs and inspector
tooling. Applications translate the code (plus their binding/form context) through their own i18n
layer.
## Property conversion
Bindings call `UIValueConverter<T>` directly in both directions:
```text
model output: T -> widget property string
widget input: property string -> accepted T or error
```
Presentation-specific formatting such as currencies, percentages, and units belongs in
`UIValueConverter<T>`. More elaborate typed adaptation can be implemented by applications if a
concrete use case requires it.
## Field-local acceptance
Parsing and semantic acceptance are conceptually different, but both are part of the converter's
single widget-input operation:
```text
widget string -> UIValueConverter<T>::toValue() -> accepted T or error
```
Examples:
- `"abc"` cannot convert to an integer.
- `-1` parses as an integer but may still be rejected by the converter's acceptance rules.
- A path converts to a string but may not exist.
- A return date converts successfully but may precede the departure date.
Do not add a second validation pass to every binding. A custom converter can reuse parsing or
validation helpers internally when an application needs them. Do not put asynchronous validation
in Stage 1.
## Binding state and API
Both `UIDataBind<T>` and `UIValueBinding<T>` must expose their current validity without requiring
knowledge of the other class. The common state is:
```cpp
class UIValueValidationState {
public:
bool isValid() const;
const std::optional<UIValueValidationResult::Code>& code() const;
const std::optional<std::string>& debugMessage() const;
Connection observe( Callback );
};
```
Validation observation reuses `ObservableValue` internally and allocates its observer storage only
when observed. `ObservableValue` does not depend on UI headers.
Required binding behavior:
1. Successful input acceptance updates the model value and clears the previous error.
2. Rejected input leaves the last accepted model value unchanged.
3. The originating widget may retain its invalid text so the user can correct it.
4. Other widgets bound to the same value must continue showing the last valid model value; invalid
text must not propagate to them.
5. Converter acceptance applies to UI-originated proposals. Programmatic `set()` and external
`ObservableValue` changes are authoritative model updates; formatting can still fail and be
reported.
6. Model-to-widget conversion failure must not apply an empty or partial property string.
7. Repeated identical validation errors should not emit duplicate state-change notifications.
8. Widget destruction clears its validation contribution safely.
## Widget presentation policy
Stage 1 deliberately does not hard-code an error class, tooltip, or localized message into the
binding core. Numeric codes and optional diagnostics remain independently observable. Stage 2 must
decide how a form maps field and cross-field errors to localized presentation after auditing the
existing invalid/error widget states and theme conventions.
## API compatibility audit
Audit and migrate:
- all `UIValueConverter` construction;
- `UIDataBind<T>::converterDefault/String/Bool()` compatibility forwarders;
- ecode's `ProjectOutputParserTypes` converter;
- `UIProperty` constructor defaults;
- all unit tests and examples;
- any downstream-style public callback signatures exposed in headers.
Document source-breaking changes clearly because `UIDataBind::Converter` was public before this
roadmap.
## Stage 1 verification
Implemented focused coverage includes:
- successful default conversion;
- conversion failure with a code and no diagnostic allocation;
- diagnostic-only and code-plus-diagnostic failures;
- field-local acceptance failure after parsing;
- model-originated values remain authoritative even when equivalent widget input would be rejected;
- invalid widget text does not change the model;
- invalid widget text does not propagate to sibling widgets;
- later valid input clears the error and updates every widget;
- failed model-to-widget conversion does not apply a property;
- programmatic set validation behavior;
- repeated identical error suppression, comparing validity, code, and diagnostic;
- widget-first, binding-first, and value-first destruction while invalid;
- custom converter parsing, formatting, and acceptance;
- formatted currency-style values in both directions.
The project builds with the ASAN debug configuration, all focused binding tests pass, and the full
suite passes 924/924. Presentation/localization and cross-field behavior remain Stage 2 concerns.
Use Flight Booker as a design reference for that phase, but do not migrate it until the resulting
form API is clearly shorter and more expressive than its current explicit validation function.
---
# Stage 2: Form and Binding Groups
## Objective
Provide ownership and aggregate validation for related bindings without turning every form into a
new framework.
The group must solve two separate concerns:
1. Stable ownership of heterogeneous bindings.
2. Aggregate state such as valid, dirty, commit, reset, and first error.
## Proposed API direction
Explore extending or replacing the narrowly typed `UIDataBindHolder` classes with a type-erased
scoped binding interface:
```cpp
class UIValueBindingBase {
public:
virtual ~UIValueBindingBase() = default;
virtual void disconnect() = 0;
virtual bool isConnected() const = 0;
virtual bool isValid() const = 0;
virtual const std::optional<UIValueValidationResult::Code>& validationCode() const = 0;
virtual const std::optional<std::string>& validationDebugMessage() const = 0;
};
```
Avoid virtual dispatch if a small type-erased value holder can provide the same ownership cleanly.
Measure complexity before choosing.
Candidate user API:
```cpp
UIBindingGroup form;
form += bindValue( config.name, nameInput, stringConverter, "text", Event::OnTextChanged );
form += bindValue( config.path, pathInput, stringConverter, "text", Event::OnTextChanged );
saveButton->setEnabled( form.isValid() && form.isDirty() );
form.onValidationChange( ... );
```
## Required semantics
- Destruction or `clear()` disconnects every binding.
- A group may contain `UIValueBinding`, `UIDataBind`, and optionally non-binding validators.
- Aggregate validity updates when a child binding changes validity or disappears.
- The group exposes every child's code, optional diagnostic, and originating binding/widget, plus
the first invalid widget for focus/navigation. The group must preserve the context needed to map
application-defined codes to localized messages.
- Dirty state compares against an explicit baseline, not merely “received an event.”
- `markClean()` establishes a new baseline after save.
- `reset()` restores the baseline where values are copyable.
- Commit/rollback must be opt-in; not every live configuration form uses temporary state.
- A group must not own widgets or observable model values.
## Tests
- heterogeneous binding ownership;
- aggregate validity transitions;
- first invalid widget behavior;
- widget destruction while invalid;
- dirty/clean baseline behavior;
- reset and mark-clean;
- group destruction before and after endpoints;
- no duplicate aggregate notification when state is unchanged.
---
# Stage 3: Computed and Derived Observable Values
## Objective
Represent read-only state derived from one or more observables while preserving synchronous,
deterministic retained-mode updates.
## Design constraints
- Do not implement implicit dependency tracking by executing arbitrary lambdas and recording reads.
- Dependencies must be explicit in the initial implementation.
- Computed values are read-only to consumers.
- Dependency connections are scoped and expire safely.
- Equality suppression should match `ObservableValue` behavior.
- Reentrant updates and cycles must have defined behavior before merging.
## Candidate API
```cpp
auto fullName = computedValue(
firstName, lastName,
[]( const std::string& first, const std::string& last ) {
return first + " " + last;
} );
auto canSave = computedValue(
formValid, formDirty,
[]( bool valid, bool dirty ) { return valid && dirty; } );
```
The returned type should expose the read/observe subset of `ObservableValue`, not `set()`.
## Reentrancy decision required
The current synchronous `ObservableValue` allows an observer to set the same value during
notification. Before computed values are implemented, define and test one policy:
1. Nested immediate notifications.
2. Queue the latest value until the current notification completes.
3. Reject/assert reentrant mutation.
Recommended direction: queue the latest distinct value and drain synchronously after the current
observer snapshot completes. This avoids observers seeing later values twice during an older
notification while retaining synchronous completion before `set()` returns.
Cycle detection must report a clear diagnostic in debug builds rather than recurse indefinitely.
## Tests
- one and multiple dependencies;
- registration-order observation;
- equality suppression;
- dependency destruction;
- computed destruction;
- chained computed values;
- diamond dependency graph behavior;
- reentrant source updates;
- direct and indirect cycles;
- binding a computed value one-way to a widget.
---
# Stage 4: Explicit UI-Thread Delivery
## Objective
Allow state produced on worker threads to be delivered safely to the UI without making
`ObservableValue`, `EventConnection`, or widgets internally thread-safe.
## Proposed direction
Use existing `Node::runOnMainThread()` / `ensureMainThread()` infrastructure. Candidate APIs:
```cpp
auto connection = observeOnUIThread( value, widget, callback );
```
or:
```cpp
auto uiValue = deliverOnUIThread( source, uiSceneOrNode );
```
The adapter must capture only lifetime-safe handles. It must not queue a raw widget pointer that can
die before execution.
## Required semantics
- Source observation may occur on the source's owning thread.
- Widget mutation occurs only on the UI thread.
- Destruction before queued delivery makes the delivery a no-op.
- Define whether every value is delivered or only the latest pending value. Provide explicit names
if both modes are needed.
- Preserve order for non-coalesced delivery.
- No blocking cross-thread calls.
- Clearly document that base `ObservableValue` remains single-threaded; a producer must serialize
mutation or use a separate synchronized source adapter.
## Tests
- worker-to-UI delivery;
- endpoint destruction before execution;
- connection destruction before execution;
- ordered delivery;
- latest-value coalescing, if supported;
- UI-thread immediate fast path;
- sanitizer coverage where available.
---
# Stage 5: Commands
## Objective
Represent user actions separately from values and bind one action consistently to buttons, menus,
keyboard shortcuts, toolbars, and command palettes.
## Candidate API
```cpp
Command save{
[&] { saveProject(); },
canSave
};
auto buttonBinding = bindCommand( save, saveButton );
auto menuBinding = bindCommand( save, saveMenuItem );
```
## Command state
Explore:
- enabled;
- checked/toggled;
- visible, only if a real use case requires it;
- label and icon metadata;
- shortcut metadata;
- execution callback;
- optional parameter type for reusable commands.
Prefer observable/computed state inputs over command-owned ad hoc listener APIs.
## Required behavior
- Disabled commands cannot execute through any bound endpoint.
- Multiple UI representations stay synchronized.
- Endpoint and command destruction are safe in either order.
- Reentrant execution policy is explicit.
- Asynchronous command progress/cancellation is deferred unless a concrete ecode workflow requires
it during implementation.
## Tests and candidate migrations
- bind one command to button and menu item;
- enabled and checked propagation;
- shortcut execution;
- endpoint destruction;
- command destruction;
- duplicate execution prevention;
- consider ecode actions already represented in menus/toolbars as the primary real-world audit;
- Circle Drawer undo/redo is a useful small example for enabled-state command binding.
---
# Stage 6: Observable Collections and Model Adapters
## Objective
Bridge ordinary application collections to eepp model/view components with incremental updates,
without replacing custom models such as Cells or forcing every collection to be observable.
## Research first
Audit existing `Model` invalidation/update flags and selection preservation before defining new
collection notifications. Reuse established model vocabulary where possible.
## Candidate change vocabulary
```cpp
inserted( index, count );
removed( index, count );
moved( from, to, count );
changed( index, count );
reset();
```
Candidate types:
```cpp
ObservableVector<T>
ObservableCollection<T>
CollectionModelAdapter<T>
```
Avoid exposing mutable container references that bypass notifications. Mutation should happen
through explicit operations or an edit guard that emits one well-defined change.
## Required behavior
- Incremental model updates preserve unaffected indexes and selection.
- Removal invalidates only affected indexes according to existing model contracts.
- Batch operations emit one range update where possible.
- Collection and view/model adapter destruction are safe in either order.
- Large collections do not copy their contents for every notification.
- Thread behavior is explicit and uses Stage 4 adapters when needed.
## Candidate audits
- CRUD's people list for basic insertion/removal/filtering.
- Application configuration lists in ecode.
- Do not migrate Cells: it has a specialized formula dependency graph and custom model semantics.
## Tests
- insert/remove/move/change/reset;
- selection preservation;
- filtering/sorting proxy interaction;
- adapter destruction;
- large range changes;
- mutation during model notification;
- stable identity where rows represent long-lived objects.
---
# Stage 7: Inspection and Diagnostics
## Objective
Make invisible data flow understandable in the existing widget inspector and debug tooling.
This stage follows the functional primitives so it can expose one coherent model.
## Inspectable information
For a widget:
- active event connections by type and registration ID;
- active `UIDataBind` / `UIValueBinding` property bindings;
- binding direction and converter type/name where available;
- current model value in a safe string representation;
- last widget value received;
- validity, numeric validation code, and optional diagnostic message;
- connected/expired endpoint status;
- owning binding/form group;
- last propagation timestamp or sequence number in debug builds;
- command bindings;
- model/collection adapter information.
For an observable:
- observer count;
- computed dependencies and dependents;
- current notification/reentrancy state;
- thread-affinity owner in debug builds;
- last validation or delivery error.
## Instrumentation constraints
- Release builds should not pay for names, timestamps, graph edges, or stack traces unless an
existing debug/inspection flag enables them.
- Do not expose raw pointers as stable identities in user-facing output.
- Inspector observation must not change lifetime or keep endpoints alive.
- Diagnostics must not recursively trigger the binding being inspected.
## Tests
- inspection does not retain endpoints;
- disconnected and expired state visibility;
- validation code and optional diagnostic visibility;
- command and computed dependency display;
- debug instrumentation compiled out or minimized in release configuration.
---
# Explicitly Deferred Work
## Generic observable transactions
Deferred because eepp already coalesces layout invalidation. Reconsider only with profiling evidence
of expensive non-layout observers repeatedly recomputing during bulk configuration changes.
## Scripting
Deferred until the C++ APIs and inspection model stabilize. A future scripting bridge should expose
the same primitives rather than inventing a separate lifetime system:
- `EventConnection`;
- observable values and computed values;
- UI bindings and validation;
- commands;
- models/collection adapters.
The scripting design must explicitly solve VM/context destruction, callback disconnection, dynamic
type conversion, error reporting, and UI-thread delivery.
## Virtual DOM / mandatory declarative components
Not planned. Users may build a React-like layer on these primitives, but eepp's core remains a
retained widget tree with direct, predictable C++ control.
---
# Cross-Stage Quality Requirements
Every stage must:
1. Preserve current direct widget/event APIs.
2. Document ownership, thread affinity, notification order, and destruction behavior.
3. Use scoped connections for all callbacks that capture object addresses.
4. Avoid per-update allocation on successful common paths where practical.
5. Preserve registration-order dispatch.
6. Define reentrancy before exposing APIs that can form cycles.
7. Add focused destruction-order and mutation-during-notification tests.
8. Run clang-format on modified C/C++ files.
9. Run `git diff --check`.
10. Build with the project's ASAN debug configuration.
11. Run focused tests first, followed by the full unit-test suite before each stage is considered
complete.
12. Audit at least one real eepp or ecode workflow before accepting a new abstraction.
# Immediate Next Action
Review and commit Stage 1, then design Stage 2 around the implementation that now exists. Audit
`UIDataBindHolder`, existing form-like screens, invalid/error widget styling, and theme conventions.
The Stage 2 design must keep field-local converter errors separate from cross-field/form errors,
aggregate observable `UIValueValidationState` instances without retaining widgets, and avoid
adding machinery to ordinary bindings solely for form use cases.
+12
View File
@@ -399,6 +399,18 @@
"command": "${project_root}/bin/eepp-sprites-debug",
"name": "eepp-sprites-debug",
"working_dir": "${project_root}/bin"
},
{
"args": "",
"command": "${project_root}/bin/eepp-ui-data-collections-debug",
"name": "eepp-ui-data-collections-debug",
"working_dir": "${project_root}/bin"
},
{
"args": "",
"command": "${project_root}/bin/eepp-ui-data-handling-debug",
"name": "eepp-ui-data-handling-debug",
"working_dir": "${project_root}/bin"
}
],
"var": {
+3 -1
View File
@@ -250,7 +250,9 @@ the most basic widgets in a vertical linear layout display.
</window>
```
**UI introduction can be found [here](https://cdn.ensoft.dev/eepp-docs/page_uiintroduction.html)**.
**UI introduction can be found [here](https://cdn.ensoft.dev/eepp-docs/page_uiintroduction.html)**.
**The UI data-binding guide can be found [here](docs/articles/uidatabinding.md).**
## UI Widgets with C++ example
+380
View File
@@ -0,0 +1,380 @@
# UI Data Binding
## Introduction
eepp data binding connects application state to UI widget properties without making the state
depend on `UIWidget`. It is designed for small, explicit data flows: a model value changes, its
bound widgets update, and valid widget input can update the model again.
The API is available through `<eepp/ui.hpp>`. Individual headers live in
`<eepp/ui/databinding/>`, while the core observable containers live in `<eepp/core/>`.
Data binding is optional. Direct widget callbacks remain the clearest solution for isolated
interactions. These helpers become useful when state has multiple consumers, input needs typed
conversion or validation, several fields form one logical form, or an action is exposed through
both a widget and a keyboard shortcut.
## Choosing a type
| Need | Type |
| --- | --- |
| UI-independent observable state | `ObservableValue<T>` |
| A value calculated from observable dependencies | `ComputedValue` |
| Concise UI-local state owned together with its binding | `UIProperty<T>` |
| Two-way model-to-widget synchronization | `UIValueBinding<T>` |
| One-way synchronization from a read-only or calculated source | `UIReadOnlyValueBinding<T>` |
| Typed parsing, formatting, and input validation | `UIValueConverter<T>` |
| Aggregate validation, dirty state, reset, and error inspection | `UIBindingGroup` |
| One action shared by buttons and keyboard shortcuts | `UICommand` |
| A live vector exposed as a one-column model | `ObservableVector<T>` and `ObservableListModel<T>` |
| Delivery of worker-produced observable changes on the UI thread | `UIThreadObservation<T>` |
| Binding an existing externally owned value | `UIDataBind<T>` |
Bindings and observer connections are scoped objects. Keep the returned object alive for as long
as synchronization is required. Destroying it disconnects the relationship.
## Observable model values
`ObservableValue<T>` owns a value and synchronously notifies observers after distinct changes:
```cpp
ObservableValue<std::string> project( "eepp" );
auto connection = project.observe( []( const std::string& value ) {
Log::info( "Project changed to: %s", value );
} );
project = "ecode";
```
The model remains independent from the UI. A model object can publish state without including UI
headers, and UI code can attach or disappear later.
Notifications run on the thread that changes the value. `ObservableValue` and its connections are
single-threaded; synchronize producer access when using worker threads.
Observer changes use snapshot semantics. Adding or disconnecting an observer during notification
takes effect on the next notification. Reentrant assignments are queued and the latest pending
value is delivered after the current observer pass.
## Binding a value to a widget
`bindValue()` creates a two-way `UIValueBinding<T>`. The current model value is immediately applied
to the widget. Later widget value changes are converted back into the model:
```cpp
ObservableValue<std::string> name( "Ada" );
auto nameBinding = bindValue( name, nameInput );
name = "Grace"; // Updates nameInput.
// Editing nameInput updates name.
```
Input widgets conventionally expose the `value` property and emit `Event::OnValueChange`, so the
default overload is normally enough. A different property can be selected explicitly:
```cpp
ObservableValue<bool> enabled( true );
auto enabledBinding = bindValue( enabled, saveButton, "enabled" );
```
Use `bindReadOnlyValue()` for calculated values or any source that should not be changed by the
widget:
```cpp
auto summary = computedValue( name, []( const std::string& value ) {
return "Hello " + value;
} );
auto summaryBinding = bindReadOnlyValue( summary, summaryLabel );
```
Both binding types observe widget destruction and disconnect safely. They do not retain widgets.
## UI-local properties
`UIProperty<T>` owns a value and synchronizes it with one or more widgets. It is useful when state
belongs entirely to one UI screen and declaring a separate model value would add ceremony:
```cpp
UIProperty<std::string> filter( filterInput );
auto filterConnection = filter.observe( [&]( const std::string& prefix ) {
updateFilter( prefix );
} );
filter = "Smith"; // Updates the widget and observers.
```
`UIProperty` implements the same observable-source interface as `ObservableValue`: `ValueType`,
`get()`, and `observe()`. It can therefore be used directly by `ComputedValue`, `UICommand`, and
`UIBindingGroup`.
Its value lives in retained shared storage. Notification publishes a lightweight change revision
instead of copying `T`, so a large `std::string` is not cloned for every observer. The retained
state also keeps the value alive if an observer destroys the property during notification.
Choose `ObservableValue` when state belongs to the model or must remain UI-independent. Choose
`UIProperty` when the value and its widgets naturally share one UI lifetime.
## Conversion and validation
Widget properties are strings. `UIValueConverter<T>` defines both directions:
- `toValue` parses widget text into `T` and may reject invalid input.
- `fromValue` formats an authoritative model value for the widget.
The common validation-only form supplies custom parsing and reuses the default formatter:
```cpp
auto validPort = UIValueConverter<int>(
[]( const CSS::PropertyDefinition*,
const std::string& text ) -> UIValueResult<int> {
int port = 0;
if ( !String::fromString( port, text ) || port < 1 || port > 65535 )
return UIValueResult<int>::error(
101, "port must be between 1 and 65535" );
return port;
} );
ObservableValue<int> port( 8080 );
auto portBinding = bindValue( port, portInput, validPort );
```
Invalid widget input does not replace the model value. The binding exposes its current
`UIValueValidationState`, including an optional numeric code and diagnostic message.
Error codes are preferable to using diagnostic strings as application logic. Codes can be mapped
to localized user-facing messages, while `debugMessage` remains useful for tests and inspection.
When presentation requires custom formatting too, provide both converter functions:
```cpp
using Date = std::optional<std::time_t>;
UIValueConverter<Date> dateConverter(
parseDateFromWidget,
formatDateForWidget );
```
## Forms with UIBindingGroup
`UIBindingGroup` owns heterogeneous value bindings or tracks `UIProperty` objects. It aggregates:
- Current validity, ignoring disabled fields.
- Dirty state relative to the last clean baseline.
- Invalid widgets and their validation results.
- Reset and `markClean()` behavior.
```cpp
ObservableValue<std::string> project( "eepp" );
ObservableValue<std::string> host( "localhost" );
ObservableValue<int> port( 8080 );
UIBindingGroup form;
form += bindValue( project, projectInput, requiredText );
form += bindValue( host, hostInput, requiredText );
form += bindValue( port, portInput, validPort );
```
The group can drive validation styling and error messages from one callback:
```cpp
form.onChange( [&] {
for ( auto widget : form.widgets() )
widget->removeClass( "field-error" );
for ( const auto& error : form.errors() )
if ( error.widget )
error.widget->addClass( "field-error" );
} );
```
The returned widget and error collections use inline storage for ordinary small forms.
`validValue()` and `dirtyValue()` are observable booleans. They compose naturally into derived
state:
```cpp
auto canSave = computedValue(
form.validValue(), form.dirtyValue(),
[]( bool valid, bool dirty ) { return valid && dirty; } );
```
After a successful save, establish a new baseline:
```cpp
form.markClean();
```
`form.reset()` restores every field to the baseline recorded when it was added or most recently
marked clean.
## Computed values
`computedValue()` creates a read-only observable derived from explicit dependencies:
```cpp
auto endpoint = computedValue(
host, port,
[]( const std::string& host, int port ) {
return host + ":" + String::toString( port );
} );
auto endpointBinding = bindReadOnlyValue( endpoint, endpointLabel );
```
Dependencies are cached and observed in argument order. Recalculation is synchronous. Equal
results are suppressed by the calculated output's `ObservableValue`.
Computed values do not own their dependencies. Keep dependencies alive while further updates are
expected.
## Commands and shortcuts
`UICommand` represents one action with observable enabled state. It is valuable when the same
action has multiple endpoints, such as a button and a keyboard shortcut. For a single button, an
ordinary `onClick()` remains simpler.
The concise binding overload creates and owns the command plus both endpoints:
```cpp
auto saveCommand = bindCommand(
[&] {
saveSettings();
form.markClean();
},
canSave,
*saveButton,
*uiScene,
{ KEY_S, KeyMod::getDefaultModifier() } );
```
For an always-enabled command, omit the enabled source:
```cpp
auto refreshCommand = bindCommand(
[&] { refresh(); },
*refreshButton,
*uiScene,
{ KEY_R, KeyMod::getDefaultModifier() } );
```
The returned binding must remain alive. It synchronizes the widget's enabled state, dispatches
clicks and shortcuts through the same action, prevents reentrant execution, and restores a
previous shortcut mapping when disconnected.
## Existing values with UIDataBind
`UIDataBind<T>` adapts a value that already exists outside the observable model types. The raw
pointer form is intentionally lightweight:
```cpp
bool showDetails = false;
auto binding = UIDataBind<bool>::New(
&showDetails, detailsCheckBox,
UIValueConverter<bool>::converterBool() );
```
The raw value must outlive the binding and every synchronous callback delivery. Direct writes
through the pointer are not observable; call `binding->set()` when a model-originated update must
reach widgets and observers.
Use the shared form when callbacks may destroy the original owner or otherwise require retained
storage:
```cpp
auto query = std::make_shared<std::string>();
auto binding = UIDataBind<std::string>::New( query, queryInput );
```
Shared bindings retain the original value through callback delivery without cloning `T`.
`UIProperty` uses this retained form internally.
## Observable collections
`ObservableVector<T>` is intended for collections that remain live while a view is attached. Its
explicit mutations emit incremental before/after notifications:
```cpp
ObservableVector<std::string> tasks( {
"Build eepp",
"Run unit tests",
} );
auto model = ObservableListModel<std::string>::create( tasks );
taskList->setModel( model );
tasks.pushBack( "Package release" );
tasks.set( 0, "Build release" );
tasks.erase( 1 );
```
Unfiltered models preserve incremental model notifications, allowing unaffected selection and
persistent indexes to survive. A formatter supports domain objects without coupling them to
`Variant`:
```cpp
auto model = ObservableListModel<Person>::create(
people,
[]( const Person& person, ModelRole role ) {
return role == ModelRole::Display
? Variant( person.surname + ", " + person.name )
: Variant{};
} );
```
Filters create a visible projection:
```cpp
model->setFilter( [prefix]( const Person& person ) {
return String::istartsWith( person.surname, prefix );
} );
```
Use `sourceRow()` before mutating a filtered source from a view selection. Immutable option lists
and collections already managed by specialized models do not benefit from `ObservableVector`.
## Delivering worker changes to the UI thread
`ObservableValue` invokes callbacks on the thread that changes it. UI widgets must be touched only
from their owning UI thread. `observeOnUIThread()` bridges a worker-produced observable to a widget:
```cpp
auto progressObservation = observeOnUIThread(
progress,
*uiScene,
*progressBar,
[]( UIWidget& widget, const float& value ) {
widget.asType<UIProgressBar>()->setProgress( value );
} );
```
The observation queues delivery through the scene scheduler and checks that the endpoint still
exists. Queued work becomes a no-op after the widget closes or the observation disconnects.
This helper does not make the source thread-safe. Construct and disconnect the observation only
while the producer is stopped or otherwise synchronized, and do not race observer-list mutation
with source mutation. If the source already changes on the UI thread, normal observation or value
binding is simpler.
## Lifetime checklist
- Retain bindings, commands, observations, and observer connections while they are needed.
- Model values must outlive bindings that refer to them.
- Widgets are not retained; bindings disconnect when widgets close.
- `ComputedValue` does not own its dependencies.
- Raw `UIDataBind` values must survive complete callback delivery.
- Shared `UIDataBind` and `UIProperty` retain their value during callbacks.
- UI operations and ordinary bindings belong on the widgets' UI thread.
- Synchronize worker-owned observable sources explicitly.
## Complete examples
- [`ui_data_handling`](https://github.com/SpartanJ/eepp/blob/develop/src/examples/ui_data_handling/ui_data_handling.cpp)
demonstrates conversion, validation, form state, computed summaries, commands, and shortcuts.
- [`ui_data_collections`](https://github.com/SpartanJ/eepp/blob/develop/src/examples/ui_data_collections/ui_data_collections.cpp)
demonstrates a live observable collection and incremental list model.
- [`7guis/flight_booker`](https://github.com/SpartanJ/eepp/blob/develop/src/examples/7guis/flight_booker/flight_booker.cpp)
demonstrates reactive cross-field validation with `UIProperty`.
- [`7guis/crud`](https://github.com/SpartanJ/eepp/blob/develop/src/examples/7guis/crud/crud.cpp)
demonstrates UI-local properties, filtering, selection state, and editable observable rows.
+3
View File
@@ -265,3 +265,6 @@ For a complete example of this introduction you can look into:
[src/examples/ui_hello_world/ui_hello_world.cpp](https://github.com/SpartanJ/eepp/blob/develop/src/examples/ui_hello_world/ui_hello_world.cpp).
Also is important to notice that for applications that wants to use the default eepp UI theme and fonts you can simply take advantage of the EE::UI::UIApplication class which controls the initialization and loading of the core components of the UI. You can look at the simplest example at [src/examples/ui_application_hello_world/ui_application_hello_world.cpp](https://github.com/SpartanJ/eepp/blob/develop/src/examples/ui_application_hello_world/ui_application_hello_world.cpp).
For applications with reactive state, typed input, validation, or observable collections, continue
with the [UI Data Binding](uidatabinding.md) guide.
+154
View File
@@ -0,0 +1,154 @@
#ifndef EE_CORE_COMPUTEDVALUE_HPP
#define EE_CORE_COMPUTEDVALUE_HPP
#include <eepp/core/observablevalue.hpp>
#include <tuple>
#include <type_traits>
#include <utility>
namespace EE {
/**
* @brief A read-only observable whose value is synchronously derived from explicit dependencies.
*
* Dependencies are observed in argument order. A dependency change updates its cached value and
* recomputes the result before the dependency's set() returns. Destroying a dependency leaves the
* computed value at its last result; destroying either endpoint safely expires scoped observers.
* ComputedValue is single-threaded, like ObservableValue.
*/
template <typename T, typename Calculator, typename... Dependencies> class ComputedValue {
private:
using Values = std::tuple<typename Dependencies::ValueType...>;
using Connections = std::tuple<typename Dependencies::Connection...>;
struct State {
State( Calculator calculator, Values values ) :
calculator( std::move( calculator ) ),
values( std::move( values ) ),
output( calculate() ) {}
T calculate() {
return std::apply( [this]( const auto&... value ) { return calculator( value... ); },
values );
}
void recompute() { output.set( calculate() ); }
Calculator calculator;
Values values;
ObservableValue<T> output;
};
template <std::size_t... Is>
ComputedValue( Calculator calculator, std::index_sequence<Is...>,
Dependencies&... dependencies ) :
mState(
std::make_shared<State>( std::move( calculator ), Values( dependencies.get()... ) ) ) {
// The comma fold guarantees dependency registration follows argument order.
( ( std::get<Is>( mConnections ) = connect<Is>( dependencies ) ), ... );
}
template <std::size_t I, typename Dependency>
typename Dependency::Connection connect( Dependency& dependency ) {
std::weak_ptr<State> weakState = mState;
return dependency.observe( [weakState]( const typename Dependency::ValueType& value ) {
if ( auto state = weakState.lock() ) {
std::get<I>( state->values ) = value;
state->recompute();
}
} );
}
public:
using ValueType = T;
using Callback = typename ObservableValue<T>::Callback;
using Connection = typename ObservableValue<T>::Connection;
/**
* @brief Creates a computed value and evaluates @p calculator once from the current
* dependencies.
*
* The calculator receives the dependency values as const references in the same order in which
* the dependencies are passed. Every dependency must outlive this object if further updates are
* expected from it.
*/
ComputedValue( Calculator calculator, Dependencies&... dependencies ) :
ComputedValue( std::move( calculator ), std::index_sequence_for<Dependencies...>{},
dependencies... ) {}
ComputedValue( const ComputedValue& ) = delete;
ComputedValue& operator=( const ComputedValue& ) = delete;
ComputedValue( ComputedValue&& ) noexcept = default;
ComputedValue& operator=( ComputedValue&& ) noexcept = default;
/** @return The most recently calculated value. */
const T& get() const { return mState->output.get(); }
const T& operator*() const { return get(); }
const T* operator->() const { return &get(); }
operator const T&() const { return get(); }
/**
* @brief Observes later changes to the calculated value.
* @return A scoped connection; destroying it disconnects the callback.
*
* The callback is not invoked immediately. Read get() when the initial value is needed.
*/
Connection observe( Callback callback ) {
return mState->output.observe( std::move( callback ) );
}
/** @return The number of observers currently attached to the calculated output. */
std::size_t observerCount() const { return mState->output.observerCount(); }
/** @return The number of observable dependencies captured by this computed value. */
static constexpr std::size_t dependencyCount() { return sizeof...( Dependencies ); }
private:
std::shared_ptr<State> mState;
Connections mConnections;
};
template <typename Calculator, typename... Dependencies>
/** @brief Creates a ComputedValue while deducing its result, calculator, and dependency types. */
auto makeComputedValue( Calculator&& calculator, Dependencies&... dependencies ) {
using StoredCalculator = std::decay_t<Calculator>;
using Result = std::decay_t<
std::invoke_result_t<StoredCalculator, const typename Dependencies::ValueType&...>>;
return ComputedValue<Result, StoredCalculator, Dependencies...>(
std::forward<Calculator>( calculator ), dependencies... );
}
template <typename Dependency, typename Calculator>
/** @brief Convenience form of makeComputedValue() for one dependency. */
auto computedValue( Dependency& dependency, Calculator&& calculator ) {
return makeComputedValue( std::forward<Calculator>( calculator ), dependency );
}
template <typename Dependency1, typename Dependency2, typename Calculator>
/** @brief Convenience form of makeComputedValue() for two dependencies. */
auto computedValue( Dependency1& dependency1, Dependency2& dependency2, Calculator&& calculator ) {
return makeComputedValue( std::forward<Calculator>( calculator ), dependency1, dependency2 );
}
template <typename Dependency1, typename Dependency2, typename Dependency3, typename Calculator>
/** @brief Convenience form of makeComputedValue() for three dependencies. */
auto computedValue( Dependency1& dependency1, Dependency2& dependency2, Dependency3& dependency3,
Calculator&& calculator ) {
return makeComputedValue( std::forward<Calculator>( calculator ), dependency1, dependency2,
dependency3 );
}
template <typename Dependency1, typename Dependency2, typename Dependency3, typename Dependency4,
typename Calculator>
/** @brief Convenience form of makeComputedValue() for four dependencies. */
auto computedValue( Dependency1& dependency1, Dependency2& dependency2, Dependency3& dependency3,
Dependency4& dependency4, Calculator&& calculator ) {
return makeComputedValue( std::forward<Calculator>( calculator ), dependency1, dependency2,
dependency3, dependency4 );
}
} // namespace EE
#endif
+4
View File
@@ -1,13 +1,17 @@
#ifndef EE_CORE_CORE_HPP
#define EE_CORE_CORE_HPP
#include <eepp/core/computedvalue.hpp>
#include <eepp/core/containers.hpp>
#include <eepp/core/debug.hpp>
#include <eepp/core/lrucache.hpp>
#include <eepp/core/memorymanager.hpp>
#include <eepp/core/noncopyable.hpp>
#include <eepp/core/observablevalue.hpp>
#include <eepp/core/observablevector.hpp>
#include <eepp/core/overloaded.hpp>
#include <eepp/core/retainsymbol.hpp>
#include <eepp/core/small_function.hpp>
#include <eepp/core/small_vector.hpp>
#include <eepp/core/string.hpp>
#include <eepp/core/utf.hpp>
+133 -20
View File
@@ -3,9 +3,11 @@
#include <algorithm>
#include <eepp/config.hpp>
#include <eepp/core/debug.hpp>
#include <eepp/core/small_vector.hpp>
#include <functional>
#include <memory>
#include <optional>
#include <utility>
namespace EE {
@@ -42,29 +44,62 @@ template <typename T> class ObservableValue {
struct Observer {
Uint32 id;
Callback callback;
bool connected{ true };
};
using Observers = SmallVector<Observer, 4>;
explicit State( T value ) : value( std::move( value ) ) {}
void set( const T& newValue ) {
void set( const T& newValue ) { setImpl( newValue ); }
void set( T&& newValue ) { setImpl( std::move( newValue ) ); }
template <typename U> void setImpl( U&& newValue ) {
if ( notifying ) {
if ( value == newValue ) {
pendingValue.reset();
} else if ( !pendingValue || *pendingValue != newValue ) {
pendingValue = std::forward<U>( newValue );
}
return;
}
if ( value == newValue )
return;
value = newValue;
notify();
}
void set( T&& newValue ) {
if ( value == newValue )
return;
value = std::move( newValue );
notify();
}
void notify() {
auto snapshot = observers;
for ( const auto& observer : snapshot )
observer.callback( value );
value = std::forward<U>( newValue );
notifying = true;
Uint32 notificationCount = 0;
do {
// Keep the callback objects in their stable observer slots while invoking them. New
// observers go into pendingObservers so growing the container cannot relocate a
// std::function that is currently executing. Disconnected observers are tombstoned
// until this pass ends, preserving snapshot semantics without copying callbacks.
const std::size_t observerCount = observers.size();
for ( std::size_t i = 0; i < observerCount; ++i )
// Disconnections made during this delivery take effect on the next one.
// The observer remains in place so callbacks are never copied here.
observers[i].callback( value );
observers.erase( std::remove_if( observers.begin(), observers.end(),
[]( const Observer& observer ) {
return !observer.connected;
} ),
observers.end() );
for ( auto& observer : pendingObservers )
observers.emplace_back( std::move( observer ) );
pendingObservers.clear();
if ( !pendingValue )
break;
value = std::move( *pendingValue );
pendingValue.reset();
// A bounded drain turns accidental observer cycles into a clear debug failure
// instead of unbounded recursion (or an infinite release-build loop).
if ( ++notificationCount == MaxReentrantNotifications ) {
eeASSERTM( false, "ObservableValue observer cycle detected" );
break;
}
} while ( true );
notifying = false;
pendingValue.reset();
}
typename Observers::iterator find( Uint32 id ) {
@@ -83,21 +118,50 @@ template <typename T> class ObservableValue {
bool contains( Uint32 id ) const {
auto observer = find( id );
return observer != observers.end() && observer->id == id;
if ( observer != observers.end() && observer->id == id )
return observer->connected;
auto pending = std::lower_bound(
pendingObservers.begin(), pendingObservers.end(), id,
[]( const Observer& item, Uint32 observerId ) { return item.id < observerId; } );
return pending != pendingObservers.end() && pending->id == id && pending->connected;
}
void remove( Uint32 id ) {
auto observer = find( id );
if ( observer != observers.end() && observer->id == id )
observers.erase( observer );
if ( observer != observers.end() && observer->id == id ) {
if ( notifying )
observer->connected = false;
else
observers.erase( observer );
return;
}
auto pending = std::lower_bound(
pendingObservers.begin(), pendingObservers.end(), id,
[]( const Observer& item, Uint32 observerId ) { return item.id < observerId; } );
if ( pending != pendingObservers.end() && pending->id == id )
pendingObservers.erase( pending );
}
void add( Uint32 id, Callback callback ) {
// Appending directly while a callback runs could reallocate observers and destroy the
// executing std::function. Pending callbacks become visible on the next delivery.
auto& destination = notifying ? pendingObservers : observers;
destination.emplace_back( Observer{ id, std::move( callback ), true } );
}
T value;
static constexpr Uint32 MaxReentrantNotifications = 1024;
Uint32 nextId{ 0 };
Observers observers;
// Separate inline storage avoids both callback copies and heap allocation for the usual
// case of a few observers added during notification.
Observers pendingObservers;
std::optional<T> pendingValue;
bool notifying{ false };
};
public:
using ValueType = T;
using Callback = std::function<void( const T& )>;
/** @brief Move-only scoped ownership of one ObservableValue observer. */
@@ -123,6 +187,7 @@ template <typename T> class ObservableValue {
return *this;
}
/** @brief Disconnects the observer. Calling this more than once is safe. */
void disconnect() {
if ( auto state = mState.lock() )
state->remove( mId );
@@ -144,11 +209,17 @@ template <typename T> class ObservableValue {
Uint32 mId{ 0 };
};
/** @brief Non-owning, lifetime-safe access used by adapters such as UIValueBinding. */
/**
* @brief Non-owning, lifetime-safe access used by adapters such as UIValueBinding.
*
* Operations fail harmlessly after the owning ObservableValue is destroyed. A WeakHandle does
* not make cross-thread access safe.
*/
class WeakHandle {
public:
WeakHandle() = default;
/** @return true when the owner still exists and accepted the set operation. */
bool set( const T& value ) const {
if ( auto state = mState.lock() ) {
state->set( value );
@@ -157,6 +228,7 @@ template <typename T> class ObservableValue {
return false;
}
/** @return true when the owner still exists and accepted the set operation. */
bool set( T&& value ) const {
if ( auto state = mState.lock() ) {
state->set( std::move( value ) );
@@ -165,6 +237,23 @@ template <typename T> class ObservableValue {
return false;
}
/** @return A copy of the current value, or std::nullopt after the owner expires. */
std::optional<T> get() const {
if ( auto state = mState.lock() )
return state->value;
return std::nullopt;
}
/** @return A scoped observer connection, or an empty connection after owner expiration. */
Connection observe( Callback callback ) const {
if ( auto state = mState.lock() ) {
auto id = ++state->nextId;
state->add( id, std::move( callback ) );
return Connection( state, id );
}
return {};
}
explicit operator bool() const { return !mState.expired(); }
private:
@@ -173,18 +262,26 @@ template <typename T> class ObservableValue {
std::weak_ptr<State> mState;
};
/** @brief Creates an observable containing a default-constructed value. */
ObservableValue() : mState( std::make_shared<State>( T{} ) ) {}
/** @brief Creates an observable containing @p value. No notification is emitted. */
explicit ObservableValue( T value ) : mState( std::make_shared<State>( std::move( value ) ) ) {}
ObservableValue( const ObservableValue& ) = delete;
ObservableValue& operator=( const ObservableValue& ) = delete;
ObservableValue( ObservableValue&& ) noexcept = default;
ObservableValue& operator=( ObservableValue&& ) noexcept = default;
/** @return A reference to the current value. */
const T& get() const { return mState->value; }
/** @brief Replaces the value and synchronously notifies observers when it changed. */
void set( const T& value ) {
auto state = mState;
state->set( value );
}
/** @brief Move-replaces the value and synchronously notifies observers when it changed. */
void set( T&& value ) {
auto state = mState;
state->set( std::move( value ) );
@@ -201,17 +298,33 @@ template <typename T> class ObservableValue {
}
const T& operator*() const { return get(); }
const T* operator->() const { return &get(); }
operator const T&() const { return get(); }
/**
* @brief Observes subsequent value changes.
* @return A scoped connection; destroying it disconnects the callback.
*
* Registration does not invoke @p callback with the current value. Reentrant set() calls are
* queued and delivered after the current observer snapshot completes.
*/
Connection observe( Callback callback ) {
auto id = ++mState->nextId;
mState->observers.emplace_back( typename State::Observer{ id, std::move( callback ) } );
mState->add( id, std::move( callback ) );
return Connection( mState, id );
}
/** @return A non-owning handle that expires safely with this observable. */
WeakHandle weakHandle() const { return WeakHandle( mState ); }
/** @return The number of currently connected observers. */
std::size_t observerCount() const { return mState->observers.size(); }
/** @return Whether observer callbacks are currently being delivered. */
bool isNotifying() const { return mState->notifying; }
private:
std::shared_ptr<State> mState;
};
+284
View File
@@ -0,0 +1,284 @@
#ifndef EE_CORE_OBSERVABLEVECTOR_HPP
#define EE_CORE_OBSERVABLEVECTOR_HPP
#include <eepp/core/observablevalue.hpp>
#include <vector>
namespace EE {
/**
* @brief A vector whose explicit mutations can incrementally update attached adapters.
*
* Use this when a collection remains live while a view is attached. Immutable option lists and
* collections already managed by a specialized Model should continue using those simpler models.
* Notifications are synchronous and the collection and its connections must be used from one
* owning thread. See the ui_data_collections example for live insertion, updates, and removal.
*/
template <typename T> class ObservableVector {
public:
/** @brief The mutation represented by a Change notification. */
enum class ChangeType { Insert, Remove, Move, Change, Reset };
/** @brief Whether a Change is emitted immediately before or after its mutation. */
enum class Phase { Before, After };
/** @brief Describes one collection mutation for incremental consumers. */
struct Change {
ChangeType type;
Phase phase;
std::size_t index{ 0 };
std::size_t count{ 0 };
std::size_t target{ 0 };
};
using ValueType = std::vector<T>;
using Callback = std::function<void( const Change& )>;
private:
struct State {
struct Observer {
std::shared_ptr<Callback> callback;
Uint64 removedGeneration{ 0 };
Uint32 id;
};
std::vector<T> values;
SmallVector<Observer, 4> observers;
Uint64 notificationGeneration{ 0 };
Uint64 activeGeneration{ 0 };
Uint32 nextId{ 0 };
Uint32 notificationDepth{ 0 };
void notify( const Change& change ) {
// Each nested delivery gets a generation. A connection removed in generation N remains
// callable by snapshots from generation <= N, but is invisible to later nested or
// future deliveries. This exactly preserves snapshot behavior without copying
// std::function.
const Uint64 parentGeneration = activeGeneration;
const Uint64 generation = ++notificationGeneration;
activeGeneration = generation;
++notificationDepth;
const std::size_t observerCount = observers.size();
for ( std::size_t i = 0; i < observerCount; ++i ) {
auto& observer = observers[i];
if ( observer.removedGeneration == 0 || observer.removedGeneration >= generation ) {
// Keep the target alive locally: a callback may grow and reallocate observers
// or disconnect itself while it is executing. Copying shared_ptr never
// allocates.
auto callback = observer.callback;
( *callback )( change );
}
}
--notificationDepth;
activeGeneration = parentGeneration;
if ( notificationDepth == 0 )
// No active snapshot can reference tombstoned observers now.
observers.erase( std::remove_if( observers.begin(), observers.end(),
[]( const Observer& observer ) {
return observer.removedGeneration != 0;
} ),
observers.end() );
}
void remove( Uint32 id ) {
auto it =
std::find_if( observers.begin(), observers.end(),
[id]( const Observer& observer ) { return observer.id == id; } );
if ( it == observers.end() )
return;
if ( notificationDepth != 0 )
it->removedGeneration = activeGeneration;
else
observers.erase( it );
}
};
public:
/** @brief Move-only scoped ownership of one collection observer. */
class Connection {
public:
Connection() = default;
~Connection() { disconnect(); }
Connection( const Connection& ) = delete;
Connection& operator=( const Connection& ) = delete;
Connection( Connection&& other ) noexcept :
mState( std::move( other.mState ) ), mId( other.mId ) {
other.mId = 0;
}
Connection& operator=( Connection&& other ) noexcept {
if ( this != &other ) {
disconnect();
mState = std::move( other.mState );
mId = other.mId;
other.mId = 0;
}
return *this;
}
/** @brief Disconnects the observer. Calling this more than once is safe. */
void disconnect() {
if ( auto state = mState.lock() )
state->remove( mId );
mState.reset();
mId = 0;
}
explicit operator bool() const {
if ( auto state = mState.lock() ) {
auto observer = std::find_if( state->observers.begin(), state->observers.end(),
[mId = mId]( const typename State::Observer& item ) {
return item.id == mId;
} );
return observer != state->observers.end() && observer->removedGeneration == 0;
}
return false;
}
private:
friend class ObservableVector<T>;
Connection( const std::shared_ptr<State>& state, Uint32 id ) : mState( state ), mId( id ) {}
std::weak_ptr<State> mState;
Uint32 mId{ 0 };
};
/**
* @brief Shared read and observation access for adapters that may outlive this wrapper.
*
* Retaining this handle keeps the collection storage alive. Mutations remain available only
* through ObservableVector, so the handle becomes a stable read-only snapshot once its owning
* wrapper is destroyed.
*/
class SharedHandle {
public:
SharedHandle() = default;
/** @return Read-only access to the retained collection. */
const std::vector<T>& get() const {
eeASSERT( mState );
return mState->values;
}
/** @return The retained value at @p index. No bounds checking is performed. */
const T& operator[]( std::size_t index ) const { return get()[index]; }
/** @return The number of retained values. */
std::size_t size() const { return mState ? mState->values.size() : 0; }
/** @return Whether this handle retains collection storage. */
explicit operator bool() const { return static_cast<bool>( mState ); }
/** @return A scoped connection observing later mutations of the owning ObservableVector. */
Connection observe( Callback callback ) const {
if ( !mState )
return {};
auto id = ++mState->nextId;
mState->observers.emplace_back( typename State::Observer{
std::make_shared<Callback>( std::move( callback ) ), 0, id } );
return Connection( mState, id );
}
private:
friend class ObservableVector<T>;
explicit SharedHandle( std::shared_ptr<State> state ) : mState( std::move( state ) ) {}
std::shared_ptr<State> mState;
};
/** @brief Creates an empty observable collection. */
ObservableVector() : mState( std::make_shared<State>() ) {}
/** @brief Creates an observable collection containing @p values without emitting a change. */
explicit ObservableVector( std::vector<T> values ) : mState( std::make_shared<State>() ) {
mState->values = std::move( values );
}
ObservableVector( const ObservableVector& ) = delete;
ObservableVector& operator=( const ObservableVector& ) = delete;
ObservableVector( ObservableVector&& ) noexcept = default;
ObservableVector& operator=( ObservableVector&& ) noexcept = default;
/** @return Read-only access to the complete collection. */
const std::vector<T>& get() const { return mState->values; }
/** @return The value at @p index. No bounds checking is performed. */
const T& operator[]( std::size_t index ) const { return mState->values[index]; }
/** @return The number of values in the collection. */
std::size_t size() const { return mState->values.size(); }
/** @return Whether the collection contains no values. */
bool empty() const { return mState->values.empty(); }
/** @brief Inserts @p value before @p index and emits paired Before/After notifications. */
void insert( std::size_t index, T value ) {
eeASSERT( index <= size() );
notify( ChangeType::Insert, Phase::Before, index, 1 );
mState->values.insert( mState->values.begin() + index, std::move( value ) );
notify( ChangeType::Insert, Phase::After, index, 1 );
}
/** @brief Appends @p value and emits paired Before/After insertion notifications. */
void pushBack( T value ) { insert( size(), std::move( value ) ); }
/** @brief Removes @p count values starting at @p index. A zero count is a no-op. */
void erase( std::size_t index, std::size_t count = 1 ) {
eeASSERT( index + count <= size() );
if ( count == 0 )
return;
notify( ChangeType::Remove, Phase::Before, index, count );
mState->values.erase( mState->values.begin() + index,
mState->values.begin() + index + count );
notify( ChangeType::Remove, Phase::After, index, count );
}
/**
* @brief Moves one value from @p from to @p to.
*
* @p to is the final index in the resulting collection. Moving to the same index is a no-op.
*/
void move( std::size_t from, std::size_t to ) {
eeASSERT( from < size() && to < size() );
if ( from == to )
return;
notify( ChangeType::Move, Phase::Before, from, 1, to );
T value = std::move( mState->values[from] );
mState->values.erase( mState->values.begin() + from );
mState->values.insert( mState->values.begin() + to, std::move( value ) );
notify( ChangeType::Move, Phase::After, from, 1, to );
}
/** @brief Replaces the value at @p index unless it already compares equal to @p value. */
void set( std::size_t index, T value ) {
eeASSERT( index < size() );
if ( mState->values[index] == value )
return;
notify( ChangeType::Change, Phase::Before, index, 1 );
mState->values[index] = std::move( value );
notify( ChangeType::Change, Phase::After, index, 1 );
}
/** @brief Replaces the entire collection and emits paired Reset notifications. */
void reset( std::vector<T> values ) {
notify( ChangeType::Reset, Phase::Before );
mState->values = std::move( values );
notify( ChangeType::Reset, Phase::After );
}
/**
* @brief Observes subsequent collection mutations.
* @return A scoped connection; destroying it disconnects the callback.
*
* Each non-empty mutation emits a Before notification followed by After. The callback is not
* invoked for the collection's current contents when it is registered.
*/
Connection observe( Callback callback ) {
auto id = ++mState->nextId;
mState->observers.emplace_back( typename State::Observer{
std::make_shared<Callback>( std::move( callback ) ), 0, id } );
return Connection( mState, id );
}
/** @return Shared read access that keeps collection storage alive independently of this object.
*/
SharedHandle sharedHandle() const { return SharedHandle( mState ); }
private:
void notify( ChangeType type, Phase phase, std::size_t index = 0, std::size_t count = 0,
std::size_t target = 0 ) {
mState->notify( { type, phase, index, count, target } );
}
std::shared_ptr<State> mState;
};
} // namespace EE
#endif
+1
View File
@@ -43,6 +43,7 @@
#include <eepp/graphics/renderer/rendererglshader.hpp>
#include <eepp/graphics/renderer/rendererhelper.hpp>
#include <eepp/graphics/rendermode.hpp>
#include <eepp/graphics/resource.hpp>
#include <eepp/graphics/resourcecatalog.hpp>
#include <eepp/graphics/resourcescope.hpp>
#include <eepp/graphics/richtext.hpp>
+1
View File
@@ -26,6 +26,7 @@
#include <eepp/scene/actions/tint.hpp>
#include <eepp/scene/actions/visible.hpp>
#include <eepp/scene/event.hpp>
#include <eepp/scene/eventconnection.hpp>
#include <eepp/scene/eventdispatcher.hpp>
#include <eepp/scene/keyevent.hpp>
#include <eepp/scene/mouseevent.hpp>
+3
View File
@@ -11,12 +11,14 @@
#include <eepp/system/cpu.hpp>
#include <eepp/system/directorypack.hpp>
#include <eepp/system/fileinfo.hpp>
#include <eepp/system/filemapped.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/functionstring.hpp>
#include <eepp/system/inifile.hpp>
#include <eepp/system/iostream.hpp>
#include <eepp/system/iostreamdeflate.hpp>
#include <eepp/system/iostreamfile.hpp>
#include <eepp/system/iostreamfilemapped.hpp>
#include <eepp/system/iostreaminflate.hpp>
#include <eepp/system/iostreammemory.hpp>
#include <eepp/system/iostreampak.hpp>
@@ -39,6 +41,7 @@
#include <eepp/system/scopedbuffer.hpp>
#include <eepp/system/scopedop.hpp>
#include <eepp/system/singleton.hpp>
#include <eepp/system/singletondeclarations.hpp>
#include <eepp/system/sys.hpp>
#include <eepp/system/thread.hpp>
#include <eepp/system/threadlocal.hpp>
+19 -5
View File
@@ -10,9 +10,11 @@
#include <eepp/ui/css/animationdefinition.hpp>
#include <eepp/ui/css/drawableimageparser.hpp>
#include <eepp/ui/css/elementdefinition.hpp>
#include <eepp/ui/css/idnamemap.hpp>
#include <eepp/ui/css/keyframesdefinition.hpp>
#include <eepp/ui/css/mediaquery.hpp>
#include <eepp/ui/css/propertydefinition.hpp>
#include <eepp/ui/css/propertyids.hpp>
#include <eepp/ui/css/propertyidset.hpp>
#include <eepp/ui/css/propertyspecification.hpp>
#include <eepp/ui/css/shorthanddefinition.hpp>
@@ -31,6 +33,14 @@
#include <eepp/ui/css/timingfunction.hpp>
#include <eepp/ui/css/transitiondefinition.hpp>
#include <eepp/ui/csslayouttypes.hpp>
#include <eepp/ui/databinding/uibindinggroup.hpp>
#include <eepp/ui/databinding/uicommand.hpp>
#include <eepp/ui/databinding/uidatabind.hpp>
#include <eepp/ui/databinding/uiobservedelivery.hpp>
#include <eepp/ui/databinding/uiproperty.hpp>
#include <eepp/ui/databinding/uivaluebinding.hpp>
#include <eepp/ui/databinding/uivalueconverter.hpp>
#include <eepp/ui/databinding/uivaluevalidation.hpp>
#include <eepp/ui/doc/documentview.hpp>
#include <eepp/ui/doc/foldrangeservice.hpp>
#include <eepp/ui/doc/foldrangetype.hpp>
@@ -48,9 +58,13 @@
#include <eepp/ui/doc/textrange.hpp>
#include <eepp/ui/doc/textundostack.hpp>
#include <eepp/ui/drawableresolver.hpp>
#include <eepp/ui/flexlayouter.hpp>
#include <eepp/ui/gridlayouter.hpp>
#include <eepp/ui/iconmanager.hpp>
#include <eepp/ui/inlinelayouter.hpp>
#include <eepp/ui/keyboardshortcut.hpp>
#include <eepp/ui/layoutinvalidation.hpp>
#include <eepp/ui/lineargradientdrawable.hpp>
#include <eepp/ui/models/csspropertiesmodel.hpp>
#include <eepp/ui/models/filesystemmodel.hpp>
#include <eepp/ui/models/itemlistmodel.hpp>
@@ -59,6 +73,7 @@
#include <eepp/ui/models/modelindex.hpp>
#include <eepp/ui/models/modelrole.hpp>
#include <eepp/ui/models/modelselection.hpp>
#include <eepp/ui/models/observablelistmodel.hpp>
#include <eepp/ui/models/persistentmodelindex.hpp>
#include <eepp/ui/models/sortingproxymodel.hpp>
#include <eepp/ui/models/stringmapmodel.hpp>
@@ -66,6 +81,7 @@
#include <eepp/ui/models/widgettreemodel.hpp>
#include <eepp/ui/mouseshortcut.hpp>
#include <eepp/ui/nonelayouter.hpp>
#include <eepp/ui/radialgradientdrawable.hpp>
#include <eepp/ui/splitdirection.hpp>
#include <eepp/ui/tablelayouter.hpp>
#include <eepp/ui/tools/htmlformatter.hpp>
@@ -77,6 +93,7 @@
#include <eepp/ui/tools/uidocfindreplace.hpp>
#include <eepp/ui/tools/uifontpickerdialog.hpp>
#include <eepp/ui/tools/uiimageviewer.hpp>
#include <eepp/ui/tools/uitabwidgetsplitter.hpp>
#include <eepp/ui/tools/uiwidgetinspector.hpp>
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/uibackgrounddrawable.hpp>
@@ -86,7 +103,6 @@
#include <eepp/ui/uicodeeditor.hpp>
#include <eepp/ui/uicombobox.hpp>
#include <eepp/ui/uiconsole.hpp>
#include <eepp/ui/uidatabind.hpp>
#include <eepp/ui/uidropdown.hpp>
#include <eepp/ui/uidropdownlist.hpp>
#include <eepp/ui/uidropdownmodellist.hpp>
@@ -100,6 +116,7 @@
#include <eepp/ui/uihtmlimage.hpp>
#include <eepp/ui/uihtmlinput.hpp>
#include <eepp/ui/uihtmllistitem.hpp>
#include <eepp/ui/uihtmlliststyle.hpp>
#include <eepp/ui/uihtmltable.hpp>
#include <eepp/ui/uihtmltextarea.hpp>
#include <eepp/ui/uihtmltextinput.hpp>
@@ -126,6 +143,7 @@
#include <eepp/ui/uimenuseparator.hpp>
#include <eepp/ui/uimenusubmenu.hpp>
#include <eepp/ui/uimessagebox.hpp>
#include <eepp/ui/uimodelcreator.hpp>
#include <eepp/ui/uimultimodelview.hpp>
#include <eepp/ui/uinode.hpp>
#include <eepp/ui/uinodedrawable.hpp>
@@ -133,7 +151,6 @@
#include <eepp/ui/uiplacementutils.hpp>
#include <eepp/ui/uipopupmenu.hpp>
#include <eepp/ui/uiprogressbar.hpp>
#include <eepp/ui/uiproperty.hpp>
#include <eepp/ui/uipushbutton.hpp>
#include <eepp/ui/uiradiobutton.hpp>
#include <eepp/ui/uirelativelayout.hpp>
@@ -172,9 +189,6 @@
#include <eepp/ui/uitooltip.hpp>
#include <eepp/ui/uitouchdraggablewidget.hpp>
#include <eepp/ui/uitreeview.hpp>
#include <eepp/ui/uivaluebinding.hpp>
#include <eepp/ui/uivalueconverter.hpp>
#include <eepp/ui/uivaluevalidation.hpp>
#include <eepp/ui/uiviewpager.hpp>
#include <eepp/ui/uiwebview.hpp>
#include <eepp/ui/uiwidget.hpp>
@@ -0,0 +1,372 @@
#ifndef EE_UI_UIBINDINGGROUP_HPP
#define EE_UI_UIBINDINGGROUP_HPP
#include <eepp/core/small_vector.hpp>
#include <eepp/ui/databinding/uidatabind.hpp>
#include <eepp/ui/databinding/uiproperty.hpp>
#include <eepp/ui/databinding/uivaluebinding.hpp>
#include <memory>
namespace EE { namespace UI {
/**
* @brief Owns heterogeneous bindings and exposes aggregate form state.
*
* Dirty state compares live values with explicit per-binding baselines. The group owns bindings,
* but never owns their widgets or model values. Disabled widgets are excluded from aggregate
* validation. clear() and destruction disconnect all bindings.
*
* @code
* UIBindingGroup form;
* form += bindValue( name, nameInput, requiredName );
* form += bindValue( port, portInput, validPort );
* auto canSave = computedValue( form.validValue(), form.dirtyValue(),
* []( bool valid, bool dirty ) { return valid && dirty; } );
* @endcode
*/
class UIBindingGroup {
private:
template <typename T> struct PropertyEntry;
public:
/** @brief Identifies one invalid binding and its current validation result. */
struct Error {
/** Insertion index of the invalid binding in this group. */
std::size_t index{ 0 };
/** Bound widget when one is still connected, otherwise nullptr. */
UIWidget* widget{ nullptr };
/** Validation state owned by the binding and valid until the group changes. */
const UIValueValidationResult* validation{ nullptr };
};
/** Inline-backed error collection sized for ordinary forms. */
using Errors = SmallVector<Error, 4>;
/** Inline-backed widget collection sized for ordinary forms. */
using Widgets = SmallVector<UIWidget*, 4>;
using Callback = std::function<void()>;
UIBindingGroup() = default;
UIBindingGroup( const UIBindingGroup& ) = delete;
UIBindingGroup& operator=( const UIBindingGroup& ) = delete;
UIBindingGroup( UIBindingGroup&& ) = delete;
UIBindingGroup& operator=( UIBindingGroup&& ) = delete;
~UIBindingGroup() = default;
/**
* @brief Adds and takes ownership of a typed binding.
* @return This group, allowing several bindings to be added in one expression.
*/
template <typename T> UIBindingGroup& hold( UIValueBinding<T>&& binding ) {
add( std::make_unique<ObservableEntry<T>>( std::move( binding ), this ) );
return *this;
}
template <typename T> UIBindingGroup& operator+=( UIValueBinding<T>&& binding ) {
return hold( std::move( binding ) );
}
/** @brief Adds and takes ownership of a legacy UIDataBind. */
template <typename T> UIBindingGroup& hold( std::unique_ptr<UIDataBind<T>> binding ) {
add( std::make_unique<RawEntry<T>>( std::move( binding ), this ) );
return *this;
}
template <typename T> UIBindingGroup& operator+=( std::unique_ptr<UIDataBind<T>> binding ) {
return hold( std::move( binding ) );
}
/**
* @brief Tracks a UIProperty without taking ownership of it.
*
* The entry expires safely if @p property is destroyed first. Its current value becomes the
* initial clean baseline.
*/
template <typename T> UIBindingGroup& hold( UIProperty<T>& property ) {
add( std::make_unique<PropertyEntry<T>>( property, this ) );
return *this;
}
/** @brief Convenience form of hold(UIProperty<T>&). */
template <typename T> UIBindingGroup& operator+=( UIProperty<T>& property ) {
return hold( property );
}
/**
* @return true when every binding attached to an enabled widget is valid.
*
* Disabled fields are intentionally ignored, which supports conditional form sections.
*/
bool isValid() const {
for ( const auto& entry : mEntries )
if ( !entry->isValid() )
return false;
return true;
}
/** @return true when at least one value differs from its current clean baseline. */
bool isDirty() const {
for ( const auto& entry : mEntries )
if ( entry->isDirty() )
return true;
return false;
}
/** @return Observable aggregate validity, suitable for computed values and commands. */
ObservableValue<bool>& validValue() { return mValid; }
/** @return Observable aggregate dirty state, suitable for computed values and commands. */
ObservableValue<bool>& dirtyValue() { return mDirty; }
/** @return Invalid enabled bindings in insertion order. */
Errors errors() const {
Errors result;
for ( std::size_t i = 0; i < mEntries.size(); ++i )
if ( !mEntries[i]->isValid() )
result.push_back( { i, mEntries[i]->widget(), &mEntries[i]->validation() } );
return result;
}
/**
* @return Connected widgets grouped by binding insertion order.
*
* The order within a legacy multi-widget UIDataBind or UIProperty is unspecified.
*/
Widgets widgets() const {
Widgets result;
for ( const auto& entry : mEntries )
entry->appendWidgets( result );
return result;
}
/** @return The first invalid enabled widget in insertion order, or nullptr. */
UIWidget* firstInvalidWidget() const {
for ( const auto& entry : mEntries )
if ( !entry->isValid() && entry->widget() )
return entry->widget();
return nullptr;
}
/** @brief Makes every binding's current value its new clean/reset baseline. */
void markClean() {
for ( auto& entry : mEntries )
entry->markClean();
notifyIfChanged();
}
/** @brief Restores every binding to the baseline recorded at insertion or markClean(). */
void reset() {
for ( auto& entry : mEntries )
entry->reset();
notifyIfChanged();
}
/** @brief Destroys all owned bindings and resets aggregate state. */
void clear() {
mEntries.clear();
notifyIfChanged();
}
/** @return The number of bindings owned by the group. */
std::size_t size() const { return mEntries.size(); }
/**
* @brief Replaces the callback invoked after a relevant group event.
*
* A non-empty callback is invoked once immediately, then after value, validation,
* enabled-state, baseline, or membership changes. Use validValue()/dirtyValue() when only
* aggregate transitions matter, since those observables suppress equal values.
*/
void onChange( Callback callback ) {
mCallback = std::move( callback );
if ( mCallback )
mCallback();
}
private:
struct Entry {
virtual ~Entry() = default;
virtual bool isValid() const = 0;
virtual bool isDirty() const = 0;
virtual UIWidget* widget() const = 0;
virtual void appendWidgets( Widgets& widgets ) const = 0;
virtual const UIValueValidationResult& validation() const = 0;
virtual void markClean() = 0;
virtual void reset() = 0;
ObservableValue<UIValueValidationResult>::Connection validationConnection;
EventConnectionList enabledConnections;
};
template <typename T> struct ObservableEntry : Entry {
ObservableEntry( UIValueBinding<T>&& binding, UIBindingGroup* group ) :
binding( std::move( binding ) ), baseline( this->binding.value() ) {
if ( auto validation = this->binding.validationState() )
this->validationConnection = validation->observe(
[group]( const UIValueValidationResult& ) { group->notifyIfChanged(); } );
valueConnection =
this->binding.observeValue( [group]( const T& ) { group->notifyIfChanged(); } );
if ( auto widget = this->binding.widget() )
this->enabledConnections += widget->connect(
Event::OnEnabledChange, [group]( const Event* ) { group->notifyIfChanged(); } );
}
bool isValid() const override {
auto widget = binding.widget();
return !widget || !widget->isEnabled() || binding.isValid();
}
bool isDirty() const override { return baseline != binding.value(); }
UIWidget* widget() const override { return binding.widget(); }
void appendWidgets( Widgets& widgets ) const override {
if ( auto boundWidget = widget() )
widgets.push_back( boundWidget );
}
const UIValueValidationResult& validation() const override {
static const UIValueValidationResult valid;
auto state = binding.validationState();
return state ? state->result() : valid;
}
void markClean() override { baseline = binding.value(); }
void reset() override {
if ( baseline )
binding.setValue( *baseline );
}
UIValueBinding<T> binding;
std::optional<T> baseline;
typename ObservableValue<T>::Connection valueConnection;
};
template <typename T> struct RawEntry : Entry {
RawEntry( std::unique_ptr<UIDataBind<T>> binding, UIBindingGroup* group ) :
binding( std::move( binding ) ), baseline( this->binding->get() ) {
this->validationConnection = this->binding->validationState().observe(
[group]( const UIValueValidationResult& ) { group->notifyIfChanged(); } );
auto previous = std::move( this->binding->onValueChangeCb );
this->binding->onValueChangeCb = [group,
previous = std::move( previous )]( const T& value ) {
if ( previous )
previous( value );
group->notifyIfChanged();
};
for ( auto widget : this->binding->getWidgets() )
this->enabledConnections += widget->connect(
Event::OnEnabledChange, [group]( const Event* ) { group->notifyIfChanged(); } );
}
bool isValid() const override {
if ( binding->isValid() )
return true;
if ( auto emitter = binding->getValidationEmitter() )
return !emitter->isEnabled();
for ( auto widget : binding->getWidgets() )
if ( widget && widget->isEnabled() )
return false;
return true;
}
bool isDirty() const override { return baseline != binding->get(); }
UIWidget* widget() const override {
if ( auto emitter = binding->getValidationEmitter(); emitter && emitter->isEnabled() )
return emitter;
for ( auto widget : binding->getWidgets() )
if ( widget && widget->isEnabled() )
return widget;
return nullptr;
}
void appendWidgets( Widgets& widgets ) const override {
widgets.insert( widgets.end(), binding->getWidgets().begin(),
binding->getWidgets().end() );
}
const UIValueValidationResult& validation() const override {
return binding->validationState().result();
}
void markClean() override { baseline = binding->get(); }
void reset() override { binding->set( baseline ); }
std::unique_ptr<UIDataBind<T>> binding;
T baseline;
};
template <typename T> struct PropertyEntry : Entry {
PropertyEntry( UIProperty<T>& property, UIBindingGroup* group ) :
handle( property.weakHandle() ),
baseline( handle.get() ),
currentValidation( handle.validation() ) {
this->validationConnection =
handle.observeValidation( [this, group]( const UIValueValidationResult& result ) {
currentValidation = result;
group->notifyIfChanged();
} );
valueConnection = handle.observe( [group]( const T& ) { group->notifyIfChanged(); } );
lifetimeConnection =
handle.observeLifetime( [group]( const bool& ) { group->notifyIfChanged(); } );
handle.forEachWidget( [this, group]( UIWidget* widget ) {
this->enabledConnections += widget->connect(
Event::OnEnabledChange, [group]( const Event* ) { group->notifyIfChanged(); } );
} );
}
bool isValid() const override {
if ( !handle )
return true;
if ( currentValidation.valid )
return true;
if ( auto emitter = handle.validationEmitter() )
return !emitter->isEnabled();
return handle.firstEnabledWidget() == nullptr;
}
bool isDirty() const override {
auto current = handle.get();
return baseline && current && *baseline != *current;
}
UIWidget* widget() const override {
if ( auto emitter = handle.validationEmitter(); emitter && emitter->isEnabled() )
return emitter;
return handle.firstEnabledWidget();
}
void appendWidgets( Widgets& widgets ) const override { handle.appendWidgets( widgets ); }
const UIValueValidationResult& validation() const override { return currentValidation; }
void markClean() override { baseline = handle.get(); }
void reset() override {
if ( baseline )
handle.set( *baseline );
}
typename UIProperty<T>::WeakHandle handle;
std::optional<T> baseline;
UIValueValidationResult currentValidation;
typename UIProperty<T>::Connection valueConnection;
ObservableValue<bool>::Connection lifetimeConnection;
};
void add( std::unique_ptr<Entry> entry ) {
mEntries.emplace_back( std::move( entry ) );
notifyIfChanged();
}
void notifyIfChanged() {
bool valid = true;
bool dirty = false;
for ( const auto& entry : mEntries ) {
if ( valid && !entry->isValid() )
valid = false;
if ( !dirty && entry->isDirty() )
dirty = true;
if ( !valid && dirty )
break;
}
if ( valid != mLastValid ) {
mLastValid = valid;
mValid = valid;
}
if ( dirty != mLastDirty ) {
mLastDirty = dirty;
mDirty = dirty;
}
if ( mCallback )
mCallback();
}
// Most forms contain only a few fields; keep their entry ownership entirely inline.
SmallVector<std::unique_ptr<Entry>, 4> mEntries;
Callback mCallback;
bool mLastValid{ true };
bool mLastDirty{ false };
ObservableValue<bool> mValid{ true };
ObservableValue<bool> mDirty{ false };
};
}} // namespace EE::UI
#endif
+304
View File
@@ -0,0 +1,304 @@
#ifndef EE_UI_UICOMMAND_HPP
#define EE_UI_UICOMMAND_HPP
#include <atomic>
#include <eepp/core/observablevalue.hpp>
#include <eepp/ui/keyboardshortcut.hpp>
#include <eepp/ui/uiwidget.hpp>
namespace EE { namespace UI {
template <typename Target> class UICommandShortcutBinding;
/**
* @brief One action shared by UI endpoints and keyboard shortcuts.
*
* @code
* auto save = bindCommand(
* [&] { saveDocument(); }, canSave, *saveButton, *uiScene,
* { KEY_S, KeyMod::getDefaultModifier() } );
* @endcode
*
* For a single button with no other representation, an ordinary onClick() callback remains the
* clearer choice.
*/
class UICommand {
private:
struct SourceConnectionBase {
virtual ~SourceConnectionBase() = default;
};
template <typename Connection> struct SourceConnection final : SourceConnectionBase {
explicit SourceConnection( Connection connection ) :
connection( std::move( connection ) ) {}
Connection connection;
};
struct State {
explicit State( std::function<void()> execute ) : execute( std::move( execute ) ) {}
bool tryExecute() {
if ( !enabled.get() || executing )
return false;
executing = true;
execute();
executing = false;
return true;
}
std::function<void()> execute;
ObservableValue<bool> enabled{ true };
// Enabled sources expose different scoped connection classes. Erasure happens once when
// constructing the command and has no execution- or notification-path cost.
std::unique_ptr<SourceConnectionBase> enabledSourceConnection;
bool executing{ false };
};
public:
/** @brief Creates an always-enabled command that invokes @p execute. */
explicit UICommand( std::function<void()> execute ) :
mState( std::make_shared<State>( std::move( execute ) ) ) {}
template <typename Source>
/**
* @brief Creates a command whose enabled state follows @p enabled.
*
* The source must expose get() and observe() for bool values and must outlive the command if
* further enabled-state updates are expected.
*/
UICommand( std::function<void()> execute, Source& enabled ) :
mState( std::make_shared<State>( std::move( execute ) ) ) {
mState->enabled = enabled.get();
std::weak_ptr<State> weakState = mState;
auto enabledConnection = enabled.observe( [weakState]( const bool& value ) {
if ( auto state = weakState.lock() )
state->enabled = value;
} );
mState->enabledSourceConnection =
std::make_unique<SourceConnection<decltype( enabledConnection )>>(
std::move( enabledConnection ) );
}
UICommand( const UICommand& ) = delete;
UICommand& operator=( const UICommand& ) = delete;
UICommand( UICommand&& ) noexcept = default;
UICommand& operator=( UICommand&& ) noexcept = default;
/**
* @brief Attempts to run the action.
* @return true when it ran, or false when disabled or already executing.
*/
bool execute() {
auto state = mState;
return state && state->tryExecute();
}
/** @return Observable enabled state shared by every endpoint bound to this command. */
ObservableValue<bool>& enabled() { return mState->enabled; }
private:
std::shared_ptr<State> mState;
friend class UICommandBinding;
template <typename Target> friend class UICommandShortcutBinding;
};
/** @brief Scoped synchronization of a command with one widget endpoint. */
class UICommandBinding {
public:
UICommandBinding() = default;
/**
* @brief Binds @p widget clicks and enabled state to @p command.
*
* Keep this object alive while the endpoint should remain active. The widget is not retained.
*/
UICommandBinding( UICommand& command, UIWidget& widget ) {
auto commandState = command.mState;
auto state = std::make_shared<BindingState>();
state->widget = &widget;
widget.setEnabled( commandState->enabled.get() );
std::weak_ptr<UICommand::State> weakCommand = commandState;
std::weak_ptr<BindingState> weakBinding = state;
state->connections += widget.connect( Event::MouseClick, [weakCommand]( const Event* ) {
if ( auto command = weakCommand.lock() )
command->tryExecute();
} );
state->connections += widget.connect( Event::OnClose, [weakBinding]( const Event* ) {
if ( auto binding = weakBinding.lock() ) {
binding->widget = nullptr;
binding->enabledConnection.disconnect();
}
} );
state->enabledConnection =
commandState->enabled.observe( [weakBinding]( const bool& enabled ) {
if ( auto binding = weakBinding.lock(); binding && binding->widget )
binding->widget->setEnabled( enabled );
} );
mState = std::move( state );
}
UICommandBinding( const UICommandBinding& ) = delete;
UICommandBinding& operator=( const UICommandBinding& ) = delete;
UICommandBinding( UICommandBinding&& ) noexcept = default;
UICommandBinding& operator=( UICommandBinding&& ) noexcept = default;
private:
struct BindingState {
UIWidget* widget{ nullptr };
EventConnectionList connections;
ObservableValue<bool>::Connection enabledConnection;
};
std::shared_ptr<BindingState> mState;
};
/** @return A scoped binding between an existing command and one clickable widget. */
inline UICommandBinding bindCommand( UICommand& command, UIWidget& widget ) {
return UICommandBinding( command, widget );
}
/**
* @brief Scoped binding of a command to a shortcut consumed by UISceneNode or UIWindow.
*
* The previous command mapped to the shortcut is restored when this binding disconnects. The
* generated command registration and shortcut are removed safely when the target closes first.
*/
template <typename Target> class UICommandShortcutBinding {
public:
UICommandShortcutBinding() = default;
/**
* @brief Registers @p shortcut on @p target as another endpoint for @p command.
*
* If the shortcut was already mapped, that mapping is restored when this binding disconnects.
*/
UICommandShortcutBinding( UICommand& command, Target& target,
const KeyBindings::Shortcut& shortcut ) {
auto state = std::make_shared<State>();
state->target = &target;
state->shortcut = shortcut;
state->previousCommand = target.getKeyBindings().getCommandFromKeyBind( shortcut );
state->commandName = "eepp-ui-command-" + String::toString( ++sNextCommandId );
std::weak_ptr<UICommand::State> weakCommand = command.mState;
target.setKeyBindingCommand( state->commandName, [weakCommand] {
if ( auto command = weakCommand.lock() )
command->tryExecute();
} );
target.getKeyBindings().addKeybind( shortcut, state->commandName );
std::weak_ptr<State> weakState = state;
state->targetConnection = target.connect( Event::OnClose, [weakState]( const Event* ) {
if ( auto state = weakState.lock() )
state->target = nullptr;
} );
mState = std::move( state );
}
~UICommandShortcutBinding() { disconnect(); }
UICommandShortcutBinding( const UICommandShortcutBinding& ) = delete;
UICommandShortcutBinding& operator=( const UICommandShortcutBinding& ) = delete;
UICommandShortcutBinding( UICommandShortcutBinding&& other ) noexcept :
mState( std::move( other.mState ) ) {}
UICommandShortcutBinding& operator=( UICommandShortcutBinding&& other ) noexcept {
if ( this != &other ) {
disconnect();
mState = std::move( other.mState );
}
return *this;
}
/** @brief Removes this shortcut endpoint and restores any previous mapping. */
void disconnect() {
if ( !mState )
return;
if ( mState->target ) {
auto& keyBindings = mState->target->getKeyBindings();
if ( keyBindings.getCommandFromKeyBind( mState->shortcut ) == mState->commandName ) {
keyBindings.removeKeybind( mState->shortcut );
if ( !mState->previousCommand.empty() )
keyBindings.addKeybind( mState->shortcut, mState->previousCommand );
}
mState->target->removeKeyBindingCommand( mState->commandName );
}
mState.reset();
}
explicit operator bool() const { return mState && mState->target; }
private:
struct State {
Target* target{ nullptr };
KeyBindings::Shortcut shortcut;
std::string commandName;
std::string previousCommand;
EventConnection targetConnection;
};
inline static std::atomic<Uint64> sNextCommandId{ 0 };
std::shared_ptr<State> mState;
};
template <typename Target>
/** @return A scoped binding between an existing command and a keyboard shortcut. */
UICommandShortcutBinding<Target> bindCommand( UICommand& command, Target& target,
const KeyBindings::Shortcut& shortcut ) {
return UICommandShortcutBinding<Target>( command, target, shortcut );
}
/**
* @brief Owns a command together with its primary widget and shortcut bindings.
*
* This is the concise form for the common case where an action is exposed by one clickable widget
* and one default shortcut. Keep the returned object alive for as long as both bindings are needed.
*/
template <typename ShortcutTarget> class UICommandBindingSet {
public:
/** @brief Creates an always-enabled command with widget and shortcut endpoints. */
UICommandBindingSet( std::function<void()> execute, UIWidget& widget,
ShortcutTarget& shortcutTarget, const KeyBindings::Shortcut& shortcut ) :
mCommand( std::move( execute ) ),
mWidgetBinding( mCommand, widget ),
mShortcutBinding( mCommand, shortcutTarget, shortcut ) {}
template <typename Source>
/** @brief Creates a command following @p enabled with widget and shortcut endpoints. */
UICommandBindingSet( std::function<void()> execute, Source& enabled, UIWidget& widget,
ShortcutTarget& shortcutTarget, const KeyBindings::Shortcut& shortcut ) :
mCommand( std::move( execute ), enabled ),
mWidgetBinding( mCommand, widget ),
mShortcutBinding( mCommand, shortcutTarget, shortcut ) {}
UICommandBindingSet( const UICommandBindingSet& ) = delete;
UICommandBindingSet& operator=( const UICommandBindingSet& ) = delete;
UICommandBindingSet( UICommandBindingSet&& ) noexcept = default;
UICommandBindingSet& operator=( UICommandBindingSet&& ) noexcept = default;
/** @return The owned command for explicit execution or additional endpoint bindings. */
UICommand& command() { return mCommand; }
/** @return The owned command. */
const UICommand& command() const { return mCommand; }
private:
UICommand mCommand;
UICommandBinding mWidgetBinding;
UICommandShortcutBinding<ShortcutTarget> mShortcutBinding;
};
template <typename ShortcutTarget>
/**
* @brief Creates an always-enabled command with a primary widget and shortcut.
* @return A scoped object that owns the command and both endpoint bindings.
*/
UICommandBindingSet<ShortcutTarget> bindCommand( std::function<void()> execute, UIWidget& widget,
ShortcutTarget& shortcutTarget,
const KeyBindings::Shortcut& shortcut ) {
return UICommandBindingSet<ShortcutTarget>( std::move( execute ), widget, shortcutTarget,
shortcut );
}
template <typename Source, typename ShortcutTarget>
/**
* @brief Creates a conditionally enabled command with a primary widget and shortcut.
* @return A scoped object that owns the command and both endpoint bindings.
*/
UICommandBindingSet<ShortcutTarget> bindCommand( std::function<void()> execute, Source& enabled,
UIWidget& widget, ShortcutTarget& shortcutTarget,
const KeyBindings::Shortcut& shortcut ) {
return UICommandBindingSet<ShortcutTarget>( std::move( execute ), enabled, widget,
shortcutTarget, shortcut );
}
}} // namespace EE::UI
#endif
@@ -3,7 +3,7 @@
#include <eepp/core/containers.hpp>
#include <eepp/core/debug.hpp>
#include <eepp/ui/uivalueconverter.hpp>
#include <eepp/ui/databinding/uivalueconverter.hpp>
#include <eepp/ui/uiwidget.hpp>
#include <memory>
#include <variant>
@@ -21,9 +21,10 @@ namespace EE { namespace UI {
* decides whether widget input is acceptable. Values passed to set() are authoritative model state
* and are formatted through fromValue().
*
* @warning The external object is not owned. It must outlive the UIDataBind, or reset() must be
* called before that object is destroyed. UIProperty is the owning alternative when the value
* should have the same lifetime as its binding.
* Raw-pointer bindings do not own their value. The external object must outlive the UIDataBind and
* every synchronous callback delivery, or reset() must be called before it is destroyed. The
* shared_ptr overload retains the value through binding lifetime and callback delivery without
* copying T. UIProperty uses that retained form automatically.
*
* Widgets are also observed without ownership: EventConnection handles remove listeners when the
* binding dies, while the widget-level Event::OnClose notification removes widgets that die before
@@ -43,11 +44,47 @@ namespace EE { namespace UI {
*/
template <typename T> class UIDataBind {
public:
using ValueType = T;
using Converter = UIValueConverter<T>;
using Callback = typename ObservableValue<T>::Callback;
using Connection = typename ObservableValue<Uint64>::Connection;
/**
* @brief Assignment-compatible callback storage retained without copying its callable target.
*
* Allocation, when required, happens when the callback is assigned. Notification only copies
* the shared handle, keeping self-destruction safe without allocating in the delivery path.
*/
class CallbackSlot {
public:
CallbackSlot() = default;
CallbackSlot( const CallbackSlot& ) = default;
CallbackSlot( CallbackSlot&& ) noexcept = default;
CallbackSlot& operator=( const CallbackSlot& ) = default;
CallbackSlot& operator=( CallbackSlot&& ) noexcept = default;
CallbackSlot& operator=( Callback callback ) {
mCallback = callback ? std::make_shared<Callback>( std::move( callback ) ) : nullptr;
return *this;
}
explicit operator bool() const { return static_cast<bool>( mCallback ); }
void operator()( const T& value ) const {
if ( mCallback )
( *mCallback )( value );
}
std::shared_ptr<Callback> retain() const { return mCallback; }
private:
std::shared_ptr<Callback> mCallback;
};
// Compatibility helpers keep existing UIDataBind call sites source-compatible while the
// conversion policy itself remains independent from this binding type.
static Converter converterDefault() { return Converter::converterDefault(); }
static Converter converterString() { return Converter::converterString(); }
static Converter converterBool() { return Converter::converterBool(); }
@@ -68,6 +105,26 @@ template <typename T> class UIDataBind {
new UIDataBind<T>( t, widget, converter, valueKey, eventType ) );
}
/** @brief Creates a binding that retains @p value through callback delivery. */
static std::unique_ptr<UIDataBind<T>>
New( std::shared_ptr<T> value, const UnorderedSet<UIWidget*>& widgets,
const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
return std::unique_ptr<UIDataBind<T>>(
new UIDataBind<T>( std::move( value ), widgets, converter, valueKey, eventType ) );
}
/** @brief Creates a binding that retains @p value through callback delivery. */
static std::unique_ptr<UIDataBind<T>>
New( std::shared_ptr<T> value, UIWidget* widget,
const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
return std::unique_ptr<UIDataBind<T>>(
new UIDataBind<T>( std::move( value ), widget, converter, valueKey, eventType ) );
}
UIDataBind() = default;
UIDataBind( const UIDataBind& ) = delete;
UIDataBind& operator=( const UIDataBind& ) = delete;
@@ -84,26 +141,87 @@ template <typename T> class UIDataBind {
UIDataBind( T* t, UIWidget* widget, const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
init( t, { widget }, converter, valueKey, eventType );
init( t, widget, converter, valueKey, eventType );
}
UIDataBind( std::shared_ptr<T> value, const UnorderedSet<UIWidget*>& widgets,
const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
init( std::move( value ), widgets, converter, valueKey, eventType );
}
UIDataBind( std::shared_ptr<T> value, UIWidget* widget,
const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
init( std::move( value ), widget, converter, valueKey, eventType );
}
void init( T* t, UIWidget* widget, const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
eeASSERT( widget != nullptr );
prepareInitialization( t, converter, valueKey, eventType );
widgets.insert( widget );
bindListeners( widget );
finishInitialization();
}
void init( T* t, const UnorderedSet<UIWidget*>& widgets,
const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
eeASSERT( t != nullptr );
reset();
data = t;
this->widgets = widgets;
this->property = StyleSheetSpecification::instance()->getProperty( valueKey );
this->converter = converter;
this->eventType = eventType;
prepareInitialization( t, converter, valueKey, eventType );
// Insert explicitly instead of assigning the unordered_dense set. Besides avoiding a full
// table copy, this sidesteps GCC's incorrect -Warray-bounds diagnosis in unordered_dense's
// vector copy assignment.
this->widgets.reserve( widgets.size() );
for ( auto widget : widgets ) {
eeASSERT( widget != nullptr );
this->widgets.insert( widget );
bindListeners( widget );
}
set( *data );
dataInitialized = true;
finishInitialization();
}
/** @brief Reinitializes the binding while retaining @p value. */
void init( std::shared_ptr<T> value, const UnorderedSet<UIWidget*>& widgets,
const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
eeASSERT( value );
T* data = value.get();
init( data, widgets, converter, valueKey, eventType );
retainedOwner = std::move( value );
}
/** @brief Reinitializes the binding for one widget while retaining @p value. */
void init( std::shared_ptr<T> value, UIWidget* widget,
const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
eeASSERT( widget != nullptr );
eeASSERT( value );
T* data = value.get();
init( data, widget, converter, valueKey, eventType );
retainedOwner = std::move( value );
}
/**
* @brief Reinitializes the binding with externally retained storage.
*
* @p owner must keep @p value alive. This supports values embedded in a larger shared state
* without constructing an aliasing shared_ptr or allocating a separate value control block.
*/
template <typename Widgets>
void initRetained( T* value, std::shared_ptr<void> owner, Widgets&& widgets,
const Converter& converter = Converter::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) {
eeASSERT( owner );
init( value, std::forward<Widgets>( widgets ), converter, valueKey, eventType );
retainedOwner = std::move( owner );
}
/** Propagates the authoritative model value and reports formatting failures. */
@@ -117,6 +235,30 @@ template <typename T> class UIDataBind {
return *data;
}
/**
* @brief Observes later changes made through this binding.
* @return A scoped connection, or an empty connection when the binding is not initialized.
*
* Direct writes through the external pointer cannot be observed; use set() for model-originated
* changes that must be published. Notifications publish an integer revision internally, so T is
* never copied for observer delivery. Shared bindings retain their value in each observer
* adapter; raw bindings require the external value to survive the complete callback sequence.
*/
Connection observe( Callback callback ) {
if ( !isInitialized() )
return {};
if ( !changeSignal )
changeSignal = std::make_shared<ObservableValue<Uint64>>( 0 );
auto owner = retainedOwner;
T* observedData = data;
return changeSignal->observe( [callback = std::move( callback ), owner = std::move( owner ),
observedData]( const Uint64& ) {
// The captured owner exists solely to retain the storage containing observedData.
(void)owner;
callback( *observedData );
} );
}
/** @return True when the binding has a valid external value, property, and converter. */
bool isInitialized() const {
return data != nullptr && property != nullptr && converter.toValue && converter.fromValue;
@@ -133,6 +275,9 @@ template <typename T> class UIDataBind {
converter = Converter();
validation.clear();
validationEmitter = nullptr;
changeSignal.reset();
retainedOwner.reset();
nextNotificationRevision = 1;
inSetValue = false;
dataInitialized = false;
property = nullptr;
@@ -178,12 +323,19 @@ template <typename T> class UIDataBind {
const PropertyDefinition* getPropertyDefinition() const { return property; }
std::function<void( const T& newVal )> onValueChangeCb;
CallbackSlot onValueChangeCb;
const UnorderedSet<UIWidget*>& getWidgets() const { return widgets; }
/**
* @return The widget that produced the current input error, or nullptr for valid state or a
* model-to-widget formatting error.
*/
UIWidget* getValidationEmitter() const { return validationEmitter; }
/** @return Observable converter error state for this binding. */
UIValueValidationState& validationState() { return validation; }
const UIValueValidationState& validationState() const { return validation; }
bool isValid() const { return validation.isValid(); }
@@ -201,9 +353,8 @@ template <typename T> class UIDataBind {
*data = std::forward<U>( t );
auto result = setValueChange();
inSetValue = false;
if ( onValueChangeCb )
onValueChangeCb( *data );
setValidationResult( result );
notifyValueChange();
return result;
}
@@ -217,6 +368,29 @@ template <typename T> class UIDataBind {
UIValueValidationState validation;
UIWidget* validationEmitter{ nullptr };
Event::EventType eventType{ Event::OnValueChange };
// Observation publishes a revision instead of cloning T. Observer adapters read from retained
// storage, or from the caller-owned pointer under the raw binding's lifetime contract.
std::shared_ptr<ObservableValue<Uint64>> changeSignal;
// Ownership and access are intentionally separate. A type-erased owner can retain either a T
// allocated directly or a T embedded in a larger shared state without an aliasing shared_ptr.
std::shared_ptr<void> retainedOwner;
Uint64 nextNotificationRevision{ 1 };
void prepareInitialization( T* value, const Converter& valueConverter,
const std::string& valueKey,
const Event::EventType& valueEventType ) {
eeASSERT( value != nullptr );
reset();
data = value;
property = StyleSheetSpecification::instance()->getProperty( valueKey );
converter = valueConverter;
eventType = valueEventType;
}
void finishInitialization() {
set( *data );
dataInitialized = true;
}
void bindListeners( UIWidget* widget ) {
auto& widgetConnections = connections[widget];
@@ -274,8 +448,35 @@ template <typename T> class UIDataBind {
inSetValue = false;
validationEmitter = nullptr;
validation.clear();
if ( onValueChangeCb )
onValueChangeCb( *data );
notifyValueChange();
}
void notifyValueChange() {
auto observed = changeSignal;
auto callback = onValueChangeCb.retain();
T* observedData = data;
if ( !observed ) {
if ( callback ) {
auto owner = retainedOwner;
( *callback )( *observedData );
(void)owner;
}
return;
}
// ObservableValue suppresses equal assignments. A monotonically increasing revision turns
// each binding change into a distinct signal without copying the bound T into the signal.
const Uint64 revision = nextNotificationRevision++;
if ( !callback ) {
observed->set( revision );
return;
}
auto owner = retainedOwner;
// An observer may destroy this binding. The retained callback and value handles survive the
// observable notification without allocation. Raw bindings instead require the caller to
// keep their external value alive during the complete delivery.
observed->set( revision );
(void)owner;
( *callback )( *observedData );
}
UIValueValidationResult setValueChange() {
@@ -0,0 +1,140 @@
#ifndef EE_UI_UIOBSERVEDELIVERY_HPP
#define EE_UI_UIOBSERVEDELIVERY_HPP
#include <eepp/core/observablevalue.hpp>
#include <eepp/scene/node.hpp>
#include <eepp/ui/uiwidget.hpp>
#include <mutex>
namespace EE { namespace UI {
/**
* @brief Scoped non-blocking delivery of observable changes to the UI thread.
*
* The scheduler must outlive this connection and is normally the owning UISceneNode. The endpoint
* widget is not retained; queued work becomes a no-op after it closes. Source mutation remains the
* producer's synchronization responsibility because ObservableValue itself is single-threaded.
* In particular, construct and disconnect this observation only while the producer is stopped or
* otherwise synchronized; observer registration and removal must not race source mutation.
*/
template <typename T> class UIThreadObservation {
public:
using Callback = std::function<void( UIWidget&, const T& )>;
UIThreadObservation() = default;
UIThreadObservation( const UIThreadObservation& ) = delete;
UIThreadObservation& operator=( const UIThreadObservation& ) = delete;
UIThreadObservation( UIThreadObservation&& ) noexcept = default;
UIThreadObservation& operator=( UIThreadObservation&& ) noexcept = default;
/**
* @brief Observes @p source and queues @p callback on @p scheduler's UI thread.
*
* Delivery preserves source notification order. The callback receives the endpoint only while
* it remains alive. Keep the returned observation alive and ensure the scheduler outlives it.
*/
template <typename Source>
UIThreadObservation( Source& source, Node& scheduler, UIWidget& endpoint, Callback callback ) {
auto state = std::make_shared<State>();
state->endpoint = &endpoint;
state->callback = std::move( callback );
std::weak_ptr<State> weakState = state;
state->endpointConnection = endpoint.connect( Event::OnClose, [weakState]( const Event* ) {
if ( auto state = weakState.lock() ) {
std::lock_guard<std::mutex> lock( state->mutex );
state->endpoint = nullptr;
}
} );
auto sourceConnection =
source.observe( [weakState, scheduler = &scheduler]( const T& value ) {
if ( auto state = weakState.lock() ) {
std::lock_guard<std::mutex> lock( state->mutex );
if ( !state->endpoint )
return;
} else {
return;
}
// Runnable uses SmallFunction<48>, so common small values travel inline with no
// per-delivery allocation. Large values use the scheduler's existing heap fallback.
scheduler->ensureMainThread( [weakState, delivery = T( value )] {
if ( auto state = weakState.lock() ) {
UIWidget* endpoint = nullptr;
{
std::lock_guard<std::mutex> lock( state->mutex );
endpoint = state->endpoint;
}
// Delivery runs on the UI thread, so the endpoint cannot close between this
// check and the callback except from within the callback itself.
if ( endpoint )
state->callback( *endpoint, delivery );
}
} );
} );
state->sourceConnection = std::make_unique<SourceConnection<decltype( sourceConnection )>>(
std::move( sourceConnection ) );
mState = std::move( state );
}
/**
* @brief Stops future delivery and invalidates already queued callbacks.
*
* Synchronize with the source producer before calling this; see the class thread-safety notes.
*/
void disconnect() {
if ( mState ) {
mState->sourceConnection->disconnect();
std::lock_guard<std::mutex> lock( mState->mutex );
mState->endpoint = nullptr;
}
mState.reset();
}
/** @return Whether the source is connected and the endpoint remains alive. */
explicit operator bool() const {
if ( !mState || !mState->sourceConnection || !mState->sourceConnection->connected() )
return false;
std::lock_guard<std::mutex> lock( mState->mutex );
return mState->endpoint != nullptr;
}
private:
struct SourceConnectionBase {
virtual ~SourceConnectionBase() = default;
virtual void disconnect() = 0;
virtual bool connected() const = 0;
};
template <typename Connection> struct SourceConnection final : SourceConnectionBase {
explicit SourceConnection( Connection connection ) :
connection( std::move( connection ) ) {}
void disconnect() override { connection.disconnect(); }
bool connected() const override { return static_cast<bool>( connection ); }
Connection connection;
};
struct State {
mutable std::mutex mutex;
UIWidget* endpoint{ nullptr };
Callback callback;
EventConnection endpointConnection;
// Source types expose different scoped connection classes. Type erasure happens once when
// constructing the observation and adds no work or allocation to value delivery.
std::unique_ptr<SourceConnectionBase> sourceConnection;
};
std::shared_ptr<State> mState;
};
template <typename Source, typename Callback>
/**
* @brief Creates a scoped UI-thread observation while deducing the source value type.
* @see UIThreadObservation
*/
auto observeOnUIThread( Source& source, Node& scheduler, UIWidget& endpoint, Callback&& callback ) {
using T = typename Source::ValueType;
return UIThreadObservation<T>( source, scheduler, endpoint,
std::forward<Callback>( callback ) );
}
}} // namespace EE::UI
#endif
+367
View File
@@ -0,0 +1,367 @@
#ifndef EE_UI_UIPROPERTY_HPP
#define EE_UI_UIPROPERTY_HPP
#include <eepp/ui/databinding/uidatabind.hpp>
#include <type_traits>
namespace EE { namespace UI {
/**
* @brief Owns a value and exposes it as a UIDataBind-backed widget property.
*
* UIProperty is the owning counterpart to UIDataBind: the synchronized value is stored inside the
* property, so callers only need to ensure the UIProperty itself remains alive while using it.
* Assignments propagate to connected widgets, and widget-originated changes update value().
* Connections are removed automatically when either the UIProperty or a connected widget dies.
*
* The class is non-copyable and non-movable because its UIDataBind stores the address of mValue and
* installs callbacks that capture the binding's address.
*
* Use UIProperty for concise UI-local state when the value and its widgets naturally share a
* lifetime. It avoids declaring a separate model value and binding, and owns its UIDataBind
* directly. A custom UIValueConverter can provide presentation-specific parsing and formatting.
*
* UIProperty also implements the common observable-source interface (ValueType, get(), and
* observe()), so it can directly feed ComputedValue and UICommand. UIBindingGroup can track a
* property with `form += property`, including its validation, dirty state, and connected widgets.
*
* @code
* UIProperty<double> celsius( 0.0, celsiusInput );
* UIProperty<double> fahrenheit( 32.0, fahrenheitInput );
* celsius.changed( [&fahrenheit]( double value ) {
* fahrenheit = value * 9.0 / 5.0 + 32.0;
* } );
* @endcode
*/
template <typename T> class UIProperty {
private:
struct LifetimeState {
LifetimeState( UIProperty<T>* property, T value ) :
value( std::move( value ) ), property( property ) {}
T value;
UIProperty<T>* property{ nullptr };
// Most UIProperty instances are not held by UIBindingGroup. Allocate the lifetime
// observable only when a consumer actually subscribes to destruction.
std::unique_ptr<ObservableValue<bool>> alive;
};
public:
using ValueType = T;
using Callback = typename UIDataBind<T>::Callback;
using Connection = typename UIDataBind<T>::Connection;
using ValidationConnection = typename UIValueValidationState::Connection;
/**
* @brief Lifetime-safe, non-owning access used by containers such as UIBindingGroup.
*
* Every operation becomes a harmless no-op or empty result after the UIProperty is destroyed.
* Like UIProperty itself, this handle is restricted to the widgets' owning UI thread.
*/
class WeakHandle {
public:
WeakHandle() = default;
/** @return A copy of the value, or std::nullopt after expiration or binding reset. */
std::optional<T> get() const {
auto state = mState.lock();
return state && state->property && state->property->databind().isInitialized()
? std::optional<T>( state->property->get() )
: std::nullopt;
}
/** @return true when the live property was assigned @p value. */
bool set( const T& value ) const {
auto state = mState.lock();
if ( !state || !state->property || !state->property->databind().isInitialized() )
return false;
*state->property = value;
return true;
}
/** @return A scoped value observer connection, or an empty connection after expiration. */
Connection observe( Callback callback ) const {
auto state = mState.lock();
return state && state->property && state->property->databind().isInitialized()
? state->property->observe( std::move( callback ) )
: Connection{};
}
/** @return A scoped connection notified when the property is about to expire. */
ObservableValue<bool>::Connection
observeLifetime( ObservableValue<bool>::Callback callback ) const {
auto state = mState.lock();
if ( !state )
return {};
if ( !state->alive )
state->alive = std::make_unique<ObservableValue<bool>>( true );
return state->alive->observe( std::move( callback ) );
}
/** @return A scoped validation observer, or an empty connection after expiration. */
ValidationConnection observeValidation( UIValueValidationState::Callback callback ) const {
auto state = mState.lock();
return state && state->property && state->property->databind().isInitialized()
? state->property->databind().validationState().observe(
std::move( callback ) )
: ValidationConnection{};
}
/** @return Current validation, or success after expiration. */
UIValueValidationResult validation() const {
auto state = mState.lock();
return state && state->property && state->property->databind().isInitialized()
? state->property->validationState().result()
: UIValueValidationResult::success();
}
/** @return The widget that produced the current input error, or nullptr. */
UIWidget* validationEmitter() const {
auto state = mState.lock();
return state && state->property && state->property->databind().isInitialized()
? state->property->databind().getValidationEmitter()
: nullptr;
}
/** @return All currently connected widgets, or an empty vector after expiration. */
std::vector<UIWidget*> widgets() const {
auto state = mState.lock();
if ( !state || !state->property || !state->property->databind().isInitialized() )
return {};
const auto& widgets = state->property->databind().getWidgets();
return { widgets.begin(), widgets.end() };
}
/** Appends connected widgets without creating an intermediate collection. */
template <typename Container> void appendWidgets( Container& destination ) const {
auto state = mState.lock();
if ( !state || !state->property || !state->property->databind().isInitialized() )
return;
const auto& widgets = state->property->databind().getWidgets();
destination.insert( destination.end(), widgets.begin(), widgets.end() );
}
/** Invokes @p callback for every connected widget without allocating a collection. */
template <typename WidgetCallback> void forEachWidget( WidgetCallback&& callback ) const {
auto state = mState.lock();
if ( !state || !state->property || !state->property->databind().isInitialized() )
return;
for ( auto widget : state->property->databind().getWidgets() )
callback( widget );
}
/** @return The first enabled connected widget, or nullptr. */
UIWidget* firstEnabledWidget() const {
auto state = mState.lock();
if ( !state || !state->property || !state->property->databind().isInitialized() )
return nullptr;
for ( auto widget : state->property->databind().getWidgets() )
if ( widget && widget->isEnabled() )
return widget;
return nullptr;
}
explicit operator bool() const {
auto state = mState.lock();
return state && state->property && state->property->databind().isInitialized();
}
private:
friend class UIProperty<T>;
explicit WeakHandle( const std::shared_ptr<LifetimeState>& state ) : mState( state ) {}
std::weak_ptr<LifetimeState> mState;
};
UIProperty( const UIProperty& ) = delete;
UIProperty& operator=( const UIProperty& ) = delete;
UIProperty( UIProperty&& ) = delete;
UIProperty& operator=( UIProperty&& ) = delete;
~UIProperty() {
mLifetime->property = nullptr;
if ( mLifetime->alive )
*mLifetime->alive = false;
}
UIProperty( T defaultValue, UIWidget* widget,
const typename EE::UI::UIDataBind<T>::Converter& converter =
EE::UI::UIDataBind<T>::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) :
mLifetime( std::make_shared<LifetimeState>( this, std::move( defaultValue ) ) ) {
initializeBinding( widget, converter, valueKey, eventType );
}
UIProperty( T defaultValue, const UnorderedSet<UIWidget*>& widgets = {},
const typename EE::UI::UIDataBind<T>::Converter& converter =
EE::UI::UIDataBind<T>::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) :
mLifetime( std::make_shared<LifetimeState>( this, std::move( defaultValue ) ) ) {
initializeBinding( widgets, converter, valueKey, eventType );
}
UIProperty( const UnorderedSet<UIWidget*>& widgets = {},
const typename EE::UI::UIDataBind<T>::Converter& converter =
EE::UI::UIDataBind<T>::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) :
mLifetime( std::make_shared<LifetimeState>( this, T{} ) ) {
initializeBinding( widgets, converter, valueKey, eventType );
}
UIProperty( UIWidget* widget,
const typename EE::UI::UIDataBind<T>::Converter& converter =
EE::UI::UIDataBind<T>::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) :
mLifetime( std::make_shared<LifetimeState>( this, T{} ) ) {
initializeBinding( widget, converter, valueKey, eventType );
}
UIProperty& operator=( const T& newVal ) {
mBindedData.set( newVal );
return *this;
}
UIProperty& operator=( T&& newVal ) noexcept {
mBindedData.set( std::move( newVal ) );
return *this;
}
/** @name Value mutation
* Compound assignment propagates through the binding like assignment. Arithmetic properties
* support the conventional numeric mutations; std::string and String properties support
* concatenation.
* @{ */
template <typename U = T,
std::enable_if_t<(std::is_arithmetic_v<U> && !std::is_same_v<U, bool>) ||
std::is_same_v<U, std::string> || std::is_same_v<U, String>,
int> = 0>
UIProperty& operator+=( const T& operand ) {
return *this = value() + operand;
}
template <
typename U = T,
std::enable_if_t<std::is_same_v<U, std::string> || std::is_same_v<U, String>, int> = 0>
T operator+( const T& operand ) const {
return value() + operand;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
UIProperty& operator-=( const T& operand ) {
return *this = value() - operand;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
UIProperty& operator*=( const T& operand ) {
return *this = value() * operand;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
UIProperty& operator/=( const T& operand ) {
return *this = value() / operand;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
UIProperty& operator++() {
return *this += 1;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
T operator++( int ) {
T previous = value();
++( *this );
return previous;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
UIProperty& operator--() {
return *this -= 1;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
T operator--( int ) {
T previous = value();
--( *this );
return previous;
}
/** @} */
/** @return The current synchronized value. */
const T& value() const { return mBindedData.get(); }
/** @return The current synchronized value; enables the common observable-source interface. */
const T& get() const { return value(); }
/**
* @brief Observes later model- or widget-originated value changes.
* @return A scoped connection; destroying it disconnects the callback.
*/
Connection observe( Callback callback ) { return mBindedData.observe( std::move( callback ) ); }
/** @return A non-owning handle that expires safely when this property is destroyed. */
WeakHandle weakHandle() { return WeakHandle( mLifetime ); }
const UIDataBind<T>& databind() const { return mBindedData; }
UIDataBind<T>& databind() { return mBindedData; }
/** @return Current converter error state. */
const UIValueValidationState& validationState() const { return mBindedData.validationState(); }
/** @brief Connects another widget to this property's value. */
UIProperty& connect( UIWidget* widget ) {
mBindedData.bind( widget );
return *this;
}
/** @brief Disconnects a widget from this property's value. */
UIProperty& disconnect( UIWidget* widget ) {
mBindedData.unbind( widget );
return *this;
}
const T& operator*() const noexcept { return value(); }
const T* operator->() const noexcept { return &value(); }
operator const T&() const noexcept { return value(); }
/** @brief Sets the callback invoked after the synchronized value changes. */
UIProperty& changed( const std::function<void( const T& newVal )>& fn ) {
mBindedData.onValueChangeCb = fn;
return *this;
}
UIProperty& changed( std::function<void( const T& newVal )>&& fn ) {
mBindedData.onValueChangeCb = std::move( fn );
return *this;
}
protected:
template <typename Widgets>
void initializeBinding( Widgets&& widgets,
const typename EE::UI::UIDataBind<T>::Converter& converter,
const std::string& valueKey, const Event::EventType& eventType ) {
// Retain the complete lifetime state while accessing its embedded value directly. Keeping
// ownership separate avoids both an extra value allocation and an aliasing shared_ptr.
mBindedData.initRetained( &mLifetime->value, mLifetime, std::forward<Widgets>( widgets ),
converter, valueKey, eventType );
}
// Declaration order is intentional: the binding aliases mLifetime's allocation and must be
// destroyed before the final owning reference is released.
std::shared_ptr<LifetimeState> mLifetime;
UIDataBind<T> mBindedData;
};
}} // namespace EE::UI
#endif
@@ -0,0 +1,319 @@
#ifndef EE_UI_UIVALUEBINDING_HPP
#define EE_UI_UIVALUEBINDING_HPP
#include <eepp/core/computedvalue.hpp>
#include <eepp/core/observablevalue.hpp>
#include <eepp/ui/databinding/uivalueconverter.hpp>
#include <eepp/ui/uiwidget.hpp>
namespace EE { namespace UI {
/**
* @brief Move-only two-way binding between an ObservableValue and a UIWidget property.
*
* The converter maps directly between T and the widget property string. Its toValue() callback
* decides whether widget input may enter the model. Model-originated values are authoritative and
* are formatted through fromValue().
*
* Destroying the binding disconnects both directions. Destroying either the observable or widget
* first is safe and does not keep that endpoint alive.
*
* Synchronization is immediate and single-threaded. The observable, widget, and binding must all be
* used on the widget's owning UI thread.
*
* Use UIValueBinding when an ObservableValue belongs to a UI-independent model. The returned
* binding must be retained for as long as synchronization is desired.
*
* @code
* ObservableValue<std::string> userName{ "Ada" };
* auto binding = bindValue( userName, textInput,
* UIValueConverter<std::string>::converterString(),
* "text", Event::OnTextChanged );
* userName = "Grace"; // Updates textInput without coupling the model to UIWidget.
* @endcode
*/
template <typename T> class UIValueBinding {
public:
using Converter = UIValueConverter<T>;
/** @return The standard converter for the bound value type. */
static Converter converterDefault() { return Converter::converterDefault(); }
UIValueBinding() = default;
UIValueBinding( const UIValueBinding& ) = delete;
UIValueBinding& operator=( const UIValueBinding& ) = delete;
UIValueBinding( UIValueBinding&& ) noexcept = default;
UIValueBinding& operator=( UIValueBinding&& ) noexcept = default;
/**
* @brief Starts synchronizing @p value with a property of @p widget.
*
* The current model value is applied to the widget immediately. Later @p eventType events parse
* the widget property back into the model.
*/
UIValueBinding( ObservableValue<T>& value, UIWidget* widget,
const Converter& converter = converterDefault(),
const std::string& propertyName = "value",
Event::EventType eventType = Event::OnValueChange ) {
connect( value, widget, converter, propertyName, eventType );
}
/** @brief Stops synchronization in both directions. Calling this repeatedly is safe. */
void disconnect() { mState.reset(); }
/** @return Whether both model and widget endpoints are still alive and connected. */
explicit operator bool() const { return mState && mState->widget && mState->value; }
/** @return Whether both model and widget endpoints are still alive and connected. */
bool isConnected() const { return static_cast<bool>( *this ); }
/** @return Whether the most recent conversion or validation succeeded. */
bool isValid() const { return !mState || mState->validation.isValid(); }
/** @return The bound widget, or nullptr after disconnection or widget destruction. */
UIWidget* widget() const { return mState ? mState->widget : nullptr; }
/** @return A copy of the model value, or std::nullopt after disconnection. */
std::optional<T> value() const { return mState ? mState->value.get() : std::nullopt; }
/** @return true when the connected model still exists and was assigned @p value. */
bool setValue( const T& value ) { return mState && mState->value.set( value ); }
/** @return A scoped observer connection to later model changes, or an empty connection. */
typename ObservableValue<T>::Connection
observeValue( typename ObservableValue<T>::Callback callback ) {
return mState ? mState->value.observe( std::move( callback ) )
: typename ObservableValue<T>::Connection{};
}
/** @return Observable conversion and input-validation state. */
UIValueValidationState* validationState() { return mState ? &mState->validation : nullptr; }
const UIValueValidationState* validationState() const {
return mState ? &mState->validation : nullptr;
}
private:
struct State {
typename ObservableValue<T>::WeakHandle value;
UIWidget* widget{ nullptr };
const PropertyDefinition* property{ nullptr };
Converter converter;
UIValueValidationState validation;
bool synchronizing{ false };
typename ObservableValue<T>::Connection valueConnection;
EventConnectionList widgetConnections;
bool applyToWidget( const T& newValue ) {
if ( !widget )
return false;
auto converted = converter.fromValue( property, newValue );
if ( !converted ) {
validation.set( std::move( converted.validation ) );
return false;
}
synchronizing = true;
widget->applyProperty( StyleSheetProperty( property, *converted.value ) );
synchronizing = false;
validation.clear();
return true;
}
};
void connect( ObservableValue<T>& value, UIWidget* widget, const Converter& converter,
const std::string& propertyName, Event::EventType eventType ) {
eeASSERT( widget != nullptr );
auto state = std::make_shared<State>();
state->value = value.weakHandle();
state->widget = widget;
state->property = StyleSheetSpecification::instance()->getProperty( propertyName );
state->converter = converter;
eeASSERT( state->property != nullptr );
eeASSERT( state->converter.toValue && state->converter.fromValue );
std::weak_ptr<State> weakState = state;
state->valueConnection = value.observe( [weakState]( const T& newValue ) {
if ( auto state = weakState.lock() )
state->applyToWidget( newValue );
} );
state->widgetConnections += widget->connect( eventType, [weakState]( const Event* event ) {
if ( auto state = weakState.lock(); state && !state->synchronizing ) {
auto proposed = state->converter.toValue(
state->property,
event->getNode()->asType<UIWidget>()->getPropertyString( state->property ) );
if ( !proposed ) {
state->validation.set( std::move( proposed.validation ) );
return;
}
state->validation.clear();
if ( !state->value.set( std::move( *proposed.value ) ) ) {
state->widget = nullptr;
state->widgetConnections.clear();
}
}
} );
state->widgetConnections += widget->connect( Event::OnClose, [weakState]( const Event* ) {
if ( auto state = weakState.lock() ) {
state->widget = nullptr;
state->valueConnection.disconnect();
state->validation.clear();
state->widgetConnections.clear();
}
} );
state->applyToWidget( value.get() );
mState = std::move( state );
}
std::shared_ptr<State> mState;
};
/**
* @brief Move-only one-way binding from a read-only observable to a widget property.
*
* Sources must provide ValueType, get(), observe(), and an ObservableValue-compatible Connection.
* The binding applies the current source value immediately and retains neither endpoint. Use this
* for ComputedValue outputs or whenever widget edits must not update the source.
*/
template <typename T> class UIReadOnlyValueBinding {
public:
using Converter = UIValueConverter<T>;
UIReadOnlyValueBinding() = default;
UIReadOnlyValueBinding( const UIReadOnlyValueBinding& ) = delete;
UIReadOnlyValueBinding& operator=( const UIReadOnlyValueBinding& ) = delete;
UIReadOnlyValueBinding( UIReadOnlyValueBinding&& ) noexcept = default;
UIReadOnlyValueBinding& operator=( UIReadOnlyValueBinding&& ) noexcept = default;
/** @brief Starts one-way synchronization from @p source to a property of @p widget. */
template <typename Source>
UIReadOnlyValueBinding( Source& source, UIWidget* widget,
const Converter& converter = Converter::converterDefault(),
const std::string& propertyName = "value" ) {
connect( source, widget, converter, propertyName );
}
/** @brief Stops synchronization. Calling this repeatedly is safe. */
void disconnect() { mState.reset(); }
/** @return Whether the source connection and widget endpoint remain active. */
explicit operator bool() const {
return mState && mState->widget && static_cast<bool>( mState->sourceConnection );
}
/** @return Whether formatting the most recent source value succeeded. */
bool isValid() const { return !mState || mState->validation.isValid(); }
/** @return Formatting validation state, or nullptr for an empty binding. */
const UIValueValidationState* validationState() const {
return mState ? &mState->validation : nullptr;
}
private:
struct State {
UIWidget* widget{ nullptr };
const PropertyDefinition* property{ nullptr };
Converter converter;
UIValueValidationState validation;
typename ObservableValue<T>::Connection sourceConnection;
EventConnection widgetConnection;
void applyToWidget( const T& value ) {
if ( !widget )
return;
auto converted = converter.fromValue( property, value );
if ( !converted ) {
validation.set( std::move( converted.validation ) );
return;
}
widget->applyProperty( StyleSheetProperty( property, *converted.value ) );
validation.clear();
}
};
template <typename Source>
void connect( Source& source, UIWidget* widget, const Converter& converter,
const std::string& propertyName ) {
eeASSERT( widget != nullptr );
auto state = std::make_shared<State>();
state->widget = widget;
state->property = StyleSheetSpecification::instance()->getProperty( propertyName );
state->converter = converter;
eeASSERT( state->property != nullptr );
eeASSERT( state->converter.fromValue );
std::weak_ptr<State> weakState = state;
state->sourceConnection = source.observe( [weakState]( const T& value ) {
if ( auto state = weakState.lock() )
state->applyToWidget( value );
} );
state->widgetConnection = widget->connect( Event::OnClose, [weakState]( const Event* ) {
if ( auto state = weakState.lock() ) {
state->widget = nullptr;
state->sourceConnection.disconnect();
state->validation.clear();
}
} );
state->applyToWidget( source.get() );
mState = std::move( state );
}
std::shared_ptr<State> mState;
};
/** @brief Creates a scoped two-way binding between @p value and @p widget. */
template <typename T>
UIValueBinding<T>
bindValue( ObservableValue<T>& value, UIWidget* widget,
const UIValueConverter<T>& converter = UIValueConverter<T>::converterDefault(),
const std::string& propertyName = "value",
Event::EventType eventType = Event::OnValueChange ) {
return UIValueBinding<T>( value, widget, converter, propertyName, eventType );
}
/** @brief Creates a two-way binding to a non-default widget property using default conversion. */
template <typename T>
UIValueBinding<T> bindValue( ObservableValue<T>& value, UIWidget* widget,
const std::string& propertyName,
Event::EventType eventType = Event::OnValueChange ) {
return UIValueBinding<T>( value, widget, UIValueConverter<T>::converterDefault(), propertyName,
eventType );
}
/** @brief Creates a scoped one-way binding from a computed value to a widget. */
template <typename T, typename Calculator, typename... Dependencies>
UIReadOnlyValueBinding<T>
bindValue( ComputedValue<T, Calculator, Dependencies...>& value, UIWidget* widget,
const UIValueConverter<T>& converter = UIValueConverter<T>::converterDefault(),
const std::string& propertyName = "value" ) {
return UIReadOnlyValueBinding<T>( value, widget, converter, propertyName );
}
/** @brief Creates a read-only binding to a non-default property using default conversion. */
template <typename T, typename Calculator, typename... Dependencies>
UIReadOnlyValueBinding<T> bindValue( ComputedValue<T, Calculator, Dependencies...>& value,
UIWidget* widget, const std::string& propertyName ) {
return UIReadOnlyValueBinding<T>( value, widget, UIValueConverter<T>::converterDefault(),
propertyName );
}
/** @brief Creates a scoped one-way binding from any observable source to a widget. */
template <typename Source>
auto bindReadOnlyValue( Source& value, UIWidget* widget,
const UIValueConverter<typename Source::ValueType>& converter =
UIValueConverter<typename Source::ValueType>::converterDefault(),
const std::string& propertyName = "value" ) {
using T = typename Source::ValueType;
return UIReadOnlyValueBinding<T>( value, widget, converter, propertyName );
}
/** @brief Creates a one-way binding to a non-default property using default conversion. */
template <typename Source>
auto bindReadOnlyValue( Source& value, UIWidget* widget, const std::string& propertyName ) {
using T = typename Source::ValueType;
return UIReadOnlyValueBinding<T>( value, widget, UIValueConverter<T>::converterDefault(),
propertyName );
}
}} // namespace EE::UI
#endif
@@ -3,7 +3,7 @@
#include <eepp/core/string.hpp>
#include <eepp/ui/css/stylesheetproperty.hpp>
#include <eepp/ui/uivaluevalidation.hpp>
#include <eepp/ui/databinding/uivaluevalidation.hpp>
#include <functional>
#include <string>
#include <type_traits>
@@ -31,18 +31,32 @@ namespace EE { namespace UI {
* @endcode
*/
template <typename T> struct UIValueConverter {
/** @brief Parses a widget property string into the model type. */
using ToValue =
std::function<UIValueResult<T>( const CSS::PropertyDefinition*, const std::string& )>;
/** @brief Formats a model value as a widget property string. */
using FromValue =
std::function<UIValueResult<std::string>( const CSS::PropertyDefinition*, const T& )>;
UIValueConverter() = default;
/**
* @brief Creates a converter with custom parsing and the default formatter for @p T.
*
* This is the common form for validation rules that only constrain text entering the model.
*/
explicit UIValueConverter( ToValue toValue ) :
UIValueConverter( std::move( toValue ), converterDefault().fromValue ) {}
/** @brief Creates a converter with custom parsing and formatting policies. */
UIValueConverter( ToValue toValue, FromValue fromValue ) :
toValue( std::move( toValue ) ), fromValue( std::move( fromValue ) ) {}
ToValue toValue;
FromValue fromValue;
/** @return The standard string conversion policy for @p T. */
static UIValueConverter converterDefault() {
return UIValueConverter(
[]( const CSS::PropertyDefinition* property, const std::string& string ) {
@@ -78,6 +92,7 @@ template <typename T> struct UIValueConverter {
} );
}
/** @return A converter that preserves text verbatim for string-compatible @p T. */
static UIValueConverter converterString() {
return UIValueConverter(
[]( const CSS::PropertyDefinition*, const std::string& string ) {
@@ -0,0 +1,183 @@
#ifndef EE_UI_MODELS_OBSERVABLELISTMODEL_HPP
#define EE_UI_MODELS_OBSERVABLELISTMODEL_HPP
#include <eepp/core/observablevector.hpp>
#include <eepp/ui/models/model.hpp>
#include <functional>
#include <optional>
#include <type_traits>
namespace EE { namespace UI { namespace Models {
/**
* @brief One-column model adapter for an ObservableVector.
*
* Unfiltered sources preserve incremental model notifications. A filtered projection rebuilds its
* row mapping when source membership can change. Custom formatters allow domain types to remain
* independent from Variant. The model retains the source storage, so it remains safe when a view
* outlives the ObservableVector wrapper. Mutations naturally stop when that wrapper is destroyed.
*
* Filtering changes model row numbers. Use sourceRow() or at() before mutating the source from a
* selection in the filtered view.
*
* @code
* ObservableVector<Person> people;
* auto model = ObservableListModel<Person>::create(
* people, []( const Person& person, ModelRole role ) {
* return role == ModelRole::Display ? Variant( person.name ) : Variant{};
* } );
* model->setFilter( []( const Person& person ) { return person.active; } );
* @endcode
*/
template <typename T> class ObservableListModel final : public Model {
public:
/** @brief Converts one source item and role into model data. */
using Formatter = std::function<Variant( const T&, ModelRole )>;
/** @brief Returns true when a source item belongs in the visible projection. */
using Predicate = std::function<bool( const T& )>;
/** @brief Creates a one-column adapter using Variant's standard conversion for Display data. */
static std::shared_ptr<ObservableListModel> create( ObservableVector<T>& source ) {
return std::make_shared<ObservableListModel>( source );
}
/** @brief Creates a one-column adapter whose data() is supplied by @p formatter. */
static std::shared_ptr<ObservableListModel> create( ObservableVector<T>& source,
Formatter formatter ) {
return std::make_shared<ObservableListModel>( source, std::move( formatter ) );
}
/**
* @brief Creates an adapter over @p source, optionally using @p formatter for all roles.
*
* The source storage is retained for the model's lifetime.
*/
explicit ObservableListModel( ObservableVector<T>& source, Formatter formatter = {} ) :
mSource( source.sharedHandle() ), mFormatter( std::move( formatter ) ) {
mConnection = mSource.observe(
[this]( const typename ObservableVector<T>::Change& change ) { onChange( change ); } );
}
~ObservableListModel() { mConnection.disconnect(); }
/** @return The number of visible rows in the current projection. */
size_t rowCount( const ModelIndex& = ModelIndex() ) const {
return mPredicate ? mRows.size() : mSource.size();
}
/** @return One; ObservableListModel is a flat, one-column model. */
size_t columnCount( const ModelIndex& = ModelIndex() ) const { return 1; }
/** @return A valid index for a visible row in column zero, or an invalid index. */
ModelIndex index( int row, int column = 0, const ModelIndex& parent = ModelIndex() ) const {
if ( row < 0 || column != 0 || static_cast<std::size_t>( row ) >= rowCount( parent ) )
return {};
return Model::index( row, column, parent );
}
/** @return Formatted data for @p index and @p role, or an empty Variant when unavailable. */
Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const {
const T* value = at( index );
if ( !value )
return {};
if ( mFormatter )
return mFormatter( *value, role );
if ( role != ModelRole::Display )
return {};
if constexpr ( std::is_constructible_v<Variant, const T&> )
return Variant( *value );
return {};
}
/**
* @brief Replaces the visible filter and invalidates all model indexes.
*
* An empty predicate is equivalent to no filter. While filtered, source mutations rebuild the
* projection and invalidate indexes instead of emitting incremental row notifications.
*/
void setFilter( Predicate predicate ) {
mPredicate = std::move( predicate );
rebuildRows();
invalidate( InvalidateAllIndexes );
}
/** @brief Removes the active filter and exposes all source rows. */
void clearFilter() {
if ( !mPredicate )
return;
mPredicate = {};
mRows.clear();
invalidate( InvalidateAllIndexes );
}
/**
* @return The ObservableVector row represented by @p index, or std::nullopt for an invalid or
* foreign index.
*/
std::optional<std::size_t> sourceRow( const ModelIndex& index ) const {
if ( !index.isValid() || index.model() != this || index.row() < 0 ||
static_cast<std::size_t>( index.row() ) >= rowCount() )
return {};
return mPredicate ? mRows[index.row()] : static_cast<std::size_t>( index.row() );
}
/**
* @return The source item represented by @p index, or nullptr for an invalid or foreign index.
* @warning The pointer is invalidated by mutations that reallocate or remove vector elements.
*/
const T* at( const ModelIndex& index ) const {
auto row = sourceRow( index );
return row ? &mSource[*row] : nullptr;
}
private:
void rebuildRows() {
mRows.clear();
if ( !mPredicate )
return;
for ( std::size_t i = 0; i < mSource.size(); ++i )
if ( mPredicate( mSource[i] ) )
mRows.push_back( i );
}
void onChange( const typename ObservableVector<T>::Change& change ) {
using ChangeType = typename ObservableVector<T>::ChangeType;
using Phase = typename ObservableVector<T>::Phase;
if ( mPredicate ) {
if ( change.phase == Phase::After ) {
rebuildRows();
invalidate( InvalidateAllIndexes );
}
return;
}
const int first = static_cast<int>( change.index );
const int last = static_cast<int>( change.index + change.count - 1 );
if ( change.phase == Phase::Before ) {
if ( change.type == ChangeType::Insert )
beginInsertRows( {}, first, last );
else if ( change.type == ChangeType::Remove )
beginDeleteRows( {}, first, last );
else if ( change.type == ChangeType::Move )
beginMoveRows( {}, first, last, {}, static_cast<int>( change.target ) );
} else {
if ( change.type == ChangeType::Insert )
endInsertRows();
else if ( change.type == ChangeType::Remove )
endDeleteRows();
else if ( change.type == ChangeType::Move )
endMoveRows();
else if ( change.type == ChangeType::Change )
invalidate( DontInvalidateIndexes );
else if ( change.type == ChangeType::Reset )
invalidate( InvalidateAllIndexes );
}
}
typename ObservableVector<T>::SharedHandle mSource;
Formatter mFormatter;
Predicate mPredicate;
std::vector<std::size_t> mRows;
typename ObservableVector<T>::Connection mConnection;
};
}}} // namespace EE::UI::Models
#endif
+1 -1
View File
@@ -4,7 +4,7 @@
#include <eepp/ui/base.hpp>
#include <eepp/ui/doc/textdocument.hpp>
#include <eepp/ui/uicodeeditor.hpp>
#include <eepp/ui/uidatabind.hpp>
#include <eepp/ui/databinding/uidatabind.hpp>
#include <eepp/ui/uilinearlayout.hpp>
#include <eepp/ui/uiselectbutton.hpp>
#include <eepp/ui/uitextinput.hpp>
@@ -9,7 +9,7 @@
#include <eepp/ui/uisplitter.hpp>
#include <eepp/ui/uitabwidget.hpp>
#include <nlohmann/json_fwd.hpp>
#include <eepp/thirdparty/nlohmann/json_fwd.hpp>
namespace EE { namespace Graphics {
class Drawable;
-185
View File
@@ -1,185 +0,0 @@
#include <eepp/ui/uidatabind.hpp>
#include <type_traits>
namespace EE { namespace UI {
/**
* @brief Owns a value and exposes it as a UIDataBind-backed widget property.
*
* UIProperty is the owning counterpart to UIDataBind: the synchronized value is stored inside the
* property, so callers only need to ensure the UIProperty itself remains alive while using it.
* Assignments propagate to connected widgets, and widget-originated changes update value().
* Connections are removed automatically when either the UIProperty or a connected widget dies.
*
* The class is non-copyable and non-movable because its UIDataBind stores the address of mValue and
* installs callbacks that capture the binding's address.
*
* Use UIProperty for concise UI-local state when the value and its widgets naturally share a
* lifetime. It avoids the shared state required by ObservableValue and owns its UIDataBind
* directly. A custom UIValueConverter can provide presentation-specific parsing and formatting.
*
* @code
* UIProperty<double> celsius( 0.0, celsiusInput );
* UIProperty<double> fahrenheit( 32.0, fahrenheitInput );
* celsius.changed( [&fahrenheit]( double value ) {
* fahrenheit = value * 9.0 / 5.0 + 32.0;
* } );
* @endcode
*/
template <typename T> class UIProperty {
public:
UIProperty( const UIProperty& ) = delete;
UIProperty& operator=( const UIProperty& ) = delete;
UIProperty( UIProperty&& ) = delete;
UIProperty& operator=( UIProperty&& ) = delete;
UIProperty( T defaultValue, UIWidget* widget,
const typename EE::UI::UIDataBind<T>::Converter& converter =
EE::UI::UIDataBind<T>::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) :
mValue( std::move( defaultValue ) ),
mBindedData( &mValue, widget, converter, valueKey, eventType ) {}
UIProperty( T defaultValue, const UnorderedSet<UIWidget*>& widgets = {},
const typename EE::UI::UIDataBind<T>::Converter& converter =
EE::UI::UIDataBind<T>::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) :
mValue( std::move( defaultValue ) ),
mBindedData( &mValue, widgets, converter, valueKey, eventType ) {}
UIProperty( const UnorderedSet<UIWidget*>& widgets = {},
const typename EE::UI::UIDataBind<T>::Converter& converter =
EE::UI::UIDataBind<T>::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) :
mBindedData( &mValue, widgets, converter, valueKey, eventType ) {}
UIProperty( UIWidget* widget,
const typename EE::UI::UIDataBind<T>::Converter& converter =
EE::UI::UIDataBind<T>::converterDefault(),
const std::string& valueKey = "value",
const Event::EventType& eventType = Event::OnValueChange ) :
mBindedData( &mValue, widget, converter, valueKey, eventType ) {}
UIProperty& operator=( const T& newVal ) {
mBindedData.set( newVal );
return *this;
}
UIProperty& operator=( T&& newVal ) noexcept {
mBindedData.set( std::move( newVal ) );
return *this;
}
/** @name Value mutation
* Compound assignment propagates through the binding like assignment. Arithmetic properties
* support the conventional numeric mutations; std::string and String properties support
* concatenation.
* @{ */
template <typename U = T,
std::enable_if_t<(std::is_arithmetic_v<U> && !std::is_same_v<U, bool>) ||
std::is_same_v<U, std::string> || std::is_same_v<U, String>,
int> = 0>
UIProperty& operator+=( const T& operand ) {
return *this = value() + operand;
}
template <
typename U = T,
std::enable_if_t<std::is_same_v<U, std::string> || std::is_same_v<U, String>, int> = 0>
T operator+( const T& operand ) const {
return value() + operand;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
UIProperty& operator-=( const T& operand ) {
return *this = value() - operand;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
UIProperty& operator*=( const T& operand ) {
return *this = value() * operand;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
UIProperty& operator/=( const T& operand ) {
return *this = value() / operand;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
UIProperty& operator++() {
return *this += 1;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
T operator++( int ) {
T previous = value();
++( *this );
return previous;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
UIProperty& operator--() {
return *this -= 1;
}
template <typename U = T,
std::enable_if_t<std::is_arithmetic_v<U> && !std::is_same_v<U, bool>, int> = 0>
T operator--( int ) {
T previous = value();
--( *this );
return previous;
}
/** @} */
const T& value() const { return mBindedData.get(); }
const UIDataBind<T>& databind() const { return mBindedData; }
UIDataBind<T>& databind() { return mBindedData; }
/** @return Current converter error state. */
const UIValueValidationState& validationState() const { return mBindedData.validationState(); }
/** @brief Connects another widget to this property's value. */
UIProperty& connect( UIWidget* widget ) {
mBindedData.bind( widget );
return *this;
}
/** @brief Disconnects a widget from this property's value. */
UIProperty& disconnect( UIWidget* widget ) {
mBindedData.unbind( widget );
return *this;
}
const T& operator*() const noexcept { return value(); }
const T* operator->() const noexcept { return &value(); }
operator const T&() const noexcept { return value(); }
/** @brief Sets the callback invoked after the synchronized value changes. */
UIProperty& changed( const std::function<void( const T& newVal )>& fn ) {
mBindedData.onValueChangeCb = fn;
return *this;
}
UIProperty& changed( std::function<void( const T& newVal )>&& fn ) {
mBindedData.onValueChangeCb = std::move( fn );
return *this;
}
protected:
T mValue{};
UIDataBind<T> mBindedData;
};
}} // namespace EE::UI
-148
View File
@@ -1,148 +0,0 @@
#ifndef EE_UI_UIVALUEBINDING_HPP
#define EE_UI_UIVALUEBINDING_HPP
#include <eepp/core/observablevalue.hpp>
#include <eepp/ui/uivalueconverter.hpp>
#include <eepp/ui/uiwidget.hpp>
namespace EE { namespace UI {
/**
* @brief Move-only two-way binding between an ObservableValue and a UIWidget property.
*
* The converter maps directly between T and the widget property string. Its toValue() callback
* decides whether widget input may enter the model. Model-originated values are authoritative and
* are formatted through fromValue().
*
* Destroying the binding disconnects both directions. Destroying either the observable or widget
* first is safe and does not keep that endpoint alive.
*
* Synchronization is immediate and single-threaded. The observable, widget, and binding must all be
* used on the widget's owning UI thread.
*
* Use UIValueBinding when an ObservableValue belongs to a UI-independent model. The returned
* binding must be retained for as long as synchronization is desired.
*
* @code
* ObservableValue<std::string> userName{ "Ada" };
* auto binding = bindValue( userName, textInput,
* UIValueConverter<std::string>::converterString(),
* "text", Event::OnTextChanged );
* userName = "Grace"; // Updates textInput without coupling the model to UIWidget.
* @endcode
*/
template <typename T> class UIValueBinding {
public:
using Converter = UIValueConverter<T>;
static Converter converterDefault() { return Converter::converterDefault(); }
UIValueBinding() = default;
UIValueBinding( const UIValueBinding& ) = delete;
UIValueBinding& operator=( const UIValueBinding& ) = delete;
UIValueBinding( UIValueBinding&& ) noexcept = default;
UIValueBinding& operator=( UIValueBinding&& ) noexcept = default;
UIValueBinding( ObservableValue<T>& value, UIWidget* widget,
const Converter& converter = converterDefault(),
const std::string& propertyName = "value",
Event::EventType eventType = Event::OnValueChange ) {
connect( value, widget, converter, propertyName, eventType );
}
void disconnect() { mState.reset(); }
explicit operator bool() const { return mState && mState->widget && mState->value; }
bool isValid() const { return !mState || mState->validation.isValid(); }
/** @return Observable conversion and input-validation state. */
UIValueValidationState* validationState() { return mState ? &mState->validation : nullptr; }
const UIValueValidationState* validationState() const {
return mState ? &mState->validation : nullptr;
}
private:
struct State {
typename ObservableValue<T>::WeakHandle value;
UIWidget* widget{ nullptr };
const PropertyDefinition* property{ nullptr };
Converter converter;
UIValueValidationState validation;
bool synchronizing{ false };
typename ObservableValue<T>::Connection valueConnection;
EventConnectionList widgetConnections;
bool applyToWidget( const T& newValue ) {
if ( !widget )
return false;
auto converted = converter.fromValue( property, newValue );
if ( !converted ) {
validation.set( std::move( converted.validation ) );
return false;
}
synchronizing = true;
widget->applyProperty( StyleSheetProperty( property, *converted.value ) );
synchronizing = false;
validation.clear();
return true;
}
};
void connect( ObservableValue<T>& value, UIWidget* widget, const Converter& converter,
const std::string& propertyName, Event::EventType eventType ) {
eeASSERT( widget != nullptr );
auto state = std::make_shared<State>();
state->value = value.weakHandle();
state->widget = widget;
state->property = StyleSheetSpecification::instance()->getProperty( propertyName );
state->converter = converter;
eeASSERT( state->property != nullptr );
eeASSERT( state->converter.toValue && state->converter.fromValue );
std::weak_ptr<State> weakState = state;
state->valueConnection = value.observe( [weakState]( const T& newValue ) {
if ( auto state = weakState.lock() )
state->applyToWidget( newValue );
} );
state->widgetConnections += widget->connect( eventType, [weakState]( const Event* event ) {
if ( auto state = weakState.lock(); state && !state->synchronizing ) {
auto proposed = state->converter.toValue(
state->property,
event->getNode()->asType<UIWidget>()->getPropertyString( state->property ) );
if ( !proposed ) {
state->validation.set( std::move( proposed.validation ) );
return;
}
state->validation.clear();
if ( !state->value.set( std::move( *proposed.value ) ) ) {
state->widget = nullptr;
state->widgetConnections.clear();
}
}
} );
state->widgetConnections += widget->connect( Event::OnClose, [weakState]( const Event* ) {
if ( auto state = weakState.lock() ) {
state->widget = nullptr;
state->valueConnection.disconnect();
state->validation.clear();
state->widgetConnections.clear();
}
} );
state->applyToWidget( value.get() );
mState = std::move( state );
}
std::shared_ptr<State> mState;
};
/** @brief Creates a scoped two-way binding between @p value and @p widget. */
template <typename T>
UIValueBinding<T>
bindValue( ObservableValue<T>& value, UIWidget* widget,
const UIValueConverter<T>& converter = UIValueConverter<T>::converterDefault(),
const std::string& propertyName = "value",
Event::EventType eventType = Event::OnValueChange ) {
return UIValueBinding<T>( value, widget, converter, propertyName, eventType );
}
}} // namespace EE::UI
#endif
+12
View File
@@ -1708,6 +1708,18 @@ solution "eepp"
files { "src/examples/ui_dropdownmodellist/*.cpp" }
build_link_configuration( "eepp-ui-dropdownmodellist", true )
project "eepp-ui-data-handling"
set_kind()
language "C++"
files { "src/examples/ui_data_handling/*.cpp" }
build_link_configuration( "eepp-ui-data-handling", true )
project "eepp-ui-data-collections"
set_kind()
language "C++"
files { "src/examples/ui_data_collections/*.cpp" }
build_link_configuration( "eepp-ui-data-collections", true )
project "eepp-ui-richtext"
set_kind()
language "C++"
+12
View File
@@ -1751,6 +1751,18 @@ workspace "eepp"
files { "src/examples/ui_dropdownmodellist/*.cpp" }
build_link_configuration( "eepp-ui-dropdownmodellist", true )
project "eepp-ui-data-handling"
set_kind()
language "C++"
files { "src/examples/ui_data_handling/*.cpp" }
build_link_configuration( "eepp-ui-data-handling", true )
project "eepp-ui-data-collections"
set_kind()
language "C++"
files { "src/examples/ui_data_collections/*.cpp" }
build_link_configuration( "eepp-ui-data-collections", true )
project "eepp-ui-richtext"
set_kind()
language "C++"
+9 -1
View File
@@ -14,6 +14,7 @@
../../bin/assets/ui/breeze.css
../../bin/assets/ui/uitheme.css
../../docs/articles/cssspecification.md
../../docs/articles/uidatabinding.md
../../docs/articles/uiintroduction.md
../../external_projects.lua
../../include/eepp/audio/alresource.hpp
@@ -337,6 +338,14 @@
../../include/eepp/ui/css/stylesheetvariable.hpp
../../include/eepp/ui/css/timingfunction.hpp
../../include/eepp/ui/css/transitiondefinition.hpp
../../include/eepp/ui/databinding/uibindinggroup.hpp
../../include/eepp/ui/databinding/uicommand.hpp
../../include/eepp/ui/databinding/uidatabind.hpp
../../include/eepp/ui/databinding/uiobservedelivery.hpp
../../include/eepp/ui/databinding/uiproperty.hpp
../../include/eepp/ui/databinding/uivaluebinding.hpp
../../include/eepp/ui/databinding/uivalueconverter.hpp
../../include/eepp/ui/databinding/uivaluevalidation.hpp
../../include/eepp/ui/doc/foldrangetype.hpp
../../include/eepp/ui/doc/syntaxcolorscheme.hpp
../../include/eepp/ui/doc/syntaxdefinition.hpp
@@ -377,7 +386,6 @@
../../include/eepp/ui/uicodeeditor.hpp
../../include/eepp/ui/uicombobox.hpp
../../include/eepp/ui/uiconsole.hpp
../../include/eepp/ui/uidatabind.hpp
../../include/eepp/ui/uidropdownlist.hpp
../../include/eepp/ui/uieventdispatcher.hpp
../../include/eepp/ui/uifiledialog.hpp
+9 -1
View File
@@ -14,6 +14,7 @@
../../bin/assets/ui/breeze.css
../../bin/assets/ui/uitheme.css
../../docs/articles/cssspecification.md
../../docs/articles/uidatabinding.md
../../docs/articles/uiintroduction.md
../../external_projects.lua
../../include/eepp/audio/alresource.hpp
@@ -336,6 +337,14 @@
../../include/eepp/ui/css/stylesheetvariable.hpp
../../include/eepp/ui/css/timingfunction.hpp
../../include/eepp/ui/css/transitiondefinition.hpp
../../include/eepp/ui/databinding/uibindinggroup.hpp
../../include/eepp/ui/databinding/uicommand.hpp
../../include/eepp/ui/databinding/uidatabind.hpp
../../include/eepp/ui/databinding/uiobservedelivery.hpp
../../include/eepp/ui/databinding/uiproperty.hpp
../../include/eepp/ui/databinding/uivaluebinding.hpp
../../include/eepp/ui/databinding/uivalueconverter.hpp
../../include/eepp/ui/databinding/uivaluevalidation.hpp
../../include/eepp/ui/doc/foldrangetype.hpp
../../include/eepp/ui/doc/syntaxcolorscheme.hpp
../../include/eepp/ui/doc/syntaxdefinition.hpp
@@ -373,7 +382,6 @@
../../include/eepp/ui/uicodeeditor.hpp
../../include/eepp/ui/uicombobox.hpp
../../include/eepp/ui/uiconsole.hpp
../../include/eepp/ui/uidatabind.hpp
../../include/eepp/ui/uidropdownlist.hpp
../../include/eepp/ui/uieventdispatcher.hpp
../../include/eepp/ui/uifiledialog.hpp
+9 -1
View File
@@ -14,6 +14,7 @@
../../bin/assets/ui/breeze.css
../../bin/assets/ui/uitheme.css
../../docs/articles/cssspecification.md
../../docs/articles/uidatabinding.md
../../docs/articles/uiintroduction.md
../../external_projects.lua
../../include/eepp/audio/alresource.hpp
@@ -331,6 +332,14 @@
../../include/eepp/ui/css/stylesheetvariable.hpp
../../include/eepp/ui/css/timingfunction.hpp
../../include/eepp/ui/css/transitiondefinition.hpp
../../include/eepp/ui/databinding/uibindinggroup.hpp
../../include/eepp/ui/databinding/uicommand.hpp
../../include/eepp/ui/databinding/uidatabind.hpp
../../include/eepp/ui/databinding/uiobservedelivery.hpp
../../include/eepp/ui/databinding/uiproperty.hpp
../../include/eepp/ui/databinding/uivaluebinding.hpp
../../include/eepp/ui/databinding/uivalueconverter.hpp
../../include/eepp/ui/databinding/uivaluevalidation.hpp
../../include/eepp/ui/doc/syntaxcolorscheme.hpp
../../include/eepp/ui/doc/syntaxdefinition.hpp
../../include/eepp/ui/doc/syntaxdefinitionmanager.hpp
@@ -366,7 +375,6 @@
../../include/eepp/ui/uicodeeditor.hpp
../../include/eepp/ui/uicombobox.hpp
../../include/eepp/ui/uiconsole.hpp
../../include/eepp/ui/uidatabind.hpp
../../include/eepp/ui/uidropdownlist.hpp
../../include/eepp/ui/uieventdispatcher.hpp
../../include/eepp/ui/uifiledialog.hpp
+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 {