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