Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
View Binding

BindingEngine bridges the gap between C++ ViewModels and native platform views. It manages the lifecycle of every binding — VM→View and View→VM — and handles thread marshalling automatically.

Include: #include "aria/binding/binding_engine.hpp"


BindingEngine

Construction

// The engine always needs an adapter — it is the only way it can reach
// your UI toolkit. `adapter` here is a std::shared_ptr<IViewAdapter>,
// e.g. std::make_shared<aria::adapters::QtAdapter>().
// Simplest: direct dispatch (single-threaded, no marshal)
// Production: SmartMarshal — auto-posts from background threads
aria::binding::BindingEngine engine(adapter, dispatcher,
BindingEngine: connects ViewModel properties to platform views via an adapter.
Definition binding_engine.hpp:103
@ SmartMarshal
inline iff dispatcher.is_main_thread()
Definition binding_engine.hpp:108

Dispatch Policies

Policy VM→View Path Use Case
Direct Synchronous, same thread Single-threaded apps, tests
SmartMarshal Direct on UI thread, post from background Production apps
AlwaysPost Always post via dispatcher Tests needing deterministic ordering

One-Way Bindings (VM → View)

bind_text_oneway

engine.bind_text_oneway(status, label_view);
// label displays "Ready"; updates when status changes
void bind_text_oneway(Property< std::string > &prop, IView &view)
Definition property.hpp:103

bind_visible / bind_enabled

aria::Property<bool> has_items{true};
engine.bind_visible(has_items, list_container);
engine.bind_enabled(can_submit, submit_button);
void bind_enabled(Property< bool > &prop, IView &view)
void bind_visible(Property< bool > &prop, IView &view)

bind_int_oneway / bind_double_oneway

aria::Property<double> progress{0.75};
engine.bind_int_oneway(count, badge_view);
engine.bind_double_oneway(progress, progress_bar);
void bind_double_oneway(Property< double > &prop, IView &view)
void bind_int_oneway(Property< int > &prop, IView &view)

bind_text_projected

Render any Property<T> into a read-only text view through a T -> std::string projection. This is the lightweight one-way alternative to a full bidirectional Converter when the view never writes back (labels, status lines):

engine.bind_text_projected(unread, badge_label,
[](int n) { return std::to_string(n) + " new"; });
// label shows "3 new"; re-renders whenever `unread` changes
void bind_text_projected(Src &src, IView &view, Project project)
Bind a read-only text view to src, rendered through project (T -> std::string).
Definition binding_engine.hpp:323

bind_optional_text

Bind a Property<std::optional<T>> to a read-only text view. When the optional holds a value it is rendered through the projection; when it is std::nullopt the view shows empty_text (default: empty string):

engine.bind_optional_text(user, welcome_label,
[](const std::string& name) { return "Welcome, " + name; },
"(signed out)");
// nullopt → "(signed out)"; user = "Alice" → "Welcome, Alice"
void bind_optional_text(Src &src, IView &view, Project project, std::string empty_text=std::string{})
Bind a read-only text view to a reactive std::optional<T> source.
Definition binding_engine.hpp:337

Async-agnostic by design. Both helpers operate purely on Property<T>, so BindingEngine stays decoupled from aria-async at the API level (the engine never names an AsyncCommand type). They are the idiomatic way to render an AsyncCommand's observable properties — last_error_message (a Property<std::string>) with bind_text_projected, and last_result (a Property<std::optional<R>>) with bind_optional_text — without teaching BindingEngine about coroutines. See View-Destroy Cancellation.


Two-Way Bindings (VM ↔ View)

Two-way bindings synchronize in both directions: VM changes update the view, and user input in the view writes back to the VM.

bind_text

engine.bind_text(query, search_input);
// Typing in search_input → query.set(text)
// query.set("hello") → search_input displays "hello"
void bind_text(Property< std::string > &prop, IView &view)

bind_bool

aria::Property<bool> dark_mode{false};
engine.bind_bool(dark_mode, toggle_switch);
void bind_bool(Property< bool > &prop, IView &view)

bind_int / bind_double

aria::Property<int> quantity{1};
engine.bind_int(quantity, spin_box);
engine.bind_double(price, price_input);
void bind_int(Property< int > &prop, IView &view)
void bind_double(Property< double > &prop, IView &view)

Command Bindings

bind_command (Parameterless)

aria::Command<> save{[&] { do_save(); }};
engine.bind_command(save, save_button);
// Button click → save.execute()
// save.can_execute() drives button.enabled automatically
Definition command.hpp:154
void bind_command(Command< Args... > &cmd, IView &view, const Args &... args)
Definition binding_engine.hpp:357

bind_command (Parameterized)

aria::Command<std::string> open{[&](const std::string& path) { /* ... */ }};
engine.bind_command(open, button, "documents/report.pdf");
// Button click → open.execute("documents/report.pdf")
Encapsulated user action with an optional CanExecute predicate.
Definition command.hpp:58

Converters

When the VM type doesn't match the view type, you have two options.

One-way display: bind_text_projected

For a read-only label, pass a plain projection functor (T -> std::string). No parsing back, no Converter needed:

aria::Property<bool> is_admin{true};
// Bool → string for display
engine.bind_text_projected(is_admin, role_label,
[](bool v) -> std::string { return v ? "Admin" : "Guest"; });

Two-way: bind_text_converted

When the view must also write back, supply a Converter<T, std::string>:

aria::Property<int> quantity{1};
engine.bind_text_converted(quantity, quantity_field, conv);
void bind_text_converted(Property< T > &prop, IView &view, Converter< T, std::string > conv)
Definition binding_engine.hpp:278
Converter< int, std::string > int_to_string()
Definition converter.hpp:60

On the View → Model direction a built-in converter reports unparseable input through try_to_model (returning std::nullopt) or by throwing ConversionError; in both cases the engine skips the model write, so the previous value stays authoritative rather than being clobbered with a default-constructed one.

Built-In Converters

All live in aria::binding::converters and return a Converter<T, U>:

Factory From To
identity_string() string string
int_to_string() int string
double_to_string(int precision = 2) double string
bool_to_yes_no() bool string ("yes" / "no"; parses yes/true/1 and no/false/0)

For anything else, construct a Converter<T, U> directly (or use bind_text_projected when the binding is one-way).


Feedback-Loop Suppression

Two-way bindings create a potential loop: VM change → view update → view callback → VM set (same value). The engine suppresses this automatically:

  1. When pushing a value VM→View, the engine marks the binding as "writing"
  2. The incoming View→VM callback checks this flag and skips if set
  3. Result: no infinite ping-pong

View Lifetime

Views can be destroyed before the engine. When a view is destroyed:

  1. IView::on_destroy fires
  2. The engine releases all bindings to that view
  3. Subsequent property changes silently skip the dead view
  4. Other bindings in the same engine remain active
// View destroyed (e.g. user closes panel)
panel->close(); // triggers on_destroy
// Property changes no longer attempt to update panel
status.set("Done"); // no crash, no dangling reference
void set(const T &new_val)
Commit a new value.
Definition property.hpp:179

Clear All Bindings

engine.clear(); // Releases every binding in one shot
void clear() noexcept
Drop every active binding.

Called automatically in the engine's destructor.


IViewAdapter / IView

Platform adapters implement IViewAdapter to teach the engine how to talk to native widgets:

  • IView — represents a single native widget (text getter/setter, visibility, enabled, destroy callback)
  • IViewAdapter — factory that creates IView wrappers for platform controls

See adapter guides for platform-specific implementations:


Quick Reference

Every binding the engine ships, with its direction and the source types it accepts. Two rules explain the whole table:

  • One-way (VM→View) bindings accept any read-only reactive sourceProperty<T> or Computed<T> (formally, anything satisfying aria::ReadOnlyReactive). A derived value needs no mirror property.
  • Two-way bindings accept Property<T> only. A Computed has no write-back path, so passing one is a compile error rather than a silently dropped edit.

Scalar bindings

Method Direction Source View channel
bind_text Two-way Property<string> text input
bind_text_oneway VM→View Property<string> / Computed<string> label text
bind_bool Two-way Property<bool> checkbox / switch
bind_bool_oneway VM→View Property<bool> / Computed<bool> checkbox / switch
bind_int Two-way Property<int> spin box / slider
bind_int_oneway VM→View Property<int> / Computed<int> spin box / progress bar
bind_int64 Two-way Property<int64_t> 64-bit numeric input
bind_int64_oneway VM→View Property<int64_t> / Computed<int64_t> 64-bit numeric display
bind_uint64 Two-way Property<uint64_t> unsigned numeric input
bind_uint64_oneway VM→View Property<uint64_t> / Computed<uint64_t> unsigned numeric display
bind_float Two-way Property<float> slider / opacity
bind_float_oneway VM→View Property<float> / Computed<float> slider / opacity
bind_double Two-way Property<double> slider / double spin box
bind_double_oneway VM→View Property<double> / Computed<double> numeric display

Text projection and conversion

Method Direction Source Notes
bind_text_projected VM→View any ReadOnlyReactive of T renders via T → string; the everyday "formatted label" binding
bind_optional_text VM→View any ReadOnlyReactive of optional<T> renders *opt via T → string; nullopt shows empty_text
bind_text_converted Two-way Property<T> full Converter<T, string>, parses text back into T
bind_text_converted_oneway VM→View any ReadOnlyReactive of T uses only the converter's to_view

State, commands, lifetime

Method Direction Source Notes
bind_visible VM→View Property<bool> / Computed<bool> inherently one-way — the view never writes visibility back
bind_enabled VM→View Property<bool> / Computed<bool> inherently one-way
bind_command View→VM Command<Args...> click → execute(args...); also drives enabled from can_execute(args...)
bind_view_lifetime std::function<void()> fires once on view-destroy (or engine teardown); use to cancel in-flight async work
adopt Subscription hands an arbitrary subscription to the view's per-view bucket, released on view-destroy

When no binding fits, write the on_changed by hand and hand the resulting Subscription to adopt(view, std::move(sub)). That keeps the lifetime story identical to a real binding — released on view-destroy — and avoids the usual workaround of a long-lived subscription vector that never releases anything.

Composite labels are the common case: a binding has exactly one source, so a label reading two values either gets a Computed on the ViewModel that combines them (then it is a one-line bind_text_oneway), or stays a manual on_changed + adopt.

Binding a derived value

// ViewModel
Property<double> bill{20.0};
Property<int> people{2};
Computed<double> per_person{[this] { return bill.get() / people.get(); }};
// View — Computed binds directly; no mirror Property, no manual on_changed.
engine.bind_text_projected(vm.per_person, label,
[](double v) { return std::format("¥ {:.2f}", v); });
engine.bind_text(vm.per_person, input); // ✗ compile error: not writable

See Also

Adapter callbacks from worker threads

For HTTP input, construct the engine with the graph owner's dispatcher and BindingEngine::DispatchPolicy::SmartMarshal (or AlwaysPost). These policies apply to both binding directions, including converted text and commands. Direct requires callbacks to already run on the graph thread. Keep binding setup, teardown, Property and Command lifetimes on that thread. Posted input is discarded if its view binding is cleared or destroyed before delivery.