morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
flows.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5/// @file forms/flows.hpp
6/// @brief Multi-step wizards: an ordered sequence of registered actions that
7/// share a "flow draft" spanning the whole sequence.
8///
9/// A `morph::flows::Wizard` names an ordered list of already-registered
10/// action types (see `BRIDGE_REGISTER_ACTION`, registry.hpp) plus, per step,
11/// a human title and an optional `Bind` prefill declaration mapping that
12/// step's field name to a `"<PriorAction>.<field>"` path into an earlier
13/// step's submitted draft or result. `wizardSchemaJson<W>()` emits the `w-*`
14/// document a renderer consumes to build a stepper;
15/// `morph::flows::FlowSession<Model, Steps...>` (see the `set<>`/`advance`/
16/// `back` API added alongside it) is the typed C++ sequencer that fires each
17/// step through the ordinary `BridgeHandler<Model>::execute<A>()` dispatch
18/// path (bridge.hpp) and tracks the resolved values a caller (or a renderer)
19/// reads to prefill a later step.
20///
21/// This is purely additive metadata and sequencing over the existing
22/// dispatch path: no new wire format, no new execution mode. See
23/// docs/spec/forms/workflows_navigation.md.
24
25#include <cstddef>
26#include <exception>
27#include <functional>
28#include <glaze/glaze.hpp>
29#include <memory>
30#include <optional>
31#include <stdexcept>
32#include <string>
33#include <string_view>
34#include <tuple>
35#include <type_traits>
36#include <unordered_map>
37#include <utility>
38
39#include "../attributes.hpp"
40#include "../core/bridge.hpp"
41#include "../core/callback_scope.hpp"
42#include "../core/logger.hpp"
44#include "forms.hpp"
45
46namespace morph::flows {
47
48/// @brief Prefill binding for a wizard step.
49///
50/// Alias for `morph::forms::Bind`, which both session types share. Kept in this
51/// namespace because it is the name shipped consumers already write.
52/// @tparam Field The step action's field name to prefill.
53/// @tparam Path Source path, `"<PriorAction>.<field>"`, into an earlier
54/// step's captured values (see `FlowSession::resolved`).
55template <morph::forms::FixedString Field, morph::forms::FixedString Path>
57
58/// @brief One step of a `Wizard`: a registered action, a display title, and
59/// zero or more `Bind` prefill declarations.
60/// @tparam Action Registered action type (`BRIDGE_REGISTER_ACTION`) this step fires.
61/// @tparam Title Human title for the step (breadcrumb / header).
62/// @tparam Binds Zero or more `Bind<Field, Path>` prefill declarations.
63template <typename Action, morph::forms::FixedString Title, typename... Binds>
64struct WizardStep {
65 /// @brief The step's action type.
66 using action = Action;
67
68 /// @brief Tuple of this step's `Bind<...>` prefill declarations (possibly empty).
69 using binds = std::tuple<Binds...>;
70
71 /// @brief The step's display title.
72 /// @return The declared title.
73 [[nodiscard]] static constexpr std::string_view title() noexcept { return Title.view(); }
74};
75
76/// @brief An ordered sequence of `WizardStep`s sharing one flow.
77/// @tparam Title Human title for the whole flow.
78/// @tparam Steps One or more `WizardStep<Action, Title, Binds...>` types, in order.
79template <morph::forms::FixedString Title, typename... Steps>
80struct Wizard {
81 /// @brief Tuple of this wizard's ordered `WizardStep<...>` types.
82 using steps = std::tuple<Steps...>;
83
84 /// @brief The wizard's display title.
85 /// @return The declared title.
86 [[nodiscard]] static constexpr std::string_view title() noexcept { return Title.view(); }
87};
88
89/// @brief Traits specialisation mapping a `Wizard` type to its string type-id.
90///
91/// Specialise via `BRIDGE_REGISTER_WIZARD` rather than by hand. The default is
92/// a forward declaration — using it without a specialisation is an
93/// incomplete-type error.
94/// @tparam W Concrete `Wizard<...>` type.
95template <typename W>
96struct WizardTraits; // forward — specialise or use BRIDGE_REGISTER_WIZARD
97
98/// @brief Generates the `w-*` JSON document for wizard type @p W.
99///
100/// Emits `w-title` and an ordered `w-steps` array; each step carries `action`
101/// (the step's registered action type-id), `title`, and — only when the step
102/// declares at least one `Bind` — a `prefill` object mapping field name to
103/// `"<PriorAction>.<field>"` path. See docs/spec/forms/workflows_navigation.md
104/// for the full key vocabulary.
105/// @tparam W Concrete `Wizard<Title, Steps...>` type.
106/// @return The wizard's JSON document. Empty string only if glaze's own JSON
107/// writer fails on the assembled DOM (schema generation never throws).
108template <typename W>
109[[nodiscard]] std::string wizardSchemaJson() {
110 glz::generic_u64 dom{};
111 dom["w-title"] = std::string{W::title()};
112
113 glz::generic_u64::array_t steps{};
114 ::morph::forms::detail::forEachTupleElement<typename W::steps>([&]<typename StepT, std::size_t I>() {
115 static_cast<void>(I);
116 glz::generic_u64 step{};
118 step["title"] = std::string{StepT::title()};
119 if constexpr (std::tuple_size_v<typename StepT::binds> != 0) {
120 // The insert is the point: `prefill` is being created here, not
121 // read, so `operator[]` is the behaviour wanted. A read would go
122 // through `morph::forms::detail::findMember` instead.
123 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
124 ::morph::forms::detail::emitBindsInto<typename StepT::binds>(step["prefill"]);
125 }
126 steps.emplace_back(std::move(step));
127 });
128 dom["w-steps"] = steps;
129
130 return glz::write_json(dom).value_or(std::string{});
131}
132
133/// @brief Sequences an ordered list of registered actions (`Steps...`) as one
134/// multi-step flow sharing captured values across steps.
135///
136/// Each step is fired through the handler's ordinary one-shot dispatch
137/// (`BridgeHandler::execute<A>()` → `Completion<R>`, bridge.hpp) — a
138/// `FlowSession` contributes only sequencing (`advance`/`back`), the per-step
139/// drafts and their readiness gate, and a resolved-values map callers use to
140/// prefill a later step from an earlier one's submitted fields or result. The
141/// handler holds no draft on a flow's behalf: nothing here goes through
142/// handler-side draft machinery.
143///
144/// `Steps...` must be pairwise distinct action types: this
145/// session's drafts live in a `std::tuple<Steps...>` addressed by type
146/// (`std::get<A>(_drafts)`), and `std::get<T>` is ill-formed when `T` occurs
147/// more than once — which is what the `AllDistinct<Steps...>` assert below
148/// enforces.
149///
150/// @tparam Model Concrete model type owning the `BridgeHandler` this flow dispatches through.
151/// @tparam Steps Ordered, pairwise-distinct action types (the flow's steps).
152template <typename Model, typename... Steps>
154 static_assert(sizeof...(Steps) > 0, "FlowSession: a flow needs at least one step");
155 static_assert(::morph::forms::detail::AllDistinct<Steps...>::value,
156 "FlowSession: step action types must be pairwise distinct");
157
158public:
159 /// @brief Constructs a flow over @p handler, starting at step 0.
160 /// @param handler Handler the flow dispatches every step through. Must
161 /// outlive this `FlowSession`. The session belongs to the
162 /// handler's owner: it is built, used and destroyed there,
163 /// and the handler's `guiExec` delivers its continuations
164 /// there.
165 /// @param onError Optional callback invoked when the current step's fire
166 /// fails (e.g. `BackendChangedError` mid-flight). When
167 /// absent, the error is logged via `morph::log::logError`,
168 /// which is how `BridgeHandler` itself reports a failure no
169 /// caller is holding a `Completion` for (bridge.hpp). A
170 /// handler offers no error callback to inherit a default
171 /// from: `subscribe<R>` is notified of results only, and an
172 /// `execute()` failure reaches the caller through the
173 /// `Completion` it returned.
174 /// Stored and invoked for this session's whole lifetime, so
175 /// anything the callable refers to must outlive the session.
176 explicit FlowSession(::morph::bridge::BridgeHandler<Model>& handler MORPH_LIFETIMEBOUND,
177 std::function<void(std::exception_ptr)> onError MORPH_LIFETIMEBOUND = nullptr)
178 : _handler{handler}, _onError{std::move(onError)} {
179 beginStep();
180 }
181
182 /// @brief Stops every callback this session installed from being delivered.
183 ///
184 /// There is nothing to detach. A step's callbacks are `.then`/`.onError`
185 /// continuations on the `Completion` that `BridgeHandler::execute<A>()`
186 /// returned — already owned by the in-flight dispatch, not held in a map
187 /// this session could remove itself from, and possibly running on another
188 /// thread the moment this destructor starts. `_callbacks` gates every
189 /// installed callback on a token the callback checks *before* touching
190 /// `this`.
191 ///
192 /// The session is destroyed on the handler's owner, where the step's
193 /// continuations are delivered, so check-then-run is atomic: a callback
194 /// still in flight is refused instead of touching a destroyed object.
195 ///
196 /// `requestStop()` is called explicitly rather than left to the member's own
197 /// destruction, even though `_callbacks` is declared last: members are
198 /// destroyed only *after* the destructor body, so a body that grew a call
199 /// pumping an event loop would otherwise deliver into a half-dead session.
200 /// This is the "teardown that pumps" escape hatch
201 /// docs/spec/core/callback_scope.md documents; the body here does not do
202 /// that today, but stopping first keeps the destructor correct if one is
203 /// added.
205 note("FlowSession::~FlowSession");
206 _callbacks.requestStop();
207 }
208
209 FlowSession(const FlowSession&) = delete;
210 FlowSession& operator=(const FlowSession&) = delete;
211 FlowSession(FlowSession&&) = delete;
212 FlowSession& operator=(FlowSession&&) = delete;
213
214 /// @brief Sets one field of the current step's draft and dispatches the
215 /// step as soon as its `ActionValidator` reports the draft ready.
216 ///
217 /// The draft is this session's own (`std::get<A>(_drafts)`), the readiness
218 /// check is made here, and a ready draft is fired by this session's
219 /// `fireStep<A>` through `BridgeHandler::execute<A>()`. The handler keeps
220 /// no draft of its own, so there is nothing to forward to.
221 ///
222 /// @par No in-flight coalescing
223 /// **Every `set<>` that leaves the draft ready dispatches, including one
224 /// made while an earlier dispatch is still in flight.** Keystroke-rate
225 /// calls on an already-complete draft therefore produce one request each,
226 /// and their results arrive in whatever order the backend answers them —
227 /// the last reply to land wins, which need not be the last call made. An
228 /// earlier handler-side draft did collapse patches arriving during a
229 /// flight; nothing does now, here or on the standalone-form path. A
230 /// caller that wants one request per pause throttles or debounces on its
231 /// own side.
232 ///
233 /// @tparam FieldPtr Pointer-to-data-member of the current step's action struct.
234 /// @param value New value for the field.
235 /// @throws std::logic_error if @p FieldPtr's action is not the current step.
236 template <auto FieldPtr>
237 void set(typename ::morph::bridge::detail::MemberPointerTraits<decltype(FieldPtr)>::ValueType value) {
238 using A = typename ::morph::bridge::detail::MemberPointerTraits<decltype(FieldPtr)>::ClassType;
239 static_assert((std::is_same_v<A, Steps> || ...),
240 "FlowSession::set<>: field's action is not a step of this flow");
241 note("FlowSession::set");
243 throw std::logic_error{"FlowSession::set<>: field belongs to an action that is not the current step"};
244 }
245 std::get<A>(_drafts).*FieldPtr = std::move(value);
246 A draft = std::get<A>(_drafts);
247 std::size_t const stepIndex = _activeStep;
248 // The readiness gate that used to live in the handler's draft machinery
249 // lives here now: the flow already owns the draft, so it can decide when
250 // the step is complete and dispatch it itself. The absence of in-flight
251 // coalescing that follows from dispatching here is published in this
252 // function's @brief, not left to this comment to discover.
254 fireStep<A>(std::move(draft), stepIndex);
255 }
256 }
257
258 /// @brief Moves to the next step, if the current step has already produced
259 /// a successful result (a not-ready step does not advance).
260 /// @return `true` if the flow advanced, `false` if the current step is not
261 /// ready or the flow is already `finished()`.
262 ///
263 bool advance() {
264 note("FlowSession::advance");
265 if (!_currentReady || finished()) {
266 return false;
267 }
268 ++_index;
269 _currentReady = false;
270 // Retire the old step's callbacks here, not only in beginStep(): on
271 // the last step this advance finishes the flow and no further
272 // beginStep() follows, so nothing else would move the marker and a
273 // late reply could still mark a finished flow ready.
274 _activeStep = _index;
275 if (!finished()) {
276 beginStep();
277 }
278 return true;
279 }
280
281 /// @brief Returns to the previous step. Its draft — this session's own
282 /// `std::get<A>(_drafts)` slot, the only draft in play — was never
283 /// reset, so its entered values are intact.
284 /// @return `true` if the flow moved back, `false` if already at step 0.
285 bool back() {
286 note("FlowSession::back");
287 if (_index == 0) {
288 return false;
289 }
290 --_index;
291 _currentReady = true; // this step already produced a result once, or it could not have been left
292 _activeStep = _index;
293 beginStep();
294 return true;
295 }
296
297 /// @brief Whether the flow has advanced past the last step.
298 /// @return `true` once `advance()` has been called successfully on the last step.
299 [[nodiscard]] bool finished() const noexcept { return _index >= sizeof...(Steps); }
300
301 /// @brief Whether the current step already has a captured, successful result.
302 /// @return `true` if `advance()` would move the flow forward right now.
303 [[nodiscard]] bool ready() const noexcept {
304 note("FlowSession::ready");
305 return _currentReady;
306 }
307
308 /// @brief The current 0-based step position.
309 /// @return `sizeof...(Steps)` once `finished()`.
310 [[nodiscard]] std::size_t currentIndex() const noexcept { return _index; }
311
312 /// @brief The number of steps in this flow.
313 /// @return `sizeof...(Steps)`.
314 [[nodiscard]] static constexpr std::size_t stepCount() noexcept { return sizeof...(Steps); }
315
316 /// @brief The registered type-id of the current step's action.
317 /// @return Empty when `finished()` (no current step).
318 [[nodiscard]] std::string_view currentActionType() const noexcept {
319 std::string_view id{};
320 ::morph::forms::detail::forPackElement<Steps...>(
321 _index, [&id]<typename A> { id = ::morph::model::ActionTraits<A>::typeId(); });
322 return id;
323 }
324
325 /// @brief Looks up a value captured from an earlier step's submitted
326 /// draft or result.
327 /// @param path `"<ActionTypeId>.<field>"`, matching a wizard step's
328 /// declared `Bind::path()`.
329 /// @return The field's JSON-encoded value, or `std::nullopt` if @p path
330 /// was never captured (the step never fired, or never had that field).
331 [[nodiscard]] std::optional<std::string> resolved(std::string_view path) const {
332 note("FlowSession::resolved");
333 auto iter = _resolvedValues.find(std::string{path});
334 if (iter == _resolvedValues.end()) {
335 return std::nullopt;
336 }
337 return iter->second;
338 }
339
340private:
341 template <typename A>
342 void captureResult(const ::morph::model::ActionTraits<A>::Result& result, std::size_t stepIndex) {
343 note("FlowSession::captureResult");
344 if (stepIndex != _activeStep) {
345 // A reply for a step the flow has already left. A dispatch in
346 // flight cannot be recalled -- its `.then` continuation is already
347 // owned by the completion (see ~FlowSession) -- so a step re-fired
348 // just before advance() can still land here afterwards. Applying it
349 // would do real damage twice over: `_resolvedValues` would be
350 // overwritten with a superseded reply that advance() never saw, and
351 // `_currentReady = true` below is not step-keyed, so it would mark
352 // the *new* step ready before anything was entered into it --
353 // letting the next advance() skip a step outright.
354 return;
355 }
356 auto const typeId = ::morph::model::ActionTraits<A>::typeId();
357 auto record = [&](const auto& value) {
358 ::morph::forms::detail::forEachNamedMember(
359 value, [&]<std::size_t I>(std::string_view name, const auto& member) {
360 static_cast<void>(I);
361 std::string json;
362 // The `!write_json(...)` guard's false arm (write failure)
363 // is not exercised by this file's own test suite: every
364 // step action's fields are plain, well-formed data glaze's
365 // JSON writer cannot fail on, matching the same
366 // "untestable line" already accepted for forms.hpp's own
367 // `write_json(dom)` call in `schemaJson`.
368 if (!glz::write_json(member, json)) {
369 _resolvedValues[std::string{typeId} + "." + std::string{name}] = std::move(json);
370 }
371 });
372 };
373 record(std::get<A>(_drafts)); // submitted draft fields first...
374 record(result); // ...result fields win on name collision
375 _currentReady = true;
376 }
377
378 /// @brief Dispatches step @p A's completed draft and routes its outcome.
379 ///
380 /// Both closures run on the handler's `guiExec`, which must be its owner,
381 /// and are gated on `_callbacks`, so neither touches anything on `this`
382 /// once the flow has been stopped or destroyed — a completion can still
383 /// resolve after the flow is gone.
384 /// @tparam A Step action type.
385 /// @param draft The completed action to execute.
386 /// @param stepIndex Index of the step this dispatch belongs to.
387 template <typename A>
388 void fireStep(A draft, std::size_t stepIndex) {
389 _handler.execute(std::move(draft))
390 .then(_callbacks,
391 [this, stepIndex](::morph::model::ActionTraits<A>::Result result) {
392 this->template captureResult<A>(result, stepIndex);
393 })
394 .onError(_callbacks, [this, stepIndex](const std::exception_ptr& err) {
395 note("FlowSession::fireStep");
396 // Only clear readiness while this really is the current step: a
397 // late failure from a step already left behind must not
398 // un-ready the step the flow moved on to. The error is still
399 // reported below either way: the action did fail, and the host
400 // wants to know.
401 if (stepIndex == _activeStep) {
402 _currentReady = false;
403 }
404 if (_onError) {
405 _onError(err);
406 } else {
407 logUnhandledError(::morph::model::ActionTraits<A>::typeId(), err);
408 }
409 });
410 }
411
412 static void logUnhandledError(std::string_view typeId, const std::exception_ptr& err) {
413 try {
414 std::rethrow_exception(err);
415 } catch (const std::exception& exc) {
416 ::morph::log::logError(std::string{"[flow:"} + std::string{typeId} +
417 "] unhandled exception: " + exc.what());
418 } catch (...) {
419 ::morph::log::logError(std::string{"[flow:"} + std::string{typeId} + "] unhandled unknown exception");
420 }
421 }
422
423 /// @brief Publishes which step is now current.
424 ///
425 /// Read back by every dispatch callback, so one that resolves after the
426 /// flow has moved on recognises itself as stale.
427 void beginStep() { _activeStep = _index; }
428
429 /// @brief Checks, in a debug build, that @p site runs on the handler's owner.
430 /// @param site Name of the calling body.
431 void note(char const* site) const noexcept {
432 ::morph::exec::detail::noteOwner(site, _handler.owner().coreExecutor(), _handler.onOwner());
433 }
434
435 ::morph::bridge::BridgeHandler<Model>& _handler;
436 std::function<void(std::exception_ptr)> _onError;
437 // Everything below is touched only on the handler's owner: by the verbs,
438 // and by a step's continuations, delivered on the handler's `guiExec` —
439 // the owner.
440 std::size_t _index{0};
441 std::tuple<Steps...> _drafts{};
442 // The step whose continuations are the ones the flow still recognises, so
443 // a reply for a step already left behind can tell it is stale.
444 std::size_t _activeStep{0};
445 bool _currentReady{false};
446 std::unordered_map<std::string, std::string> _resolvedValues;
447 // Declared last, so it is the first member destroyed: every gated callback
448 // is refused before the fields it would have touched are torn down. See
449 // docs/spec/core/callback_scope.md.
450 ::morph::async::CallbackScope _callbacks;
451};
452
453} // namespace morph::flows
454
455/// @brief Specialises `morph::flows::WizardTraits<W>` with the string type-id @p NAME.
456///
457/// Metadata only: unlike `BRIDGE_REGISTER_ACTION`, this performs no
458/// static-init registration into any dispatch registry — a wizard is never
459/// itself executed, only its steps' already-registered actions are (see
460/// docs/spec/forms/workflows_navigation.md).
461/// @param W Concrete `morph::flows::Wizard<...>` type.
462/// @param NAME String literal used as the wizard's type-id.
463// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
464#define BRIDGE_REGISTER_WIZARD(W, NAME) \
465 template <> \
466 struct morph::flows::WizardTraits<W> { \
467 static constexpr std::string_view typeId() noexcept { return NAME; } \
468 };
469// clang-format on
::morph::detail::FixedString< N > FixedString
A structural, NTTP-capable compile-time string (for naming the options action and its value/label res...
Definition choice.hpp:65
RAII wrapper that binds a single model type to a Bridge.
Definition bridge.hpp:2583
Sequences an ordered list of registered actions (Steps...) as one multi-step flow sharing captured va...
Definition flows.hpp:153
~FlowSession()
Stops every callback this session installed from being delivered.
Definition flows.hpp:204
bool back()
Returns to the previous step. Its draft — this session's own std::get<A>(_drafts) slot,...
Definition flows.hpp:285
FlowSession(::morph::bridge::BridgeHandler< Model > &handler, std::function< void(std::exception_ptr)> onError=nullptr)
Constructs a flow over handler, starting at step 0.
Definition flows.hpp:176
static constexpr std::size_t stepCount() noexcept
The number of steps in this flow.
Definition flows.hpp:314
std::optional< std::string > resolved(std::string_view path) const
Looks up a value captured from an earlier step's submitted draft or result.
Definition flows.hpp:331
std::string_view currentActionType() const noexcept
The registered type-id of the current step's action.
Definition flows.hpp:318
bool ready() const noexcept
Whether the current step already has a captured, successful result.
Definition flows.hpp:303
std::size_t currentIndex() const noexcept
The current 0-based step position.
Definition flows.hpp:310
void set(typename ::morph::bridge::detail::MemberPointerTraits< decltype(FieldPtr)>::ValueType value)
Sets one field of the current step's draft and dispatches the step as soon as its ActionValidator rep...
Definition flows.hpp:237
bool finished() const noexcept
Whether the flow has advanced past the last step.
Definition flows.hpp:299
bool advance()
Moves to the next step, if the current step has already produced a successful result (a not-ready ste...
Definition flows.hpp:263
morph::forms::Bind< Field, Path > Bind
Prefill binding for a wizard step.
Definition flows.hpp:56
std::string wizardSchemaJson()
Generates the w-* JSON document for wizard type W.
Definition flows.hpp:109
JSON-Forms-style schema generation for morph actions.
Machinery shared by the two form session types: morph::flows::FlowSession (ordered wizard steps) and ...
One step of a Wizard: a registered action, a display title, and zero or more Bind prefill declaration...
Definition flows.hpp:64
std::tuple< Binds... > binds
Tuple of this step's Bind<...> prefill declarations (possibly empty).
Definition flows.hpp:69
Action action
The step's action type.
Definition flows.hpp:66
static constexpr std::string_view title() noexcept
The step's display title.
Definition flows.hpp:73
Traits specialisation mapping a Wizard type to its string type-id.
Definition flows.hpp:96
An ordered sequence of WizardSteps sharing one flow.
Definition flows.hpp:80
std::tuple< Steps... > steps
Tuple of this wizard's ordered WizardStep<...> types.
Definition flows.hpp:82
static constexpr std::string_view title() noexcept
The wizard's display title.
Definition flows.hpp:86
One field -> "<ActionTypeId>.<field>" prefill binding declared on a wizard step or an unordered secti...
Traits specialisation that maps an action type to its id, JSON codec, and result type.
Definition registry.hpp:48
static constexpr bool ready(const Action &action)
Returns true if the action is in a state that should be executed.
Definition registry.hpp:269