diff --git a/.agent/plans/ui_data_handling_modernization_plan.md b/.agent/plans/ui_data_handling_modernization_plan.md deleted file mode 100644 index 67be48f6e..000000000 --- a/.agent/plans/ui_data_handling_modernization_plan.md +++ /dev/null @@ -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 UIProperty T* - | | | -UIValueBinding UIDataBind <--------------+ - | | - +------------- UIValueConverter + - | - 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`: low-level adaptation of an externally owned `T*`. The caller controls and must - prove the value lifetime. -- `UIProperty`: inexpensive owned UI-local value and bidirectional widget binding. -- `ObservableValue`: UI-independent owned state whose observer types are unknown to the model. -- `UIValueBinding`: scoped bidirectional adapter between an `ObservableValue` and a widget. -- `UIValueConverter`: 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` 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; - std::optional 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` callbacks from: - -```cpp -std::function -std::function -``` - -to: - -```cpp -std::function( const PropertyDefinition*, const std::string& )> -std::function( 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::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` 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`. 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::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` and `UIValueBinding` 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& code() const; - const std::optional& 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::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& validationCode() const = 0; - virtual const std::optional& 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 -ObservableCollection -CollectionModelAdapter -``` - -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. diff --git a/.ecode/project_build.json b/.ecode/project_build.json index 7369a4c91..b5135be70 100644 --- a/.ecode/project_build.json +++ b/.ecode/project_build.json @@ -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": { diff --git a/README.md b/README.md index 5d5305b82..54244d9c4 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,9 @@ the most basic widgets in a vertical linear layout display. ``` -**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 diff --git a/docs/articles/uidatabinding.md b/docs/articles/uidatabinding.md new file mode 100644 index 000000000..0d3245a77 --- /dev/null +++ b/docs/articles/uidatabinding.md @@ -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 ``. Individual headers live in +``, while the core observable containers live in ``. + +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` | +| A value calculated from observable dependencies | `ComputedValue` | +| Concise UI-local state owned together with its binding | `UIProperty` | +| Two-way model-to-widget synchronization | `UIValueBinding` | +| One-way synchronization from a read-only or calculated source | `UIReadOnlyValueBinding` | +| Typed parsing, formatting, and input validation | `UIValueConverter` | +| 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` and `ObservableListModel` | +| Delivery of worker-produced observable changes on the UI thread | `UIThreadObservation` | +| Binding an existing externally owned value | `UIDataBind` | + +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` owns a value and synchronously notifies observers after distinct changes: + +```cpp +ObservableValue 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`. The current model value is immediately applied +to the widget. Later widget value changes are converted back into the model: + +```cpp +ObservableValue 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 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` 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 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` 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( + []( const CSS::PropertyDefinition*, + const std::string& text ) -> UIValueResult { + int port = 0; + if ( !String::fromString( port, text ) || port < 1 || port > 65535 ) + return UIValueResult::error( + 101, "port must be between 1 and 65535" ); + return port; + } ); + +ObservableValue 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; +UIValueConverter 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 project( "eepp" ); +ObservableValue host( "localhost" ); +ObservableValue 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` 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::New( + &showDetails, detailsCheckBox, + UIValueConverter::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(); +auto binding = UIDataBind::New( query, queryInput ); +``` + +Shared bindings retain the original value through callback delivery without cloning `T`. +`UIProperty` uses this retained form internally. + +## Observable collections + +`ObservableVector` is intended for collections that remain live while a view is attached. Its +explicit mutations emit incremental before/after notifications: + +```cpp +ObservableVector tasks( { + "Build eepp", + "Run unit tests", +} ); + +auto model = ObservableListModel::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::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()->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. diff --git a/docs/articles/uiintroduction.md b/docs/articles/uiintroduction.md index 6a2e1469f..230219cea 100644 --- a/docs/articles/uiintroduction.md +++ b/docs/articles/uiintroduction.md @@ -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. diff --git a/include/eepp/core/computedvalue.hpp b/include/eepp/core/computedvalue.hpp new file mode 100644 index 000000000..01a44a252 --- /dev/null +++ b/include/eepp/core/computedvalue.hpp @@ -0,0 +1,154 @@ +#ifndef EE_CORE_COMPUTEDVALUE_HPP +#define EE_CORE_COMPUTEDVALUE_HPP + +#include +#include +#include +#include + +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 class ComputedValue { + private: + using Values = std::tuple; + using Connections = std::tuple; + + 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 output; + }; + + template + ComputedValue( Calculator calculator, std::index_sequence, + Dependencies&... dependencies ) : + mState( + std::make_shared( std::move( calculator ), Values( dependencies.get()... ) ) ) { + // The comma fold guarantees dependency registration follows argument order. + ( ( std::get( mConnections ) = connect( dependencies ) ), ... ); + } + + template + typename Dependency::Connection connect( Dependency& dependency ) { + std::weak_ptr weakState = mState; + return dependency.observe( [weakState]( const typename Dependency::ValueType& value ) { + if ( auto state = weakState.lock() ) { + std::get( state->values ) = value; + state->recompute(); + } + } ); + } + + public: + using ValueType = T; + using Callback = typename ObservableValue::Callback; + using Connection = typename ObservableValue::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... ) {} + 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 mState; + Connections mConnections; +}; + +template +/** @brief Creates a ComputedValue while deducing its result, calculator, and dependency types. */ +auto makeComputedValue( Calculator&& calculator, Dependencies&... dependencies ) { + using StoredCalculator = std::decay_t; + using Result = std::decay_t< + std::invoke_result_t>; + return ComputedValue( + std::forward( calculator ), dependencies... ); +} + +template +/** @brief Convenience form of makeComputedValue() for one dependency. */ +auto computedValue( Dependency& dependency, Calculator&& calculator ) { + return makeComputedValue( std::forward( calculator ), dependency ); +} + +template +/** @brief Convenience form of makeComputedValue() for two dependencies. */ +auto computedValue( Dependency1& dependency1, Dependency2& dependency2, Calculator&& calculator ) { + return makeComputedValue( std::forward( calculator ), dependency1, dependency2 ); +} + +template +/** @brief Convenience form of makeComputedValue() for three dependencies. */ +auto computedValue( Dependency1& dependency1, Dependency2& dependency2, Dependency3& dependency3, + Calculator&& calculator ) { + return makeComputedValue( std::forward( calculator ), dependency1, dependency2, + dependency3 ); +} + +template +/** @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 ), dependency1, dependency2, + dependency3, dependency4 ); +} + +} // namespace EE + +#endif diff --git a/include/eepp/core/core.hpp b/include/eepp/core/core.hpp index d0a687bd8..fabc978ae 100644 --- a/include/eepp/core/core.hpp +++ b/include/eepp/core/core.hpp @@ -1,13 +1,17 @@ #ifndef EE_CORE_CORE_HPP #define EE_CORE_CORE_HPP +#include #include #include #include #include #include +#include +#include #include #include +#include #include #include #include diff --git a/include/eepp/core/observablevalue.hpp b/include/eepp/core/observablevalue.hpp index 3c662b387..b444e3f53 100644 --- a/include/eepp/core/observablevalue.hpp +++ b/include/eepp/core/observablevalue.hpp @@ -3,9 +3,11 @@ #include #include +#include #include #include #include +#include #include namespace EE { @@ -42,29 +44,62 @@ template class ObservableValue { struct Observer { Uint32 id; Callback callback; + bool connected{ true }; }; using Observers = SmallVector; 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 void setImpl( U&& newValue ) { + if ( notifying ) { + if ( value == newValue ) { + pendingValue.reset(); + } else if ( !pendingValue || *pendingValue != newValue ) { + pendingValue = std::forward( 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( 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 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 pendingValue; + bool notifying{ false }; }; public: + using ValueType = T; using Callback = std::function; /** @brief Move-only scoped ownership of one ObservableValue observer. */ @@ -123,6 +187,7 @@ template 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 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 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 class ObservableValue { return false; } + /** @return A copy of the current value, or std::nullopt after the owner expires. */ + std::optional 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 class ObservableValue { std::weak_ptr mState; }; + /** @brief Creates an observable containing a default-constructed value. */ ObservableValue() : mState( std::make_shared( T{} ) ) {} + + /** @brief Creates an observable containing @p value. No notification is emitted. */ explicit ObservableValue( T value ) : mState( std::make_shared( 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 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 mState; }; diff --git a/include/eepp/core/observablevector.hpp b/include/eepp/core/observablevector.hpp new file mode 100644 index 000000000..001c1afe6 --- /dev/null +++ b/include/eepp/core/observablevector.hpp @@ -0,0 +1,284 @@ +#ifndef EE_CORE_OBSERVABLEVECTOR_HPP +#define EE_CORE_OBSERVABLEVECTOR_HPP + +#include +#include + +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 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; + using Callback = std::function; + + private: + struct State { + struct Observer { + std::shared_ptr callback; + Uint64 removedGeneration{ 0 }; + Uint32 id; + }; + std::vector values; + SmallVector 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; + Connection( const std::shared_ptr& state, Uint32 id ) : mState( state ), mId( id ) {} + std::weak_ptr 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& 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( 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( std::move( callback ) ), 0, id } ); + return Connection( mState, id ); + } + + private: + friend class ObservableVector; + explicit SharedHandle( std::shared_ptr state ) : mState( std::move( state ) ) {} + std::shared_ptr mState; + }; + + /** @brief Creates an empty observable collection. */ + ObservableVector() : mState( std::make_shared() ) {} + + /** @brief Creates an observable collection containing @p values without emitting a change. */ + explicit ObservableVector( std::vector values ) : mState( std::make_shared() ) { + 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& 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 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( 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 mState; +}; + +} // namespace EE + +#endif diff --git a/include/eepp/graphics.hpp b/include/eepp/graphics.hpp index d24df1788..47b965130 100644 --- a/include/eepp/graphics.hpp +++ b/include/eepp/graphics.hpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include diff --git a/include/eepp/scene.hpp b/include/eepp/scene.hpp index 25f921580..fdd43a7ca 100644 --- a/include/eepp/scene.hpp +++ b/include/eepp/scene.hpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include diff --git a/include/eepp/system.hpp b/include/eepp/system.hpp index 4fb92d49d..8d83f3c01 100644 --- a/include/eepp/system.hpp +++ b/include/eepp/system.hpp @@ -11,12 +11,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -39,6 +41,7 @@ #include #include #include +#include #include #include #include diff --git a/include/eepp/ui.hpp b/include/eepp/ui.hpp index 7d687e58f..12b7e0186 100644 --- a/include/eepp/ui.hpp +++ b/include/eepp/ui.hpp @@ -10,9 +10,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -31,6 +33,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -48,9 +58,13 @@ #include #include #include +#include +#include #include #include #include +#include +#include #include #include #include @@ -59,6 +73,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +81,7 @@ #include #include #include +#include #include #include #include @@ -77,6 +93,7 @@ #include #include #include +#include #include #include #include @@ -86,7 +103,6 @@ #include #include #include -#include #include #include #include @@ -100,6 +116,7 @@ #include #include #include +#include #include #include #include @@ -126,6 +143,7 @@ #include #include #include +#include #include #include #include @@ -133,7 +151,6 @@ #include #include #include -#include #include #include #include @@ -172,9 +189,6 @@ #include #include #include -#include -#include -#include #include #include #include diff --git a/include/eepp/ui/databinding/uibindinggroup.hpp b/include/eepp/ui/databinding/uibindinggroup.hpp new file mode 100644 index 000000000..e6554f06e --- /dev/null +++ b/include/eepp/ui/databinding/uibindinggroup.hpp @@ -0,0 +1,372 @@ +#ifndef EE_UI_UIBINDINGGROUP_HPP +#define EE_UI_UIBINDINGGROUP_HPP + +#include +#include +#include +#include +#include + +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 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; + /** Inline-backed widget collection sized for ordinary forms. */ + using Widgets = SmallVector; + using Callback = std::function; + + 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 UIBindingGroup& hold( UIValueBinding&& binding ) { + add( std::make_unique>( std::move( binding ), this ) ); + return *this; + } + + template UIBindingGroup& operator+=( UIValueBinding&& binding ) { + return hold( std::move( binding ) ); + } + + /** @brief Adds and takes ownership of a legacy UIDataBind. */ + template UIBindingGroup& hold( std::unique_ptr> binding ) { + add( std::make_unique>( std::move( binding ), this ) ); + return *this; + } + + template UIBindingGroup& operator+=( std::unique_ptr> 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 UIBindingGroup& hold( UIProperty& property ) { + add( std::make_unique>( property, this ) ); + return *this; + } + + /** @brief Convenience form of hold(UIProperty&). */ + template UIBindingGroup& operator+=( UIProperty& 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& validValue() { return mValid; } + + /** @return Observable aggregate dirty state, suitable for computed values and commands. */ + ObservableValue& 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::Connection validationConnection; + EventConnectionList enabledConnections; + }; + + template struct ObservableEntry : Entry { + ObservableEntry( UIValueBinding&& 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 binding; + std::optional baseline; + typename ObservableValue::Connection valueConnection; + }; + + template struct RawEntry : Entry { + RawEntry( std::unique_ptr> 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> binding; + T baseline; + }; + + template struct PropertyEntry : Entry { + PropertyEntry( UIProperty& 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::WeakHandle handle; + std::optional baseline; + UIValueValidationResult currentValidation; + typename UIProperty::Connection valueConnection; + ObservableValue::Connection lifetimeConnection; + }; + + void add( std::unique_ptr 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, 4> mEntries; + Callback mCallback; + bool mLastValid{ true }; + bool mLastDirty{ false }; + ObservableValue mValid{ true }; + ObservableValue mDirty{ false }; +}; + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/databinding/uicommand.hpp b/include/eepp/ui/databinding/uicommand.hpp new file mode 100644 index 000000000..4d31f81ba --- /dev/null +++ b/include/eepp/ui/databinding/uicommand.hpp @@ -0,0 +1,304 @@ +#ifndef EE_UI_UICOMMAND_HPP +#define EE_UI_UICOMMAND_HPP + +#include +#include +#include +#include + +namespace EE { namespace UI { + +template 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 struct SourceConnection final : SourceConnectionBase { + explicit SourceConnection( Connection connection ) : + connection( std::move( connection ) ) {} + Connection connection; + }; + + struct State { + explicit State( std::function execute ) : execute( std::move( execute ) ) {} + bool tryExecute() { + if ( !enabled.get() || executing ) + return false; + executing = true; + execute(); + executing = false; + return true; + } + std::function execute; + ObservableValue 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 enabledSourceConnection; + bool executing{ false }; + }; + + public: + /** @brief Creates an always-enabled command that invokes @p execute. */ + explicit UICommand( std::function execute ) : + mState( std::make_shared( std::move( execute ) ) ) {} + template + /** + * @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 execute, Source& enabled ) : + mState( std::make_shared( std::move( execute ) ) ) { + mState->enabled = enabled.get(); + std::weak_ptr weakState = mState; + auto enabledConnection = enabled.observe( [weakState]( const bool& value ) { + if ( auto state = weakState.lock() ) + state->enabled = value; + } ); + mState->enabledSourceConnection = + std::make_unique>( + 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& enabled() { return mState->enabled; } + + private: + std::shared_ptr mState; + friend class UICommandBinding; + template 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(); + state->widget = &widget; + widget.setEnabled( commandState->enabled.get() ); + std::weak_ptr weakCommand = commandState; + std::weak_ptr 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::Connection enabledConnection; + }; + std::shared_ptr 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 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->target = ⌖ + state->shortcut = shortcut; + state->previousCommand = target.getKeyBindings().getCommandFromKeyBind( shortcut ); + state->commandName = "eepp-ui-command-" + String::toString( ++sNextCommandId ); + std::weak_ptr weakCommand = command.mState; + target.setKeyBindingCommand( state->commandName, [weakCommand] { + if ( auto command = weakCommand.lock() ) + command->tryExecute(); + } ); + target.getKeyBindings().addKeybind( shortcut, state->commandName ); + std::weak_ptr 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 sNextCommandId{ 0 }; + std::shared_ptr mState; +}; + +template +/** @return A scoped binding between an existing command and a keyboard shortcut. */ +UICommandShortcutBinding bindCommand( UICommand& command, Target& target, + const KeyBindings::Shortcut& shortcut ) { + return UICommandShortcutBinding( 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 class UICommandBindingSet { + public: + /** @brief Creates an always-enabled command with widget and shortcut endpoints. */ + UICommandBindingSet( std::function execute, UIWidget& widget, + ShortcutTarget& shortcutTarget, const KeyBindings::Shortcut& shortcut ) : + mCommand( std::move( execute ) ), + mWidgetBinding( mCommand, widget ), + mShortcutBinding( mCommand, shortcutTarget, shortcut ) {} + + template + /** @brief Creates a command following @p enabled with widget and shortcut endpoints. */ + UICommandBindingSet( std::function 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 mShortcutBinding; +}; + +template +/** + * @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 bindCommand( std::function execute, UIWidget& widget, + ShortcutTarget& shortcutTarget, + const KeyBindings::Shortcut& shortcut ) { + return UICommandBindingSet( std::move( execute ), widget, shortcutTarget, + shortcut ); +} + +template +/** + * @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 bindCommand( std::function execute, Source& enabled, + UIWidget& widget, ShortcutTarget& shortcutTarget, + const KeyBindings::Shortcut& shortcut ) { + return UICommandBindingSet( std::move( execute ), enabled, widget, + shortcutTarget, shortcut ); +} + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uidatabind.hpp b/include/eepp/ui/databinding/uidatabind.hpp similarity index 58% rename from include/eepp/ui/uidatabind.hpp rename to include/eepp/ui/databinding/uidatabind.hpp index 2c8706cc6..b7f3969ff 100644 --- a/include/eepp/ui/uidatabind.hpp +++ b/include/eepp/ui/databinding/uidatabind.hpp @@ -3,7 +3,7 @@ #include #include -#include +#include #include #include #include @@ -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 class UIDataBind { public: + using ValueType = T; using Converter = UIValueConverter; + using Callback = typename ObservableValue::Callback; + using Connection = typename ObservableValue::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( std::move( callback ) ) : nullptr; + return *this; + } + + explicit operator bool() const { return static_cast( mCallback ); } + + void operator()( const T& value ) const { + if ( mCallback ) + ( *mCallback )( value ); + } + + std::shared_ptr retain() const { return mCallback; } + + private: + std::shared_ptr 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 class UIDataBind { new UIDataBind( t, widget, converter, valueKey, eventType ) ); } + /** @brief Creates a binding that retains @p value through callback delivery. */ + static std::unique_ptr> + New( std::shared_ptr value, const UnorderedSet& widgets, + const Converter& converter = Converter::converterDefault(), + const std::string& valueKey = "value", + const Event::EventType& eventType = Event::OnValueChange ) { + return std::unique_ptr>( + new UIDataBind( std::move( value ), widgets, converter, valueKey, eventType ) ); + } + + /** @brief Creates a binding that retains @p value through callback delivery. */ + static std::unique_ptr> + New( std::shared_ptr value, UIWidget* widget, + const Converter& converter = Converter::converterDefault(), + const std::string& valueKey = "value", + const Event::EventType& eventType = Event::OnValueChange ) { + return std::unique_ptr>( + new UIDataBind( std::move( value ), widget, converter, valueKey, eventType ) ); + } + UIDataBind() = default; UIDataBind( const UIDataBind& ) = delete; UIDataBind& operator=( const UIDataBind& ) = delete; @@ -84,26 +141,87 @@ template 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 value, const UnorderedSet& 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 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& 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 value, const UnorderedSet& 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 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 + void initRetained( T* value, std::shared_ptr 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 ), converter, valueKey, eventType ); + retainedOwner = std::move( owner ); } /** Propagates the authoritative model value and reports formatting failures. */ @@ -117,6 +235,30 @@ template 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>( 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 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 class UIDataBind { const PropertyDefinition* getPropertyDefinition() const { return property; } - std::function onValueChangeCb; + CallbackSlot onValueChangeCb; const UnorderedSet& 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 class UIDataBind { *data = std::forward( t ); auto result = setValueChange(); inSetValue = false; - if ( onValueChangeCb ) - onValueChangeCb( *data ); setValidationResult( result ); + notifyValueChange(); return result; } @@ -217,6 +368,29 @@ template 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> 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 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 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() { diff --git a/include/eepp/ui/databinding/uiobservedelivery.hpp b/include/eepp/ui/databinding/uiobservedelivery.hpp new file mode 100644 index 000000000..3a271e1b8 --- /dev/null +++ b/include/eepp/ui/databinding/uiobservedelivery.hpp @@ -0,0 +1,140 @@ +#ifndef EE_UI_UIOBSERVEDELIVERY_HPP +#define EE_UI_UIOBSERVEDELIVERY_HPP + +#include +#include +#include +#include + +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 class UIThreadObservation { + public: + using Callback = std::function; + + 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 + UIThreadObservation( Source& source, Node& scheduler, UIWidget& endpoint, Callback callback ) { + auto state = std::make_shared(); + state->endpoint = &endpoint; + state->callback = std::move( callback ); + std::weak_ptr weakState = state; + state->endpointConnection = endpoint.connect( Event::OnClose, [weakState]( const Event* ) { + if ( auto state = weakState.lock() ) { + std::lock_guard lock( state->mutex ); + state->endpoint = nullptr; + } + } ); + auto sourceConnection = + source.observe( [weakState, scheduler = &scheduler]( const T& value ) { + if ( auto state = weakState.lock() ) { + std::lock_guard 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 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>( + 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 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 lock( mState->mutex ); + return mState->endpoint != nullptr; + } + + private: + struct SourceConnectionBase { + virtual ~SourceConnectionBase() = default; + virtual void disconnect() = 0; + virtual bool connected() const = 0; + }; + + template struct SourceConnection final : SourceConnectionBase { + explicit SourceConnection( Connection connection ) : + connection( std::move( connection ) ) {} + void disconnect() override { connection.disconnect(); } + bool connected() const override { return static_cast( 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 sourceConnection; + }; + std::shared_ptr mState; +}; + +template +/** + * @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( source, scheduler, endpoint, + std::forward( callback ) ); +} + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/databinding/uiproperty.hpp b/include/eepp/ui/databinding/uiproperty.hpp new file mode 100644 index 000000000..f03dd8e27 --- /dev/null +++ b/include/eepp/ui/databinding/uiproperty.hpp @@ -0,0 +1,367 @@ +#ifndef EE_UI_UIPROPERTY_HPP +#define EE_UI_UIPROPERTY_HPP + +#include +#include + +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 celsius( 0.0, celsiusInput ); + * UIProperty fahrenheit( 32.0, fahrenheitInput ); + * celsius.changed( [&fahrenheit]( double value ) { + * fahrenheit = value * 9.0 / 5.0 + 32.0; + * } ); + * @endcode + */ +template class UIProperty { + private: + struct LifetimeState { + LifetimeState( UIProperty* property, T value ) : + value( std::move( value ) ), property( property ) {} + T value; + UIProperty* 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> alive; + }; + + public: + using ValueType = T; + using Callback = typename UIDataBind::Callback; + using Connection = typename UIDataBind::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 get() const { + auto state = mState.lock(); + return state && state->property && state->property->databind().isInitialized() + ? std::optional( 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::Connection + observeLifetime( ObservableValue::Callback callback ) const { + auto state = mState.lock(); + if ( !state ) + return {}; + if ( !state->alive ) + state->alive = std::make_unique>( 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 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 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 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; + explicit WeakHandle( const std::shared_ptr& state ) : mState( state ) {} + std::weak_ptr 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::Converter& converter = + EE::UI::UIDataBind::converterDefault(), + const std::string& valueKey = "value", + const Event::EventType& eventType = Event::OnValueChange ) : + mLifetime( std::make_shared( this, std::move( defaultValue ) ) ) { + initializeBinding( widget, converter, valueKey, eventType ); + } + + UIProperty( T defaultValue, const UnorderedSet& widgets = {}, + const typename EE::UI::UIDataBind::Converter& converter = + EE::UI::UIDataBind::converterDefault(), + const std::string& valueKey = "value", + const Event::EventType& eventType = Event::OnValueChange ) : + mLifetime( std::make_shared( this, std::move( defaultValue ) ) ) { + initializeBinding( widgets, converter, valueKey, eventType ); + } + + UIProperty( const UnorderedSet& widgets = {}, + const typename EE::UI::UIDataBind::Converter& converter = + EE::UI::UIDataBind::converterDefault(), + const std::string& valueKey = "value", + const Event::EventType& eventType = Event::OnValueChange ) : + mLifetime( std::make_shared( this, T{} ) ) { + initializeBinding( widgets, converter, valueKey, eventType ); + } + + UIProperty( UIWidget* widget, + const typename EE::UI::UIDataBind::Converter& converter = + EE::UI::UIDataBind::converterDefault(), + const std::string& valueKey = "value", + const Event::EventType& eventType = Event::OnValueChange ) : + mLifetime( std::make_shared( 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 && !std::is_same_v) || + std::is_same_v || std::is_same_v, + int> = 0> + UIProperty& operator+=( const T& operand ) { + return *this = value() + operand; + } + + template < + typename U = T, + std::enable_if_t || std::is_same_v, int> = 0> + T operator+( const T& operand ) const { + return value() + operand; + } + + template && !std::is_same_v, int> = 0> + UIProperty& operator-=( const T& operand ) { + return *this = value() - operand; + } + + template && !std::is_same_v, int> = 0> + UIProperty& operator*=( const T& operand ) { + return *this = value() * operand; + } + + template && !std::is_same_v, int> = 0> + UIProperty& operator/=( const T& operand ) { + return *this = value() / operand; + } + + template && !std::is_same_v, int> = 0> + UIProperty& operator++() { + return *this += 1; + } + + template && !std::is_same_v, int> = 0> + T operator++( int ) { + T previous = value(); + ++( *this ); + return previous; + } + + template && !std::is_same_v, int> = 0> + UIProperty& operator--() { + return *this -= 1; + } + + template && !std::is_same_v, 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& databind() const { return mBindedData; } + + UIDataBind& 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& fn ) { + mBindedData.onValueChangeCb = fn; + return *this; + } + + UIProperty& changed( std::function&& fn ) { + mBindedData.onValueChangeCb = std::move( fn ); + return *this; + } + + protected: + template + void initializeBinding( Widgets&& widgets, + const typename EE::UI::UIDataBind::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 ), + 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 mLifetime; + UIDataBind mBindedData; +}; + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/databinding/uivaluebinding.hpp b/include/eepp/ui/databinding/uivaluebinding.hpp new file mode 100644 index 000000000..83aa91ff5 --- /dev/null +++ b/include/eepp/ui/databinding/uivaluebinding.hpp @@ -0,0 +1,319 @@ +#ifndef EE_UI_UIVALUEBINDING_HPP +#define EE_UI_UIVALUEBINDING_HPP + +#include +#include +#include +#include + +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 userName{ "Ada" }; + * auto binding = bindValue( userName, textInput, + * UIValueConverter::converterString(), + * "text", Event::OnTextChanged ); + * userName = "Grace"; // Updates textInput without coupling the model to UIWidget. + * @endcode + */ +template class UIValueBinding { + public: + using Converter = UIValueConverter; + + /** @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& 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( *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 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::Connection + observeValue( typename ObservableValue::Callback callback ) { + return mState ? mState->value.observe( std::move( callback ) ) + : typename ObservableValue::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::WeakHandle value; + UIWidget* widget{ nullptr }; + const PropertyDefinition* property{ nullptr }; + Converter converter; + UIValueValidationState validation; + bool synchronizing{ false }; + typename ObservableValue::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& value, UIWidget* widget, const Converter& converter, + const std::string& propertyName, Event::EventType eventType ) { + eeASSERT( widget != nullptr ); + auto state = std::make_shared(); + 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 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()->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 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 class UIReadOnlyValueBinding { + public: + using Converter = UIValueConverter; + + 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 + 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( 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::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 + void connect( Source& source, UIWidget* widget, const Converter& converter, + const std::string& propertyName ) { + eeASSERT( widget != nullptr ); + auto state = std::make_shared(); + state->widget = widget; + state->property = StyleSheetSpecification::instance()->getProperty( propertyName ); + state->converter = converter; + eeASSERT( state->property != nullptr ); + eeASSERT( state->converter.fromValue ); + + std::weak_ptr 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 mState; +}; + +/** @brief Creates a scoped two-way binding between @p value and @p widget. */ +template +UIValueBinding +bindValue( ObservableValue& value, UIWidget* widget, + const UIValueConverter& converter = UIValueConverter::converterDefault(), + const std::string& propertyName = "value", + Event::EventType eventType = Event::OnValueChange ) { + return UIValueBinding( value, widget, converter, propertyName, eventType ); +} + +/** @brief Creates a two-way binding to a non-default widget property using default conversion. */ +template +UIValueBinding bindValue( ObservableValue& value, UIWidget* widget, + const std::string& propertyName, + Event::EventType eventType = Event::OnValueChange ) { + return UIValueBinding( value, widget, UIValueConverter::converterDefault(), propertyName, + eventType ); +} + +/** @brief Creates a scoped one-way binding from a computed value to a widget. */ +template +UIReadOnlyValueBinding +bindValue( ComputedValue& value, UIWidget* widget, + const UIValueConverter& converter = UIValueConverter::converterDefault(), + const std::string& propertyName = "value" ) { + return UIReadOnlyValueBinding( value, widget, converter, propertyName ); +} + +/** @brief Creates a read-only binding to a non-default property using default conversion. */ +template +UIReadOnlyValueBinding bindValue( ComputedValue& value, + UIWidget* widget, const std::string& propertyName ) { + return UIReadOnlyValueBinding( value, widget, UIValueConverter::converterDefault(), + propertyName ); +} + +/** @brief Creates a scoped one-way binding from any observable source to a widget. */ +template +auto bindReadOnlyValue( Source& value, UIWidget* widget, + const UIValueConverter& converter = + UIValueConverter::converterDefault(), + const std::string& propertyName = "value" ) { + using T = typename Source::ValueType; + return UIReadOnlyValueBinding( value, widget, converter, propertyName ); +} + +/** @brief Creates a one-way binding to a non-default property using default conversion. */ +template +auto bindReadOnlyValue( Source& value, UIWidget* widget, const std::string& propertyName ) { + using T = typename Source::ValueType; + return UIReadOnlyValueBinding( value, widget, UIValueConverter::converterDefault(), + propertyName ); +} + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uivalueconverter.hpp b/include/eepp/ui/databinding/uivalueconverter.hpp similarity index 83% rename from include/eepp/ui/uivalueconverter.hpp rename to include/eepp/ui/databinding/uivalueconverter.hpp index ae98143d3..745c4025d 100644 --- a/include/eepp/ui/uivalueconverter.hpp +++ b/include/eepp/ui/databinding/uivalueconverter.hpp @@ -3,7 +3,7 @@ #include #include -#include +#include #include #include #include @@ -31,18 +31,32 @@ namespace EE { namespace UI { * @endcode */ template struct UIValueConverter { + /** @brief Parses a widget property string into the model type. */ using ToValue = std::function( const CSS::PropertyDefinition*, const std::string& )>; + + /** @brief Formats a model value as a widget property string. */ using FromValue = std::function( 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 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 ) { diff --git a/include/eepp/ui/uivaluevalidation.hpp b/include/eepp/ui/databinding/uivaluevalidation.hpp similarity index 100% rename from include/eepp/ui/uivaluevalidation.hpp rename to include/eepp/ui/databinding/uivaluevalidation.hpp diff --git a/include/eepp/ui/models/observablelistmodel.hpp b/include/eepp/ui/models/observablelistmodel.hpp new file mode 100644 index 000000000..7fde7a876 --- /dev/null +++ b/include/eepp/ui/models/observablelistmodel.hpp @@ -0,0 +1,183 @@ +#ifndef EE_UI_MODELS_OBSERVABLELISTMODEL_HPP +#define EE_UI_MODELS_OBSERVABLELISTMODEL_HPP + +#include +#include +#include +#include +#include + +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 people; + * auto model = ObservableListModel::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 class ObservableListModel final : public Model { + public: + /** @brief Converts one source item and role into model data. */ + using Formatter = std::function; + + /** @brief Returns true when a source item belongs in the visible projection. */ + using Predicate = std::function; + + /** @brief Creates a one-column adapter using Variant's standard conversion for Display data. */ + static std::shared_ptr create( ObservableVector& source ) { + return std::make_shared( source ); + } + + /** @brief Creates a one-column adapter whose data() is supplied by @p formatter. */ + static std::shared_ptr create( ObservableVector& source, + Formatter formatter ) { + return std::make_shared( 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& source, Formatter formatter = {} ) : + mSource( source.sharedHandle() ), mFormatter( std::move( formatter ) ) { + mConnection = mSource.observe( + [this]( const typename ObservableVector::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( 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 ) + 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 sourceRow( const ModelIndex& index ) const { + if ( !index.isValid() || index.model() != this || index.row() < 0 || + static_cast( index.row() ) >= rowCount() ) + return {}; + return mPredicate ? mRows[index.row()] : static_cast( 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::Change& change ) { + using ChangeType = typename ObservableVector::ChangeType; + using Phase = typename ObservableVector::Phase; + if ( mPredicate ) { + if ( change.phase == Phase::After ) { + rebuildRows(); + invalidate( InvalidateAllIndexes ); + } + return; + } + const int first = static_cast( change.index ); + const int last = static_cast( 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( 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::SharedHandle mSource; + Formatter mFormatter; + Predicate mPredicate; + std::vector mRows; + typename ObservableVector::Connection mConnection; +}; + +}}} // namespace EE::UI::Models + +#endif diff --git a/include/eepp/ui/tools/uidocfindreplace.hpp b/include/eepp/ui/tools/uidocfindreplace.hpp index a991d14cb..7b57f7c43 100644 --- a/include/eepp/ui/tools/uidocfindreplace.hpp +++ b/include/eepp/ui/tools/uidocfindreplace.hpp @@ -4,7 +4,7 @@ #include #include #include -#include +#include #include #include #include diff --git a/include/eepp/ui/tools/uitabwidgetsplitter.hpp b/include/eepp/ui/tools/uitabwidgetsplitter.hpp index 7f9185bff..5411e4056 100644 --- a/include/eepp/ui/tools/uitabwidgetsplitter.hpp +++ b/include/eepp/ui/tools/uitabwidgetsplitter.hpp @@ -9,7 +9,7 @@ #include #include -#include +#include namespace EE { namespace Graphics { class Drawable; diff --git a/include/eepp/ui/uiproperty.hpp b/include/eepp/ui/uiproperty.hpp deleted file mode 100644 index b1a5d8462..000000000 --- a/include/eepp/ui/uiproperty.hpp +++ /dev/null @@ -1,185 +0,0 @@ -#include -#include - -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 celsius( 0.0, celsiusInput ); - * UIProperty fahrenheit( 32.0, fahrenheitInput ); - * celsius.changed( [&fahrenheit]( double value ) { - * fahrenheit = value * 9.0 / 5.0 + 32.0; - * } ); - * @endcode - */ -template 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::Converter& converter = - EE::UI::UIDataBind::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& widgets = {}, - const typename EE::UI::UIDataBind::Converter& converter = - EE::UI::UIDataBind::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& widgets = {}, - const typename EE::UI::UIDataBind::Converter& converter = - EE::UI::UIDataBind::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::Converter& converter = - EE::UI::UIDataBind::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 && !std::is_same_v) || - std::is_same_v || std::is_same_v, - int> = 0> - UIProperty& operator+=( const T& operand ) { - return *this = value() + operand; - } - - template < - typename U = T, - std::enable_if_t || std::is_same_v, int> = 0> - T operator+( const T& operand ) const { - return value() + operand; - } - - template && !std::is_same_v, int> = 0> - UIProperty& operator-=( const T& operand ) { - return *this = value() - operand; - } - - template && !std::is_same_v, int> = 0> - UIProperty& operator*=( const T& operand ) { - return *this = value() * operand; - } - - template && !std::is_same_v, int> = 0> - UIProperty& operator/=( const T& operand ) { - return *this = value() / operand; - } - - template && !std::is_same_v, int> = 0> - UIProperty& operator++() { - return *this += 1; - } - - template && !std::is_same_v, int> = 0> - T operator++( int ) { - T previous = value(); - ++( *this ); - return previous; - } - - template && !std::is_same_v, int> = 0> - UIProperty& operator--() { - return *this -= 1; - } - - template && !std::is_same_v, int> = 0> - T operator--( int ) { - T previous = value(); - --( *this ); - return previous; - } - /** @} */ - - const T& value() const { return mBindedData.get(); } - - const UIDataBind& databind() const { return mBindedData; } - UIDataBind& 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& fn ) { - mBindedData.onValueChangeCb = fn; - return *this; - } - - UIProperty& changed( std::function&& fn ) { - mBindedData.onValueChangeCb = std::move( fn ); - return *this; - } - - protected: - T mValue{}; - UIDataBind mBindedData; -}; - -}} // namespace EE::UI diff --git a/include/eepp/ui/uivaluebinding.hpp b/include/eepp/ui/uivaluebinding.hpp deleted file mode 100644 index bc38efb64..000000000 --- a/include/eepp/ui/uivaluebinding.hpp +++ /dev/null @@ -1,148 +0,0 @@ -#ifndef EE_UI_UIVALUEBINDING_HPP -#define EE_UI_UIVALUEBINDING_HPP - -#include -#include -#include - -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 userName{ "Ada" }; - * auto binding = bindValue( userName, textInput, - * UIValueConverter::converterString(), - * "text", Event::OnTextChanged ); - * userName = "Grace"; // Updates textInput without coupling the model to UIWidget. - * @endcode - */ -template class UIValueBinding { - public: - using Converter = UIValueConverter; - 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& 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::WeakHandle value; - UIWidget* widget{ nullptr }; - const PropertyDefinition* property{ nullptr }; - Converter converter; - UIValueValidationState validation; - bool synchronizing{ false }; - typename ObservableValue::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& value, UIWidget* widget, const Converter& converter, - const std::string& propertyName, Event::EventType eventType ) { - eeASSERT( widget != nullptr ); - auto state = std::make_shared(); - 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 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()->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 mState; -}; - -/** @brief Creates a scoped two-way binding between @p value and @p widget. */ -template -UIValueBinding -bindValue( ObservableValue& value, UIWidget* widget, - const UIValueConverter& converter = UIValueConverter::converterDefault(), - const std::string& propertyName = "value", - Event::EventType eventType = Event::OnValueChange ) { - return UIValueBinding( value, widget, converter, propertyName, eventType ); -} - -}} // namespace EE::UI - -#endif diff --git a/premake4.lua b/premake4.lua index 1f7952609..aec0f63a2 100644 --- a/premake4.lua +++ b/premake4.lua @@ -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++" diff --git a/premake5.lua b/premake5.lua index 1cacbc912..1bdb37d85 100644 --- a/premake5.lua +++ b/premake5.lua @@ -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++" diff --git a/projects/linux/ee.files b/projects/linux/ee.files index 2e85401ed..dded25d1b 100644 --- a/projects/linux/ee.files +++ b/projects/linux/ee.files @@ -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 diff --git a/projects/macos/ee.files b/projects/macos/ee.files index 69aa2bb6e..94e09839b 100644 --- a/projects/macos/ee.files +++ b/projects/macos/ee.files @@ -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 diff --git a/projects/windows/ee.files b/projects/windows/ee.files index d7359ae4f..0b7ba0f25 100644 --- a/projects/windows/ee.files +++ b/projects/windows/ee.files @@ -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 diff --git a/src/examples/7guis/crud/crud.cpp b/src/examples/7guis/crud/crud.cpp index 0ace0a191..4d994f681 100644 --- a/src/examples/7guis/crud/crud.cpp +++ b/src/examples/7guis/crud/crud.cpp @@ -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& 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 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& people ) { - mData = people; - invalidate( Model::UpdateFlag::DontInvalidateIndexes ); - } - - const Person& getPerson( const ModelIndex& index ) const { return mData[index.row()]; } - - protected: - std::vector 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** ) { )xml" ); - std::vector people{ + auto listView = vbox->find( "list" ); + auto filterInput = vbox->find( "filter" ); + auto nameInput = vbox->find( "name" ); + auto surnameInput = vbox->find( "surname" ); + auto createButton = vbox->find( "create" ); + auto updateButton = vbox->find( "update" ); + auto deleteButton = vbox->find( "delete" ); + + ObservableVector people( { { 1, "Hans", "Emil" }, { 2, "Max", "Mustermann" }, { 3, "Roman", "Tisch" }, - }; - std::uint64_t nextId = people.back().id + 1; - auto listView = vbox->find( "list" ); - auto filterView = vbox->find( "filter" ); - auto nameView = vbox->find( "name" ); - auto surnameView = vbox->find( "surname" ); - auto createBut = vbox->find( "create" ); - auto updateBut = vbox->find( "update" ); - auto deleteBut = vbox->find( "delete" ); - auto model = std::make_shared( people ); + } ); + std::uint64_t nextId = people[people.size() - 1].id + 1; + auto model = + ObservableListModel::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 filter( filterInput ); + UIProperty name( nameInput ); + UIProperty 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( listView->getModel() ) - ->getPerson( listView->getSelection().first() ); - nameView->setText( selPerson.name ); - surnameView->setText( selPerson.surname ); + + ObservableValue 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::iterator { - auto p = static_cast( 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(); } diff --git a/src/examples/7guis/flight_booker/flight_booker.cpp b/src/examples/7guis/flight_booker/flight_booker.cpp index bee3fcc31..76bc0a8c6 100644 --- a/src/examples/7guis/flight_booker/flight_booker.cpp +++ b/src/examples/7guis/flight_booker/flight_booker.cpp @@ -2,13 +2,16 @@ #include #include -// 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( @@ -21,56 +24,68 @@ EE_MAIN_FUNC int main( int, char** ) { )xml" ); - auto ddlType = vbox->find( "type" ); - auto dateFrom = vbox->find( "date_from" ); - auto dateTo = vbox->find( "date_to" ); - auto bookBut = vbox->find( "book" ); - const auto covertDate = []( const String& dateStr ) -> std::optional { - if ( std::count( dateStr.begin(), dateStr.end(), '.' ) != 2 ) + auto flightTypeInput = vbox->find( "type" ); + auto departureInput = vbox->find( "date_from" ); + auto returnInput = vbox->find( "date_to" ); + auto bookButton = vbox->find( "book" ); + + const auto parseDate = []( const std::string& text ) -> std::optional { + 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::optional{ 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& date ) -> bool { - if ( !input->isEnabled() || date ) { - input->removeClass( "error_input" ); - return true; - } else - input->addClass( "error_input" ); - return false; - }; - const auto update = [&]() { - std::optional fromDate = covertDate( dateFrom->getText() ); - std::optional 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; + auto dateConverter = UIValueConverter( + [&]( const CSS::PropertyDefinition*, const std::string& text ) -> UIValueResult { + auto value = parseDate( text ); + return value ? UIValueResult( value ) + : UIValueResult::error( 1, "date must use DD.MM.YYYY" ); + }, + [&]( const CSS::PropertyDefinition*, const Date& value ) -> UIValueResult { + return value ? formatDate( *value ) : std::string{}; + } ); + + std::time_t today = std::time( nullptr ); + UIProperty flightType( "one-way flight", flightTypeInput ); + UIProperty departureDate( today, departureInput, dateConverter ); + UIProperty 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(); } diff --git a/src/examples/ui_data_collections/ui_data_collections.cpp b/src/examples/ui_data_collections/ui_data_collections.cpp new file mode 100644 index 000000000..90ecbefc7 --- /dev/null +++ b/src/examples/ui_data_collections/ui_data_collections.cpp @@ -0,0 +1,50 @@ +#include + +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( + + + + + + + + + + + )xml" ); + if ( !app.getWindow()->isOpen() ) + return EXIT_FAILURE; + + auto list = root->find( "tasks" ); + auto input = root->find( "task" ); + ObservableVector tasks( { "Build eepp", "Run unit tests", "Package release" } ); + list->setModel( ObservableListModel::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(); +} diff --git a/src/examples/ui_data_handling/ui_data_handling.cpp b/src/examples/ui_data_handling/ui_data_handling.cpp new file mode 100644 index 000000000..599a17ea9 --- /dev/null +++ b/src/examples/ui_data_handling/ui_data_handling.cpp @@ -0,0 +1,118 @@ +#include + +// 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( + + + + + + + + + + + + + + + + + + + + + + + + + )xml" ); + if ( !app.getWindow()->isOpen() ) + return EXIT_FAILURE; + + auto projectInput = root->find( "project" ); + auto hostInput = root->find( "host" ); + auto portInput = root->find( "port" ); + auto automaticInput = root->find( "automatic" ); + auto validationMessage = root->find( "validation-message" ); + auto saveButton = root->find( "save" ); + auto resetButton = root->find( "reset" ); + auto status = root->find( "status" ); + + ObservableValue project( "eepp" ); + ObservableValue host( "localhost" ); + ObservableValue port( 8080 ); + ObservableValue automatic( false ); + auto requiredText = UIValueConverter( + []( const CSS::PropertyDefinition*, + const std::string& value ) -> UIValueResult { + return value.empty() ? UIValueResult::error( 100, "value is required" ) + : UIValueResult( value ); + } ); + auto validPort = UIValueConverter( + []( const CSS::PropertyDefinition*, const std::string& value ) -> UIValueResult { + int parsed = 0; + if ( !String::fromString( parsed, value ) || parsed < 1 || parsed > 65535 ) + return UIValueResult::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( "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(); +} diff --git a/src/tests/unit_tests/observablevalue_tests.cpp b/src/tests/unit_tests/observablevalue_tests.cpp index 36977b840..477b77798 100644 --- a/src/tests/unit_tests/observablevalue_tests.cpp +++ b/src/tests/unit_tests/observablevalue_tests.cpp @@ -1,10 +1,20 @@ #include "utest.h" +#include #include +#include +#include #include +#include #include +#include #include +#include +#include +#include +#include #include -#include +#include +#include using namespace EE; using namespace EE::UI; @@ -66,6 +76,495 @@ UTEST( ObservableValue, notifiesObserversInRegistrationOrder ) { EXPECT_EQ( order[2], 2 ); } +UTEST( ObservableValue, queuesLatestReentrantValue ) { + ObservableValue value( 0 ); + std::vector 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 value( 0 ); + int firstCalls = 0; + int disconnectedCalls = 0; + int addedCalls = 0; + typename ObservableValue::Connection disconnected; + typename ObservableValue::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( disconnected ) ); + EXPECT_TRUE( static_cast( added ) ); +} + +UTEST( ComputedValue, derivesExplicitDependenciesAndSuppressesEqualResults ) { + ObservableValue first( "Ada" ); + ObservableValue 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>( 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( 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 count( 2 ); + auto label = computedValue( count, []( int value ) { return String::toString( value * 2 ); } ); + auto binding = + bindValue( label, widget, UIValueConverter::converterString(), "text" ); + + EXPECT_TRUE( widget->getText() == "4" ); + count = 3; + EXPECT_TRUE( widget->getText() == "6" ); + eeDelete( widget ); + EXPECT_FALSE( static_cast( binding ) ); +} + +UTEST( UIProperty, participatesInComputedValuesAndCommands ) { + UIProperty count( 2 ); + auto doubled = computedValue( count, []( int value ) { return value * 2; } ); + EXPECT_EQ( doubled.get(), 4 ); + + count = 3; + EXPECT_EQ( doubled.get(), 6 ); + + UIProperty 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 converter( + []( const CSS::PropertyDefinition*, const std::string& value ) -> UIValueResult { + int parsed = 0; + return String::fromString( parsed, value ) ? UIValueResult( parsed ) + : UIValueResult::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 name( "Ada" ); + int count = 2; + UIBindingGroup form; + form += bindValue( name, nameInput, UIValueConverter::converterString(), "text", + Event::OnTextChanged ); + form += UIDataBind::New( &count, countInput, UIValueConverter::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 value( "initial" ); + UIBindingGroup form; + form += bindValue( value, input, UIValueConverter::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 validPort( + []( const CSS::PropertyDefinition*, const std::string& value ) -> UIValueResult { + int parsed = 0; + return String::fromString( parsed, value ) && parsed >= 1 && parsed <= 65535 + ? UIValueResult( parsed ) + : UIValueResult::error( 1, "invalid port" ); + } ); + UIBindingGroup form; + { + UIProperty port( 8080, UnorderedSet{ 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 valid( true ); + ObservableValue 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 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 values( { "a", "b" } ); + auto model = Models::ObservableListModel::create( values ); + std::vector::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 values( { 1 } ); + int firstCalls = 0; + int disconnectedCalls = 0; + int addedCalls = 0; + bool nested = false; + typename ObservableVector::Connection disconnected; + typename ObservableVector::Connection added; + auto first = values.observe( [&]( const auto& change ) { + ++firstCalls; + if ( !nested && change.type == ObservableVector::ChangeType::Insert && + change.phase == ObservableVector::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( disconnected ) ); + EXPECT_TRUE( static_cast( added ) ); +} + +UTEST( ObservableListModel, formatsFiltersAndMapsSourceRows ) { + ObservableVector values( { "alpha", "beta", "alpine" } ); + auto model = Models::ObservableListModel::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> model; + { + ObservableVector values( { "alpha", "beta" } ); + model = Models::ObservableListModel::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 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( 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 value( 1 ); + auto observation = + observeOnUIThread( value, *app.getUI(), *widget, + [&]( UIWidget& endpoint, const int& ) { eeDelete( &endpoint ); } ); + + value = 2; + EXPECT_FALSE( static_cast( 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 value( 1 ); + std::vector 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( observation ) ); + + auto disconnectedWidget = UIWidget::New(); + ObservableValue 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, diff --git a/src/tests/unit_tests/uidatabind_tests.cpp b/src/tests/unit_tests/uidatabind_tests.cpp index e49cac259..0309505ce 100644 --- a/src/tests/unit_tests/uidatabind_tests.cpp +++ b/src/tests/unit_tests/uidatabind_tests.cpp @@ -2,7 +2,7 @@ #include #include #include -#include +#include #include 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>( 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( connection ) ); + eeDelete( input ); +} + UTEST( UIDataBind, defaultStringConverterReadsWidgetValue ) { auto converter = UIDataBind::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( 256, 'a' ); + std::weak_ptr weakValue = value; + auto binding = std::make_unique>( + value, UnorderedSet{}, UIDataBind::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( connection ) ); +} + UTEST( UIDataBind, lateBoundWidgetReceivesValueAndCanDieFirst ) { UIApplication app( WindowSettings( 320, 240, "eepp - UIDataBind Test", WindowStyle::Default, diff --git a/src/tools/ecode/appconfig.hpp b/src/tools/ecode/appconfig.hpp index a222b061a..d8e647f23 100644 --- a/src/tools/ecode/appconfig.hpp +++ b/src/tools/ecode/appconfig.hpp @@ -12,7 +12,7 @@ #include #include -#include +#include using namespace EE; using namespace EE::Math; diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index fb6fe4360..d05b222de 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -9,7 +9,7 @@ #include #include -#include +#include namespace EE { namespace UI { class UIWidget; diff --git a/src/tools/ecode/plugins/aiassistant/llmmodelcatalog.hpp b/src/tools/ecode/plugins/aiassistant/llmmodelcatalog.hpp index a7f4c94f2..1637608c1 100644 --- a/src/tools/ecode/plugins/aiassistant/llmmodelcatalog.hpp +++ b/src/tools/ecode/plugins/aiassistant/llmmodelcatalog.hpp @@ -7,7 +7,7 @@ #include #include #include -#include +#include #include namespace ecode { diff --git a/src/tools/ecode/plugins/discordRPC/sdk/ipc.hpp b/src/tools/ecode/plugins/discordRPC/sdk/ipc.hpp index 948b98ab2..782bdb841 100644 --- a/src/tools/ecode/plugins/discordRPC/sdk/ipc.hpp +++ b/src/tools/ecode/plugins/discordRPC/sdk/ipc.hpp @@ -1,7 +1,7 @@ #include #include -#include +#include using namespace EE::System; diff --git a/src/tools/ecode/uibuildsettings.hpp b/src/tools/ecode/uibuildsettings.hpp index 4181cc684..ab53e3868 100644 --- a/src/tools/ecode/uibuildsettings.hpp +++ b/src/tools/ecode/uibuildsettings.hpp @@ -2,7 +2,7 @@ #define EE_UI_UIBUILDSETTINGS_HPP #include "projectbuild.hpp" -#include +#include #include namespace EE { namespace UI {