morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
interpreter.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <charconv>
6#include <chrono>
7#include <cstddef>
8#include <cstdint>
9#include <deque>
10#include <exception>
11#include <expected>
12#include <format>
13#include <functional>
14#include <limits>
15#include <map>
16#include <memory>
17#include <optional>
18#include <ranges>
19#include <set>
20#include <span>
21#include <stdexcept>
22#include <string>
23#include <string_view>
24#include <utility>
25#include <variant>
26#include <vector>
27
28#include "../core/backend.hpp"
29#include "../core/bridge.hpp"
30#include "../core/completion.hpp"
31#include "../core/wire.hpp"
41#include "../reactive/scope.hpp"
43#include "../ui/backend.hpp"
44#include "dev_tools.hpp"
45#include "document.hpp"
46#include "expression.hpp"
47#include "message.hpp"
48#include "mount.hpp"
49#include "value.hpp"
50#include "view.hpp"
51
52/// @file
53/// @brief The UI document interpreter: `Interpreter::mount` turns a loaded `Document` into reactive state and a view
54/// tree, mounts it through `Mounted` on any `IViewBackend`, and runs its queries, mutations, relations and
55/// commands. Model calls go through a `Dispatcher` (the bridge's model-free seams in an application, a
56/// scripted fake in a test); what only the application can do goes through an `InterpreterHost`; what
57/// screens share — the query cache, the principal's permissions, the formatting facts — is a `Session`.
58
59namespace morph::ui {
60
61// ---------------------------------------------------------------------------------------------------------------
62// Dispatch
63// ---------------------------------------------------------------------------------------------------------------
64
65/// @brief How one model call is sent.
67 /// @brief Ask the server to evaluate the action's body instead of executing it (spec 5 §7, `ui-evaluate`).
68 bool evaluate = false;
69 /// @brief The mutation declares `"offline": "queue"`: a dispatcher with an offline queue holds a call that cannot
70 /// reach the server and settles the completion when the replay settles.
71 bool queueOffline = false;
72};
73
74/// @brief One bound model alias: the instance a document's alias names, and the calls made through it.
75class ModelChannel {
76public:
77 ModelChannel() = default;
78 ModelChannel(ModelChannel const&) = delete;
79 ModelChannel& operator=(ModelChannel const&) = delete;
80 ModelChannel(ModelChannel&&) = delete;
81 ModelChannel& operator=(ModelChannel&&) = delete;
82 virtual ~ModelChannel() = default;
83
84 /// @brief Sends one action.
85 /// @param actionId The action's registered type id.
86 /// @param bodyJson The action's JSON body.
87 /// @param options How to send it.
88 /// @return The reply's JSON text, delivered on the runtime's owner.
89 [[nodiscard]] virtual async::Completion<std::string> execute(std::string const& actionId,
90 std::string const& bodyJson, CallOptions options) = 0;
91};
92
93/// @brief The interpreter's view of the bridge: binds a model by its type id, privately or to a shared instance.
94class Dispatcher {
95public:
96 Dispatcher() = default;
97 Dispatcher(Dispatcher const&) = delete;
98 Dispatcher& operator=(Dispatcher const&) = delete;
99 Dispatcher(Dispatcher&&) = delete;
100 Dispatcher& operator=(Dispatcher&&) = delete;
101 virtual ~Dispatcher() = default;
102
103 /// @brief Binds a model.
104 /// @param typeId The model's registered type id.
105 /// @param sharing Private, or shared.
106 /// @param instanceKey For a shared binding, the instance key; empty for the model's shared singleton.
107 /// @return The bound channel. The caller holds it as long as it makes calls through it.
108 [[nodiscard]] virtual std::shared_ptr<ModelChannel> bind(std::string const& typeId, bridge::BindSharing sharing,
109 std::string const& instanceKey) = 0;
110};
111
112/// @brief Evaluates an action body without running it: the model's type id, the action id and the JSON body in, the
113/// query's value out (spec 5 §7). `ui::evaluatorOver` builds one over the UI service's `ui-evaluate`.
114using Evaluator = std::function<async::Completion<std::string>(std::string const& typeId, std::string const& actionId,
115 std::string const& bodyJson)>;
116
117/// @brief A `Dispatcher` over the bridge's model-free seams: `bindByType`, `executeRaw` and the string-keyed
118/// `attach`, through `RawHandler`.
119///
120/// An evaluation goes through the `Evaluator` it was given, since that envelope belongs to the UI service, not the
121/// bridge; without one it is refused with a `std::runtime_error`. It has no offline queue of its own: `queueOffline`
122/// is ignored unless a `QueueingDispatcher` wraps it. A shared binding without a key is bound unattached, which the
123/// bridge refuses at the first call; the interpreter never binds one, since a `"shared"` alias is a stateless model's
124/// and binds privately.
125class BridgeDispatcher final : public Dispatcher {
126public:
127 /// @brief Dispatches through @p bridge, and evaluates through @p evaluator.
128 /// @param bridge The bridge. Borrowed: it must outlive every channel.
129 /// @param cbExec The executor replies are delivered on: the runtime's owner. Borrowed.
130 /// @param evaluator What answers an `evaluate` query; empty to refuse them.
131 BridgeDispatcher(bridge::Bridge& bridge MORPH_LIFETIMEBOUND, exec::IExecutor* cbExec MORPH_LIFETIMEBOUND,
132 Evaluator evaluator = {})
133 : _bridge{&bridge}, _cbExec{cbExec}, _evaluator{std::move(evaluator)} {}
134
135 /// @brief Binds through a `RawHandler`.
136 /// @param typeId The model's registered type id.
137 /// @param sharing Private, or shared.
138 /// @param instanceKey For a shared binding, the key to attach to; empty to stay unattached.
139 /// @return The channel; destroying it deregisters the binding.
140 [[nodiscard]] std::shared_ptr<ModelChannel> bind(std::string const& typeId, bridge::BindSharing sharing,
141 std::string const& instanceKey) override {
142 return std::make_shared<Channel>(*_bridge, _cbExec, _evaluator, typeId, sharing, instanceKey);
143 }
144
145private:
146 class Channel final : public ModelChannel {
147 public:
148 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): the type id, then the key, as `bind` takes them.
149 Channel(bridge::Bridge& bridge, exec::IExecutor* cbExec, Evaluator evaluator, std::string const& typeId,
150 bridge::BindSharing sharing, std::string const& instanceKey)
151 : _cbExec{cbExec},
152 _evaluator{std::move(evaluator)},
153 _typeId{typeId},
154 _handler{bridge, cbExec, typeId, sharing, instanceKey} {}
155
156 [[nodiscard]] async::Completion<std::string> execute(std::string const& actionId, std::string const& bodyJson,
157 CallOptions options) override {
158 if (options.evaluate) {
159 if (_evaluator) {
160 return _evaluator(_typeId, actionId, bodyJson);
161 }
162 auto [refused, promise] = async::Completion<std::string>::makeSettleable(_cbExec);
163 promise.reject(std::make_exception_ptr(std::runtime_error{"evaluate needs the UI service"}));
164 return std::move(refused);
165 }
166 return _handler.execute(actionId, bodyJson);
167 }
168
169 private:
170 exec::IExecutor* _cbExec;
171 Evaluator _evaluator;
172 std::string _typeId;
173 bridge::RawHandler _handler;
174 };
175
176 bridge::Bridge* _bridge;
177 exec::IExecutor* _cbExec;
178 Evaluator _evaluator;
179};
180
181// ---------------------------------------------------------------------------------------------------------------
182// Host
183// ---------------------------------------------------------------------------------------------------------------
184
185/// @brief What `navigate` asks the host to open (spec 6 §16, §20).
187 /// @brief The screen id; empty when `route` is given.
188 std::string screen;
189 /// @brief The route, when the command named one instead of a screen.
191 /// @brief The params.
193 /// @brief `tab`, `dialog`, `drawer` or `window`; empty for the shell's choice.
194 std::string as;
195};
196
197/// @brief What an `upload` command asks the host to send, and the mutation that will reference the file, which is what
198/// the server authorises the upload as (spec 6 §18).
200 /// @brief What the user picked or dropped.
202 /// @brief The model type of the mutation that will reference the file.
203 std::string modelType;
204 /// @brief That mutation's action.
205 std::string action;
206};
207
208/// @brief A file whose bytes a document moves inline: `download` from bytes, `upload` into bytes.
210 /// @brief The file's name.
211 std::string name;
212 /// @brief Its media type; empty when unknown.
213 std::string mediaType;
214 /// @brief Its bytes.
215 std::string bytes;
216};
217
218/// @brief The inline cap a session has unless it is given another: 1 MiB.
219inline constexpr std::size_t kDefaultInlineFileBytes = std::size_t{1024} * 1024;
220
221/// @brief The largest inline cap a session takes: base64 grows bytes by 4/3, and 64 KiB of an envelope are left for
222/// the rest of the body, so the encoded file still fits `wire::kMaxEnvelopeBytes`.
223inline constexpr std::size_t kMaxInlineFileBytes = (wire::kMaxEnvelopeBytes - (std::size_t{64} * 1024)) * 3 / 4;
224
225/// @brief What the application answers for the commands a document cannot carry out alone (spec 6). Every default
226/// answers "not supported", which the interpreter reports as a diagnostic.
227class InterpreterHost {
228public:
229 InterpreterHost() = default;
230 InterpreterHost(InterpreterHost const&) = delete;
231 InterpreterHost& operator=(InterpreterHost const&) = delete;
232 InterpreterHost(InterpreterHost&&) = delete;
233 InterpreterHost& operator=(InterpreterHost&&) = delete;
234 virtual ~InterpreterHost() = default;
235
236 // NOLINTBEGIN(performance-unnecessary-value-param,bugprone-easily-swappable-parameters): every default below
237 // answers "not supported" and reads nothing; an override keeps the callbacks, so they are taken by value.
238
239 /// @brief Opens another screen.
240 /// @param request What to open.
241 /// @param onResult Called with the value a screen opened as a dialog, drawer or window returns.
242 /// @param onCancel Called when such a screen closes without a value.
243 /// @return Whether the host opened it.
244 virtual bool navigate(NavigateRequest const& request, std::function<void(Value)> onResult,
245 std::function<void()> onCancel) {
246 static_cast<void>(request);
247 static_cast<void>(onResult);
248 static_cast<void>(onCancel);
249 return false;
250 }
251
252 /// @brief Ends the screen that ran `return` or `close: self`.
253 /// @param screen The screen's id.
254 /// @param result The value of `return`; `std::nullopt` for `close: self`.
255 /// @return Whether the screen was opened as something that can end.
256 virtual bool closeSelf(std::string const& screen, std::optional<Value> result) {
257 static_cast<void>(screen);
258 static_cast<void>(result);
259 return false;
260 }
261
262 /// @brief Runs a command the application registered (`host/1`).
263 /// @param name The command.
264 /// @param args Its arguments; `null` when none.
265 /// @return Whether the command exists.
266 virtual bool hostCommand(std::string const& name, Value const& args) {
267 static_cast<void>(name);
268 static_cast<void>(args);
269 return false;
270 }
271
272 /// @brief Switches to a backend the application's source lists (`AppSource::switchBackend`, spec 6 §9).
273 ///
274 /// The switch is asynchronous: the host calls @p done once, on the runtime's owner, when it committed or failed.
275 /// A failed switch leaves the old backend in place.
276 /// @param name The backend.
277 /// @param done Called once with nothing when the switch committed, or with why it failed. Not called when the
278 /// backend is not listed.
279 /// @return Whether it is listed.
280 virtual bool switchBackend(std::string const& name, std::function<void(std::expected<void, std::string>)> done) {
281 static_cast<void>(name);
282 static_cast<void>(done);
283 return false;
284 }
285
286 /// @brief The document reset a customization point, or all of them; the host forgets the stored choice.
287 /// @param screen The screen's id.
288 /// @param point The point, or `all`.
289 virtual void resetPrefs(std::string const& screen, std::string const& point) {
290 static_cast<void>(screen);
291 static_cast<void>(point);
292 }
293
294 /// @brief Stores the active locale as a preference. The interpreter has already re-resolved every `t`.
295 /// @param locale The BCP 47 locale.
296 virtual void setLocale(std::string const& locale) { static_cast<void>(locale); }
297
298 /// @brief Shows a transient message.
299 /// @param text The message.
300 /// @param role `info`, `success`, `warning` or `error`; empty for the default.
301 /// @return Whether it was shown.
302 virtual bool notify(std::string const& text, std::string const& role) {
303 static_cast<void>(text);
304 static_cast<void>(role);
305 return false;
306 }
307
308 /// @brief Installs the session the reply of a sign-in mutation carries, and reloads the catalog.
309 /// @param reply The mutation's reply, secret fields included.
310 /// @return Whether the host installed it.
311 virtual bool signIn(Value const& reply) {
312 static_cast<void>(reply);
313 return false;
314 }
315
316 /// @brief Ends the session. The interpreter has already cleared the session's query cache.
317 virtual void signOut() {}
318
319 /// @brief Writes `app.name` with `set` (spec 6 §5).
320 /// @param name The app state's name.
321 /// @param value The value.
322 /// @return Whether the app document declares it.
323 virtual bool setAppValue(std::string const& name, Value const& value) {
324 static_cast<void>(name);
325 static_cast<void>(value);
326 return false;
327 }
328
329 /// @brief Sends a file through the server's file side channel (`files/1`).
330 /// @param file What the user picked or dropped.
331 /// @param progress Called with the progress, 0 to 1.
332 /// @param done Called once with the file's reference, or the failure's text.
333 /// @return Whether the host can send files.
334 virtual bool upload(Value const& file, std::function<void(Value)> progress,
335 std::function<void(std::expected<Value, std::string>)> done) {
336 static_cast<void>(file);
337 static_cast<void>(progress);
338 static_cast<void>(done);
339 return false;
340 }
341
342 /// @brief Sends a file through the server's file side channel, knowing the mutation that will reference it. The
343 /// interpreter calls this one; the default calls `upload(file, …)`, so a host that overrides only that
344 /// keeps working.
345 /// @param request The file and the mutation.
346 /// @param progress Called with the progress, 0 to 1.
347 /// @param done Called once with the file's reference, or the failure's text.
348 /// @return Whether the host can send files.
349 virtual bool uploadFor(UploadRequest const& request, std::function<void(Value)> progress,
350 std::function<void(std::expected<Value, std::string>)> done) {
351 return upload(request.file, std::move(progress), std::move(done));
352 }
353
354 /// @brief Fetches a file by reference to save it, or to a local temporary reference for a viewer.
355 /// @param file The file's reference.
356 /// @param option `dialog` to save, `preview` for a local reference.
357 /// @param name The suggested file name; `null` when none.
358 /// @param done Called once with the local reference (`preview`) or `null` (`dialog`), or the failure.
359 /// @return Whether the host can fetch files.
360 virtual bool download(Value const& file, std::string const& option, Value const& name,
361 std::function<void(std::expected<Value, std::string>)> done) {
362 static_cast<void>(file);
363 static_cast<void>(option);
364 static_cast<void>(name);
365 static_cast<void>(done);
366 return false;
367 }
368
369 /// @brief Reads a file's bytes for `upload` into bytes. A host reads the file's size first and refuses one over
370 /// @p maxBytes without reading it; the interpreter refuses a reply over the cap as well.
371 /// @param file What the user picked or dropped.
372 /// @param maxBytes The session's inline cap.
373 /// @param done Called once with the file, or the failure's text.
374 /// @return Whether the host can read files.
375 virtual bool readFile(Value const& file, std::size_t maxBytes,
376 std::function<void(std::expected<InlineFile, std::string>)> done) {
377 static_cast<void>(file);
378 static_cast<void>(maxBytes);
379 static_cast<void>(done);
380 return false;
381 }
382
383 /// @brief Saves a file a document holds the bytes of, for `download` from bytes.
384 /// @param file The file.
385 /// @param option `dialog` to save, `preview` for a local reference.
386 /// @param done Called once with the local reference (`preview`) or `null` (`dialog`), or the failure.
387 /// @return Whether the host can save files.
388 virtual bool saveFile(InlineFile const& file, std::string const& option,
389 std::function<void(std::expected<Value, std::string>)> done) {
390 static_cast<void>(file);
391 static_cast<void>(option);
392 static_cast<void>(done);
393 return false;
394 }
395
396 /// @brief The user chose at a customization point through a `customize` editor; the host stores the choice.
397 /// @param screen The screen's id.
398 /// @param point The point.
399 /// @param value The choice.
400 virtual void preferenceChanged(std::string const& screen, std::string const& point, Value const& value) {
401 static_cast<void>(screen);
402 static_cast<void>(point);
403 static_cast<void>(value);
404 }
405
406 /// @brief A call was refused with error kind `denied`: the permitted set is a snapshot, so the host asks the
407 /// server again and calls `Session::setPermitted`.
408 virtual void permissionsStale() {}
409 // NOLINTEND(performance-unnecessary-value-param,bugprone-easily-swappable-parameters)
410};
411
412// ---------------------------------------------------------------------------------------------------------------
413// Session
414// ---------------------------------------------------------------------------------------------------------------
415
416/// @brief Sends a `watch` or `unwatch` request for a session's cache (spec 8 §7): the kind, and the `WatchBody` as
417/// JSON. `ui::watcherOver` builds one over a remote source's exchange.
418using Watcher = std::function<async::Completion<std::string>(std::string const& kind, std::string const& bodyJson)>;
419
420/// @brief What a `Session` is built with.
422 /// @brief Timers for `debounce`, `refreshEvery` and the cache's eviction. Without one, `debounce` fetches at once,
423 /// `refreshEvery` never ticks and unused entries are kept. Borrowed.
425 /// @brief The clock `staleTime` is measured on; empty uses `std::chrono::steady_clock`.
426 std::function<std::chrono::milliseconds()> clock;
427 /// @brief How long an entry no mounted query uses is kept (spec 5 §5: five minutes).
428 std::chrono::milliseconds keepUnused{std::chrono::minutes{5}};
429 /// @brief The catalog: `stateless` decides an alias's default instance; an action's `secretFields` are never
430 /// cached or kept in `result`.
432 /// @brief Maps a failed call to its error kind (`denied`, `offline`, `timeout`, …); empty uses the default,
433 /// which knows the bridge's own failures and calls everything else `error`.
434 std::function<std::string(std::exception_ptr const&)> classify;
435 /// @brief Development mode: whether this session's screens record what the inspector reads. Off by default.
437 /// @brief Sends the cache's watch changes to the server. Empty: the session still counts what its cache holds,
438 /// and sends nothing, which is right for a local source, whose replies carry every change it sees.
440 /// @brief The most bytes a document moves inline in one file (`download` from bytes, `upload` into bytes, a
441 /// field component writing a base64 member); 1 to `kMaxInlineFileBytes`.
443};
444
445namespace detail {
446
447/// @brief The text of a failure.
448/// @param error The failure.
449/// @return `what()`, or `unknown error`.
450[[nodiscard]] inline std::string failureText(std::exception_ptr const& error) {
451 try {
452 std::rethrow_exception(error);
453 } catch (std::exception const& failure) {
454 return failure.what();
455 } catch (...) {
456 return "unknown error";
457 }
458}
459
460/// @brief The default error kinds: the bridge's own failures, and `error` for the rest.
461/// @param error The failure.
462/// @return The kind.
463[[nodiscard]] inline std::string defaultErrorKind(std::exception_ptr const& error) {
464 try {
465 std::rethrow_exception(error);
466 } catch (backend::DisconnectedError const&) {
467 return "offline";
468 } catch (backend::TimeoutError const&) {
469 return "timeout";
470 } catch (backend::ClientTimeoutError const&) {
471 return "timeout";
472 } catch (backend::BackendChangedError const&) {
473 return "backendChanged";
474 } catch (...) {
475 return "error";
476 }
477}
478
479/// @brief A value's JSON text, with record fields in name order: a cache key's canonical body.
480/// @param value The value.
481/// @return The text.
482[[nodiscard]] inline std::string canonicalJson(Value const& value) {
483 return glz::write_json(valueToJson(value)).value_or(std::string{});
484}
485
486/// @brief A value with the field at @p path replaced, creating records along the way.
487/// @param base The value written into.
488/// @param path The field path.
489/// @param value The new value at the path.
490/// @return The new value.
491// NOLINTNEXTLINE(misc-no-recursion): one level per path segment
492[[nodiscard]] inline Value withPath(Value const& base, std::span<std::string const> path, Value value) {
493 if (path.empty()) {
494 return value;
495 }
496 ValueRecord fields;
497 if (ValueRecord const* const record = base.asRecord()) {
498 fields = *record;
499 }
500 Value const inner = base.field(path.front()) != nullptr ? *base.field(path.front()) : Value{};
501 fields.insert_or_assign(path.front(), withPath(inner, path.subspan(1), std::move(value)));
502 return Value{std::move(fields)};
503}
504
505/// @brief A row's identity for `patch` and `optimistic`: its `id` field.
506/// @param row The row.
507/// @return The `id` field, or `null`.
508[[nodiscard]] inline Value rowId(Value const& row) {
509 Value const* const found = row.field("id");
510 return found == nullptr ? Value{} : *found;
511}
512
513/// @brief Writes @p with over a row: the fields of a record over a record's, otherwise the whole row.
514/// @param row The row.
515/// @param with The new content.
516/// @return The new row.
517[[nodiscard]] inline Value mergeRow(Value const& row, Value const& with) {
518 ValueRecord const* const fields = row.asRecord();
519 ValueRecord const* const over = with.asRecord();
520 if (fields == nullptr || over == nullptr) {
521 return with;
522 }
523 ValueRecord merged = *fields;
524 for (auto const& [name, field] : *over) {
525 merged.insert_or_assign(name, field);
526 }
527 return Value{std::move(merged)};
528}
529
530/// @brief Why a patch left a query's value as it was.
531enum class PatchMiss : std::uint8_t {
532 NoList, ///< The value, or its `at` member, is not a list: the patch can never apply, and is reported.
533 NoRow, ///< No row's `id` equals the key: the row is not loaded, which is not a fault.
534};
535
536/// @brief Replaces the row whose `id` equals @p key in a query's list of rows.
537/// @param value The query's value: the list itself, or a record holding it in member @p listMember.
538/// @param listMember The record member holding the list; empty when @p value is the list.
539/// @param key The row's id.
540/// @param with The new content, merged over the row.
541/// @return The new value, or why it is unchanged.
542// NOLINTBEGIN(bugprone-easily-swappable-parameters): a list, a key and a row are distinct roles of one type.
543[[nodiscard]] inline std::expected<Value, PatchMiss> patchRows(Value const& value, std::string_view listMember,
544 Value const& key, Value const& with) {
545 // NOLINTEND(bugprone-easily-swappable-parameters)
546 ValueRecord const* const record = listMember.empty() ? nullptr : value.asRecord();
547 if (!listMember.empty() && (record == nullptr || !record->contains(listMember))) {
548 return std::unexpected(PatchMiss::NoList);
549 }
550 Value const* const rows = record == nullptr ? &value : &record->find(listMember)->second;
551 ValueList const* const items = rows->asList();
552 if (items == nullptr) {
553 return std::unexpected(PatchMiss::NoList);
554 }
555 if (key.isNull()) {
556 return std::unexpected(PatchMiss::NoRow);
557 }
558 ValueList out = *items;
559 bool found = false;
560 for (Value& row : out) {
561 if (rowId(row) == key) {
562 row = mergeRow(row, with);
563 found = true;
564 }
565 }
566 if (!found) {
567 return std::unexpected(PatchMiss::NoRow);
568 }
569 if (record == nullptr) {
570 return Value{std::move(out)};
571 }
572 ValueRecord patched = *record;
573 patched.insert_or_assign(std::string{listMember}, Value{std::move(out)});
574 return Value{std::move(patched)};
575}
576
577/// @brief What a patch that found no list reports.
578/// @param query The query it patches.
579/// @param listMember The record member it names; empty when none.
580/// @return The diagnostic's reason.
581[[nodiscard]] inline std::string patchMissReason(std::string_view query, std::string_view listMember) {
582 if (listMember.empty()) {
583 return "the value of `" + std::string{query} +
584 "` is not a list, so the patch changes nothing; `at` names the record member that holds the list";
585 }
586 return "the value of `" + std::string{query} + "` has no list at `" + std::string{listMember} +
587 "`, so the patch changes nothing";
588}
589
590/// @brief Removes the named fields from a record, and from each record of a list.
591/// @param value The value.
592/// @param fields The field names.
593/// @return The value without them.
594// NOLINTNEXTLINE(misc-no-recursion): one level per nested list or record of the value.
595[[nodiscard]] inline Value withoutFields(Value const& value, std::vector<std::string> const& fields) {
596 if (fields.empty()) {
597 return value;
598 }
599 if (ValueRecord const* const record = value.asRecord()) {
600 ValueRecord kept = *record;
601 for (std::string const& field : fields) {
602 kept.erase(field);
603 }
604 return Value{std::move(kept)};
605 }
606 if (ValueList const* const items = value.asList()) {
607 ValueList kept;
608 kept.reserve(items->size());
609 for (Value const& item : *items) {
610 kept.push_back(withoutFields(item, fields));
611 }
612 return Value{std::move(kept)};
613 }
614 return value;
615}
616
617/// @brief The members of a catalog record path: `"header.rows"` is `header`, then `rows`; `""` is none.
618/// @param path The path.
619/// @return Its members in order.
620[[nodiscard]] inline std::vector<std::string> pathMembers(std::string_view path) {
621 std::vector<std::string> members;
622 for (auto const part : std::views::split(path, '.')) {
623 if (!part.empty()) {
624 members.emplace_back(part.begin(), part.end());
625 }
626 }
627 return members;
628}
629
630/// @brief The rows of a keyed record that a value holds at a path.
631///
632/// A list on the way is looked through, as the result walker looks through a `std::vector`. At the path's end a
633/// record with the key field is a row, and a record without it is a map whose values are the rows.
634/// @param value The value.
635/// @param path The members still to follow.
636/// @param keyField The record's key field.
637/// @param out Where the rows go.
638// NOLINTNEXTLINE(misc-no-recursion): one level per path member or nested list.
639inline void keyedRowsAt(Value const& value, std::span<std::string const> path, std::string_view keyField,
640 std::vector<Value const*>& out) {
641 if (ValueList const* const items = value.asList()) {
642 for (Value const& item : *items) {
643 keyedRowsAt(item, path, keyField, out);
644 }
645 return;
646 }
647 ValueRecord const* const record = value.asRecord();
648 if (record == nullptr) {
649 return;
650 }
651 if (!path.empty()) {
652 if (auto const found = record->find(path.front()); found != record->end()) {
653 keyedRowsAt(found->second, path.subspan(1), keyField, out);
654 }
655 return;
656 }
657 if (record->contains(keyField)) {
658 out.push_back(&value);
659 return;
660 }
661 for (auto const& [name, item] : *record) {
662 static_cast<void>(name);
663 if (ValueRecord const* const row = item.asRecord(); row != nullptr && row->contains(keyField)) {
664 out.push_back(&item);
665 }
666 }
667}
668
669/// @brief New rows by the canonical text of their key.
670using RowsByKey = std::map<std::string, Value const*, std::less<>>;
671
672/// @brief Merges new rows over the rows with the same key that a value holds at a path.
673/// @param value The value.
674/// @param path The members still to follow.
675/// @param keyField The record's key field.
676/// @param rows The new rows.
677/// @return The new value, or nothing when no row matched.
678// NOLINTNEXTLINE(misc-no-recursion): one level per path member or nested list.
679[[nodiscard]] inline std::optional<Value> patchKeyedRows(Value const& value, std::span<std::string const> path,
680 std::string_view keyField, RowsByKey const& rows) {
681 if (ValueList const* const items = value.asList()) {
682 ValueList out = *items;
683 bool changed = false;
684 for (Value& item : out) {
685 if (auto patched = patchKeyedRows(item, path, keyField, rows)) {
686 item = std::move(*patched);
687 changed = true;
688 }
689 }
690 return changed ? std::optional<Value>{Value{std::move(out)}} : std::nullopt;
691 }
692 ValueRecord const* const record = value.asRecord();
693 if (record == nullptr) {
694 return std::nullopt;
695 }
696 if (!path.empty()) {
697 auto const found = record->find(path.front());
698 if (found == record->end()) {
699 return std::nullopt;
700 }
701 auto patched = patchKeyedRows(found->second, path.subspan(1), keyField, rows);
702 if (!patched) {
703 return std::nullopt;
704 }
705 ValueRecord out = *record;
706 out.insert_or_assign(path.front(), std::move(*patched));
707 return Value{std::move(out)};
708 }
709 if (auto const key = record->find(keyField); key != record->end()) {
710 auto const row = rows.find(canonicalJson(key->second));
711 return row == rows.end() ? std::nullopt : std::optional<Value>{mergeRow(value, *row->second)};
712 }
713 ValueRecord out = *record;
714 bool changed = false;
715 for (auto& [name, item] : out) {
716 static_cast<void>(name);
717 if (ValueRecord const* const row = item.asRecord(); row != nullptr && row->contains(keyField)) {
718 if (auto patched = patchKeyedRows(item, {}, keyField, rows)) {
719 item = std::move(*patched);
720 changed = true;
721 }
722 }
723 }
724 return changed ? std::optional<Value>{Value{std::move(out)}} : std::nullopt;
725}
726
727/// @brief One optimistic write still waiting for its reply.
729 /// @brief The run that made it.
730 std::uint64_t run = 0;
731 /// @brief The record member holding the list; empty when the value is the list.
732 std::string at;
733 /// @brief The row's id.
735 /// @brief What was written.
737};
738
739/// @brief One entry of the session's query cache (spec 5 §5): one request in flight, one value, one error.
741 /// @brief An empty entry.
742 /// @param runtime The runtime its signals belong to.
743 explicit CacheEntry(reactive::Runtime& runtime)
744 : value{runtime}, hasValue{runtime, false}, error{runtime}, errorKind{runtime}, pending{runtime, false} {}
745
746 /// @brief The cache key: model type, instance, action and canonical body.
747 std::string key;
748 /// @brief The model type.
749 std::string typeId;
750 /// @brief The action.
751 std::string action;
752 /// @brief The body's JSON text.
753 std::string body;
754 /// @brief Whether it is an `evaluate` query.
755 bool evaluate = false;
756 /// @brief The value shown: the confirmed value with the optimistic layers over it.
758 /// @brief Whether a reply has been applied.
760 /// @brief The last failure's text; empty when none.
762 /// @brief The last failure's kind; empty when none.
764 /// @brief Whether a request is in flight.
766 /// @brief The value the server last confirmed.
768 /// @brief The optimistic writes still waiting, oldest first.
769 std::vector<OptimisticLayer> layers;
770 /// @brief The generation of the request in flight; a reply of an older one is dropped.
771 std::uint64_t generation = 0;
772 /// @brief When the last reply was applied.
773 std::optional<std::chrono::milliseconds> fetchedAt;
774 /// @brief Marked stale by a `refetch`, a backend switch or a change.
775 bool stale = false;
776 /// @brief Marked stale while a fetch was in flight: that fetch may have read before the change, so when it
777 /// settles the entry stays stale and fetches once more.
778 bool refetchAfter = false;
779 /// @brief The mounted queries using it.
780 int users = 0;
781 /// @brief The eviction timer, while no query uses it.
783 /// @brief The fields never cached.
784 std::vector<std::string> secretFields;
785 /// @brief The alias's instance token the entry was read through.
786 std::string instanceToken;
787 /// @brief The records the entry's action returns, with the key field and paths of each; empty for an
788 /// `evaluate` entry, which holds none.
789 std::vector<ActionRecordInfo> records;
790
791 /// @brief The catalog entry of a record the entry holds.
792 /// @param record The record's wire name.
793 /// @return It, or `nullptr` when the entry does not hold the record.
794 [[nodiscard]] ActionRecordInfo const* holding(std::string_view record) const {
795 auto const found = std::ranges::find(records, record, &ActionRecordInfo::record);
796 return found == records.end() ? nullptr : &*found;
797 }
798};
799
800/// @brief The rows of a keyed record a value holds, at each of the record's paths.
801/// @param value The value.
802/// @param paths The record's paths.
803/// @param keyField The record's key field.
804/// @return The rows.
805[[nodiscard]] inline std::vector<Value const*> keyedRowsOf(Value const& value, std::vector<std::string> const& paths,
806 std::string_view keyField) {
807 std::vector<Value const*> rows;
808 for (std::string const& path : paths) {
809 std::vector<std::string> const members = pathMembers(path);
810 keyedRowsAt(value, members, keyField, rows);
811 }
812 return rows;
813}
814
815/// @brief Merges new rows into a value at each of the record's paths.
816/// @param value The value.
817/// @param paths The record's paths.
818/// @param keyField The record's key field.
819/// @param rows The new rows by key.
820/// @return The new value, or nothing when no row matched.
821[[nodiscard]] inline std::optional<Value> patchHeldRows(Value const& value, std::vector<std::string> const& paths,
822 std::string_view keyField, RowsByKey const& rows) {
823 std::optional<Value> patched;
824 for (std::string const& path : paths) {
825 std::vector<std::string> const members = pathMembers(path);
826 auto next = patched ? patchKeyedRows(*patched, members, keyField, rows)
827 : patchKeyedRows(value, members, keyField, rows);
828 if (next) {
829 patched = std::move(next);
830 }
831 }
832 return patched;
833}
834
835/// @brief The entries one change marks stale, each once, in the order they were found.
836struct Marks {
837 /// @brief The entries.
838 std::vector<CacheEntry*> entries;
839
840 /// @brief Adds an entry, unless it is already there.
841 /// @param entry The entry.
842 void add(CacheEntry& entry) {
843 if (std::ranges::find(entries, &entry) == entries.end()) {
844 entries.push_back(&entry);
845 }
846 }
847};
848
849class QueryRuntime;
850class RtScope;
851class ScreenCore;
852
853} // namespace detail
854
855/// @brief How a mutation's reply was used.
856enum class ReplyUse : std::uint8_t {
857 Applied, ///< The reply is the latest: its values may patch the cache.
858 Discarded, ///< A newer run's reply was applied first, so its values are older and only mark.
859};
860
861/// @brief What running a host command (`host/1`) did.
863 /// @brief The outcome.
864 enum class Kind : std::uint8_t {
865 Ran, ///< The command ran.
866 Missing, ///< The client has no such command; nothing ran.
867 Refused, ///< The command exists and did not run: its arguments, or a connection that is not verified.
868 };
869 /// @brief The outcome.
871 /// @brief Why it was refused; empty otherwise.
872 std::string reason;
873};
874
875/// @brief The client's host commands, as a session runs them (spec 6 §14).
877 /// @brief The commands the client can run now; `{"can": "host:name"}` is true for these.
878 std::set<std::string, std::less<>> available;
879 /// @brief Runs a command with its evaluated `args` (`null` when the document gives none).
880 std::function<HostCommandResult(std::string const& name, Value const& args)> run;
881};
882
883/// @brief What screens of one client session share (spec 5 §5, §12b): the query cache, the mounted queries, the
884/// principal's permitted actions, the formatting facts and the client's `env`, `session` and `host` values.
885///
886/// It belongs to the runtime's owner, outlives every screen mounted on it, and is cleared by `signOut` and on a
887/// principal change.
888class Session {
889public:
890 /// @brief An empty session.
891 /// @param runtime The runtime. Borrowed.
892 /// @param options Timers, clock, catalog and the inline cap.
893 /// @throws std::invalid_argument when `maxInlineFileBytes` is 0 or above `kMaxInlineFileBytes`.
894 explicit Session(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, SessionOptions options = {})
895 : _runtime{&runtime},
896 _options{checkedOptions(std::move(options))},
897 _permitted{runtime},
898 _format{runtime, std::make_shared<FormatContext const>()},
899 _env{runtime},
900 _sessionValues{runtime},
901 _hostValues{runtime},
902 _hostCommandNames{runtime} {}
903
904 Session(Session const&) = delete;
905 Session& operator=(Session const&) = delete;
906 Session(Session&&) = delete;
907 Session& operator=(Session&&) = delete;
908 ~Session() = default;
909
910 /// @brief The runtime.
911 /// @return It.
912 [[nodiscard]] reactive::Runtime& runtime() const noexcept { return *_runtime; }
913
914 /// @brief The options.
915 /// @return They.
916 [[nodiscard]] SessionOptions const& options() const noexcept { return _options; }
917
918 /// @brief Sets the action ids the principal may run, as the catalog reply lists them; `{"can": ...}` follows them.
919 /// @param permitted The ids.
920 void setPermitted(std::set<std::string, std::less<>> permitted) { _permitted.set(std::move(permitted)); }
921
922 /// @brief Whether the principal may run an action, or the client can run a host command now. A tracked read.
923 ///
924 /// A `host:` name is answered from the client's own commands (`setHostCommands`), never from the server's
925 /// permitted set: the two have different owners, and a catalog refresh must not drop the client's commands.
926 /// @param action The action id, or `host:<command>`.
927 /// @return Whether it is permitted.
928 [[nodiscard]] bool can(std::string_view action) const {
929 static constexpr std::string_view kHostPrefix = "host:";
930 if (action.starts_with(kHostPrefix)) {
931 return _hostCommandNames.get().contains(action.substr(kHostPrefix.size()));
932 }
933 return _permitted.get().contains(action);
934 }
935
936 /// @brief Replaces the formatting facts; every `t` and display function re-evaluates.
937 /// @param format The facts.
938 void setFormatting(FormatContext format) { _format.set(std::make_shared<FormatContext const>(std::move(format))); }
939
940 /// @brief The formatting facts. A tracked read.
941 /// @return They.
942 [[nodiscard]] FormatContext const& formatting() const { return *_format.get(); }
943
944 /// @brief Changes the active locale, keeping the other formatting facts.
945 /// @param locale The BCP 47 locale.
946 void setLocale(std::string locale) {
947 FormatContext next = *_format.peek();
948 next.locale = std::move(locale);
949 setFormatting(std::move(next));
950 }
951
952 /// @brief Replaces the message catalogue, keeping the other formatting facts; every `t` re-resolves.
953 /// @param translations The provider, such as `MessageCatalogues::provider()`; empty shows every fallback.
955 FormatContext next = *_format.peek();
956 next.translations = std::move(translations);
957 setFormatting(std::move(next));
958 }
959
960 /// @brief Sets the `env` values (`widthClass`, `locale`, `platform`, `online`, `backend`).
961 /// @param values The values.
962 void setEnvironment(ValueRecord values) { _env.set(std::move(values)); }
963
964 /// @brief Sets the `session` values (`principal`, `userName`, …).
965 /// @param values The values.
966 void setSessionValues(ValueRecord values) { _sessionValues.set(std::move(values)); }
967
968 /// @brief Sets the `host` values the application registered.
969 /// @param values The values.
970 void setHostValues(ValueRecord values) { _hostValues.set(std::move(values)); }
971
972 /// @brief Sets the client's host commands: the names `{"can": "host:name"}` answers for, and what runs them. A
973 /// session without a runner asks `InterpreterHost::hostCommand`.
974 /// @param commands The names and the runner.
976 _hostCommandRunner = std::move(commands.run);
977 _hostCommandNames.set(std::move(commands.available));
978 }
979
980 /// @brief What runs host commands.
981 /// @return It; empty when the application set none.
982 [[nodiscard]] std::function<HostCommandResult(std::string const&, Value const&)> const& hostCommandRunner()
983 const noexcept {
984 return _hostCommandRunner;
985 }
986
987 /// @brief Reads `env`, `session` or `host`. A tracked read.
988 /// @param form `Env`, `Session` or `Host`.
989 /// @param name The name.
990 /// @return The value, or `null`.
991 [[nodiscard]] Value client(ExprKind form, std::string_view name) const {
992 ValueRecord const& values = [&]() -> ValueRecord const& {
993 if (form == ExprKind::Env) {
994 return _env.get();
995 }
996 return form == ExprKind::Session ? _sessionValues.get() : _hostValues.get();
997 }();
998 auto const found = values.find(name);
999 return found == values.end() ? Value{} : found->second;
1000 }
1001
1002 /// @brief The app document's scope, which `{"ref": "app.name"}` reads and `{"set": "app.name"}` writes; set by
1003 /// `Interpreter::mountApp` for as long as the `AppRoot` lives.
1004 /// @return It, or `nullptr` when no app document is mounted.
1005 [[nodiscard]] detail::RtScope const* appScope() const noexcept { return _appScope; }
1006
1007 /// @brief Installs or clears the app document's scope. Called by `AppRoot`, nothing else.
1008 /// @param scope The scope, or `nullptr`.
1009 void setAppScope(detail::RtScope const* scope) noexcept { _appScope = scope; }
1010
1011 /// @brief Drops every cache entry: `signOut` and a principal change (spec 5 §5, "Isolation").
1012 void clear() {
1013 _runtime->batch([this] {
1014 for (auto const& [key, entry] : _entries) {
1015 static_cast<void>(key);
1016 ++entry->generation;
1017 entry->evict.cancel();
1018 countTopics(*entry, -1);
1019 }
1020 _entries.clear();
1021 for (detail::QueryRuntime* const query : _mounted) {
1022 detachQuery(*query);
1023 }
1024 });
1025 }
1026
1027 /// @brief Carries out a committed backend switch (spec 6 §9), in one batch: every mounted screen's
1028 /// `onBackendChange` runs first, then every entry is marked stale and the mounted queries refetch, and
1029 /// `{"env": "backend"}` becomes @p backend. A query on a private alias therefore never fetches before its
1030 /// screen has recovered from the switch.
1031 /// @param backend The backend now in use.
1032 void backendChanged(std::string const& backend);
1033
1034 /// @brief Registers a mounted screen or app document, whose `onBackendChange` a backend switch runs.
1035 /// @param screen It. Borrowed until `detachScreen`.
1036 void attachScreen(detail::ScreenCore& screen) { _screens.push_back(&screen); }
1037
1038 /// @brief Forgets a screen or app document.
1039 /// @param screen It.
1040 void detachScreen(detail::ScreenCore& screen) { std::erase(_screens, &screen); }
1041
1042 /// @brief Starts a query's request before its screen mounts, so the screen mounts on a warm cache.
1043 /// @param channel The alias's channel.
1044 /// @param token The alias's instance token (`Screen::instanceToken`).
1045 /// @param typeId The model type.
1046 /// @param action The action.
1047 /// @param body The body.
1048 void prefetch(std::shared_ptr<ModelChannel> const& channel, std::string const& token, std::string const& typeId,
1049 std::string const& action, Value const& body) {
1050 auto entry = acquire(token, typeId, action, false, body);
1051 if (!entry->pending.peek() && (!entry->hasValue.peek() || entry->stale)) {
1052 fetch(entry, channel);
1053 }
1054 release(entry);
1055 }
1056
1057 /// @brief The number of entries in the cache.
1058 /// @return It.
1059 [[nodiscard]] std::size_t cacheSize() const noexcept { return _entries.size(); }
1060
1061 /// @brief A fresh instance token for a private alias: what the cache key holds for its instance.
1062 /// @return It, unique in the session.
1063 [[nodiscard]] std::string privateToken() { return "private#" + std::to_string(++_privateTokens); }
1064
1065 /// @brief Whether the newest write to a document source was made by a user event (spec 5 §6, `source: "user"`).
1066 /// @return It.
1067 [[nodiscard]] bool lastWriteByUser() const noexcept { return _lastWriteUser; }
1068
1069 /// @brief Records a write to a document source.
1070 void noteWrite() noexcept { _lastWriteUser = _userDepth > 0; }
1071
1072 /// @brief Runs @p body as a user event: what it writes counts as the user's.
1073 /// @param body The body.
1074 void asUser(std::function<void()> const& body) {
1075 ++_userDepth;
1076 try {
1077 body();
1078 } catch (...) {
1079 --_userDepth;
1080 throw;
1081 }
1082 --_userDepth;
1083 }
1084
1085 /// @brief The cache key of a query.
1086 /// @param token The alias's instance token.
1087 /// @param typeId The model type.
1088 /// @param action The action.
1089 /// @param evaluate Whether it is an `evaluate` query.
1090 /// @param body The canonical body.
1091 /// @return The key.
1092 [[nodiscard]] static std::string cacheKey(std::string const& token, std::string const& typeId,
1093 std::string const& action, bool evaluate, std::string const& body) {
1094 return typeId + '\x1f' + token + '\x1f' + (evaluate ? "evaluate:" : "") + action + '\x1f' + body;
1095 }
1096
1097 /// @brief Finds or makes an entry and counts one more user.
1098 /// @param token The alias's instance token.
1099 /// @param typeId The model type.
1100 /// @param action The action.
1101 /// @param evaluate Whether it is an `evaluate` query.
1102 /// @param body The body.
1103 /// @return The entry.
1104 std::shared_ptr<detail::CacheEntry> acquire(std::string const& token, std::string const& typeId,
1105 std::string const& action, bool evaluate, Value const& body) {
1106 std::string const text = detail::canonicalJson(body);
1107 std::string key = cacheKey(token, typeId, action, evaluate, text);
1108 auto found = _entries.find(key);
1109 if (found == _entries.end()) {
1110 auto entry = std::make_shared<detail::CacheEntry>(*_runtime);
1111 entry->key = key;
1112 entry->typeId = typeId;
1113 entry->action = action;
1114 entry->body = text;
1115 entry->evaluate = evaluate;
1116 entry->instanceToken = token;
1117 if (_options.catalog.action) {
1118 if (auto const info = _options.catalog.action(action)) {
1119 entry->secretFields = info->secretFields;
1120 if (!evaluate) {
1121 entry->records = info->records;
1122 }
1123 }
1124 }
1125 countTopics(*entry, +1);
1126 found = _entries.emplace(std::move(key), std::move(entry)).first;
1127 }
1128 ++found->second->users;
1129 found->second->evict.cancel();
1130 return found->second;
1131 }
1132
1133 /// @brief Counts one user less; an entry nobody uses is dropped after `keepUnused`.
1134 /// @param entry The entry.
1135 void release(std::shared_ptr<detail::CacheEntry> const& entry) {
1136 if (--entry->users > 0 || _options.scheduler == nullptr) {
1137 return;
1138 }
1139 std::weak_ptr<detail::CacheEntry> const weak = entry;
1140 entry->evict = _options.scheduler->after(_options.keepUnused, [this, weak] {
1141 auto const held = weak.lock();
1142 if (held && held->users == 0) {
1143 countTopics(*held, -1);
1144 _entries.erase(held->key);
1145 flushWatches();
1146 }
1147 });
1148 }
1149
1150 /// @brief Sends an entry's request under a new generation; a reply of an older generation is dropped.
1151 /// @param entry The entry.
1152 /// @param channel The channel to send it through; `nullptr` fails the request at once.
1153 void fetch(std::shared_ptr<detail::CacheEntry> const& entry, std::shared_ptr<ModelChannel> const& channel) {
1154 // The watch goes out before the read, on the same connection, so a change committed after the read is
1155 // seen as an event.
1156 flushWatches();
1157 std::uint64_t const generation = ++entry->generation;
1158 if (channel == nullptr) {
1159 settleFailure(*entry, "the model alias has no instance", "error");
1160 return;
1161 }
1162 entry->pending.set(true);
1163 std::weak_ptr<detail::CacheEntry> const weak = entry;
1164 try {
1165 auto completion = channel->execute(entry->action, entry->body, CallOptions{.evaluate = entry->evaluate});
1166 completion
1167 .then([this, weak, generation](std::string const& reply) {
1168 auto const held = weak.lock();
1169 if (held && held->generation == generation) {
1170 settleReply(*held, reply);
1171 }
1172 })
1173 .onError([this, weak, generation](std::exception_ptr const& error) {
1174 auto const held = weak.lock();
1175 if (held && held->generation == generation) {
1176 settleFailure(*held, detail::failureText(error), classify(error));
1177 }
1178 });
1179 } catch (...) {
1180 std::exception_ptr const error = std::current_exception();
1181 settleFailure(*entry, detail::failureText(error), classify(error));
1182 }
1183 }
1184
1185 /// @brief Writes an entry's confirmed value and re-applies its optimistic layers over it.
1186 /// @param entry The entry.
1187 /// @param confirmed The confirmed value.
1188 void confirm(detail::CacheEntry& entry, Value const& confirmed) {
1189 entry.confirmed = detail::withoutFields(confirmed, entry.secretFields);
1190 showLayers(entry);
1191 }
1192
1193 /// @brief Recomputes what an entry shows from its confirmed value and its layers.
1194 /// @param entry The entry.
1196 Value shown = entry.confirmed;
1197 for (detail::OptimisticLayer const& layer : entry.layers) {
1198 if (auto patched = detail::patchRows(shown, layer.at, layer.key, layer.with)) {
1199 shown = std::move(*patched);
1200 }
1201 }
1202 noteWrite();
1203 entry.value.set(std::move(shown));
1204 }
1205
1206 /// @brief The error kind of a failure.
1207 /// @param error The failure.
1208 /// @return The kind.
1209 [[nodiscard]] std::string classify(std::exception_ptr const& error) const {
1210 return _options.classify ? _options.classify(error) : detail::defaultErrorKind(error);
1211 }
1212
1213 /// @brief The clock.
1214 /// @return Now.
1215 [[nodiscard]] std::chrono::milliseconds now() const {
1216 if (_options.clock) {
1217 return _options.clock();
1218 }
1219 return std::chrono::duration_cast<std::chrono::milliseconds>(
1220 std::chrono::steady_clock::now().time_since_epoch());
1221 }
1222
1223 /// @brief Registers a mounted query, which a reconnect and a backend switch refetch.
1224 /// @param query The query.
1225 void attachQuery(detail::QueryRuntime& query) { _mounted.push_back(&query); }
1226
1227 /// @brief Unregisters a mounted query.
1228 /// @param query The query.
1229 void forgetQuery(detail::QueryRuntime& query) { std::erase(_mounted, &query); }
1230
1231 /// @brief A call of @p actionId on a model of @p typeId succeeded: applies the action's effects to the cache
1232 /// (spec 8 §9).
1233 ///
1234 /// `updates R` merges the reply's rows of `R` into each entry holding `R`, matched by the record's key field at
1235 /// the entry's paths, with no fetch; an entry whose fetch is in flight also fetches once more, since that fetch
1236 /// may have read before the write. `changes R` marks each entry holding `R` stale. `changesInstance` marks each
1237 /// entry read through the same instance. A marked entry refetches through a mounted query that uses it. A
1238 /// discarded reply only marks: its `updates` act as `changes`.
1239 /// @param typeId The model type.
1240 /// @param actionId The action.
1241 /// @param reply The reply's value.
1242 /// @param use Whether the reply was applied or discarded.
1243 /// @param instanceToken The instance token of the alias the call went through.
1244 void actionSucceeded(std::string const& typeId, std::string const& actionId, Value const& reply, ReplyUse use,
1245 std::string const& instanceToken);
1246
1247 /// @brief The connection to the server was made again (spec 8 §7, "Reconnect"): sends the whole watch set with
1248 /// `replace: true`, then marks every entry but an `evaluate` one stale. A mounted entry refetches, and one
1249 /// whose fetch is in flight fetches once more when that fetch settles, so a change missed while the
1250 /// connection was down is read again.
1251 void reconnected();
1252
1253 /// @brief The topics the cache holds now: every record an entry holds, and the keyed shared instance of every
1254 /// entry whose action holds no record. What `reconnected` sends.
1255 /// @return The topics, records and instances each in name order; `replace` is false.
1256 [[nodiscard]] wire::WatchBody watchSet() const;
1257
1258 /// @brief Applies a server event to the cache (spec 8 §9).
1259 ///
1260 /// A `changes` event marks the entries it names stale, and a `resync` sends the whole watch set again, as
1261 /// `reconnected` does, and marks every entry but an `evaluate` one;
1262 /// each marked entry refetches through a mounted query that uses it, and one no query uses refetches when a
1263 /// query mounts on it. An event of another topic, or a payload that cannot be read, is ignored.
1264 /// @param event The event.
1265 void deliverEvent(wire::EventBody const& event);
1266
1267 /// @brief Applies a `changes` event's payload to the cache, in one reactive batch.
1268 ///
1269 /// A record change with keys marks each entry holding the record whose value has a row with one of the keys,
1270 /// and an entry holding it keyless; a record change without keys marks every entry holding the record. An
1271 /// instance change marks every entry read through that instance: the keyed shared one its primary key names,
1272 /// or, with no primary key, the connection's own private ones of that model type.
1273 /// @param changes The payload.
1274 void applyChanges(wire::ChangesBody const& changes);
1275
1276 /// @brief Marks an entry stale. While its fetch is in flight, the entry also refetches once that fetch settles,
1277 /// because the fetch may have read before the change.
1278 /// @param entry The entry.
1279 static void markStale(detail::CacheEntry& entry) {
1280 entry.stale = true;
1281 if (entry.pending.peek()) {
1282 entry.refetchAfter = true;
1283 }
1284 }
1285
1286private:
1287 // A cap of 0 moves nothing inline, and one above the largest would not fit an envelope once encoded.
1288 [[nodiscard]] static SessionOptions checkedOptions(SessionOptions options) {
1289 if (options.maxInlineFileBytes == 0 || options.maxInlineFileBytes > kMaxInlineFileBytes) {
1290 throw std::invalid_argument{"morph::ui::Session: maxInlineFileBytes is " +
1291 std::to_string(options.maxInlineFileBytes) + "; it is 1 to " +
1292 std::to_string(kMaxInlineFileBytes) + " (kMaxInlineFileBytes)"};
1293 }
1294 return options;
1295 }
1296
1297 void settleReply(detail::CacheEntry& entry, std::string const& reply) {
1298 auto json = parseJson(reply);
1299 auto value = json ? valueFromJson(*json) : std::unexpected(json.error());
1300 if (!value) {
1301 settleFailure(entry, "the reply is not a document value: " + value.error(), "error");
1302 return;
1303 }
1304 _runtime->batch([&] {
1305 confirm(entry, *value);
1306 entry.hasValue.set(true);
1307 entry.error.set({});
1308 entry.errorKind.set({});
1309 entry.pending.set(false);
1310 entry.fetchedAt = now();
1311 entry.stale = entry.refetchAfter;
1312 if (entry.refetchAfter) {
1313 entry.refetchAfter = false;
1314 refetchMounted(entry);
1315 }
1316 });
1317 }
1318
1319 /// @brief Refetches an entry through the mounted queries that use it; one fetch even when several do.
1320 /// @param entry The entry.
1321 void refetchMounted(detail::CacheEntry const& entry);
1322
1323 /// @brief Applies a reply's effects to the cache, for `actionSucceeded`.
1324 /// @param typeId The model type.
1325 /// @param actionId The action.
1326 /// @param reply The reply's value.
1327 /// @param use Whether the reply was applied or discarded.
1328 /// @param instanceToken The instance token of the alias the call went through.
1329 void applyReplyEffects(std::string const& typeId, std::string const& actionId, Value const& reply, ReplyUse use,
1330 std::string const& instanceToken);
1331
1332 /// @brief Counts an entry's topics in or out, queueing a `watch` for a count that leaves zero and an `unwatch`
1333 /// for one that returns to it.
1334 /// @param entry The entry.
1335 /// @param delta `+1` when the entry is made, `-1` when it is dropped.
1336 void countTopics(detail::CacheEntry const& entry, int delta);
1337
1338 /// @brief Sends the queued watch changes, at most one `watch` and one `unwatch`; drops them when there is no
1339 /// watcher.
1340 void flushWatches();
1341
1342 /// @brief Sends one watch request, ignoring its reply: a refused topic simply sends no events.
1343 /// @param kind The kind, `watch` or `unwatch`.
1344 /// @param body The body.
1345 void sendWatch(std::string_view kind, wire::WatchBody const& body) const;
1346
1347 /// @brief Marks every entry but an `evaluate` one stale and refetches the mounted ones, in one batch.
1348 void markAllStale();
1349
1350 /// @brief Marks each entry of @p marks stale and refetches it through a mounted query that uses it.
1351 /// @param marks The entries.
1352 void markAndRefetch(detail::Marks const& marks);
1353
1354 /// @brief Applies one `updates` or `changes` effect of a reply: patches the entries holding the record, or marks
1355 /// them.
1356 /// @param effect The effect.
1357 /// @param record The record it names.
1358 /// @param info The action's catalog entry, for the records its reply holds.
1359 /// @param reply The reply's value.
1360 /// @param use Whether the reply was applied or discarded.
1361 /// @param marks Where entries to mark go.
1362 void applyRecordEffect(ActionEffect const& effect, std::string const& record, ActionInfo const& info,
1363 Value const& reply, ReplyUse use, detail::Marks& marks);
1364
1365 /// @brief Finds the entries a `changes` event's record change names.
1366 /// @param change The change.
1367 /// @param marks Where they go.
1368 void markRecordChange(effects::RecordChange const& change, detail::Marks& marks);
1369
1370 /// @brief Finds the entries a `changes` event's instance change names.
1371 /// @param instance The instance.
1372 /// @param marks Where they go.
1373 void markInstanceChange(wire::InstanceTopic const& instance, detail::Marks& marks);
1374
1375 void settleFailure(detail::CacheEntry& entry, std::string text, std::string kind) {
1376 _runtime->batch([&] {
1377 noteWrite();
1378 entry.error.set(std::move(text));
1379 entry.errorKind.set(std::move(kind));
1380 entry.pending.set(false);
1381 });
1382 }
1383
1384 static void detachQuery(detail::QueryRuntime& query);
1385
1386 reactive::Runtime* _runtime;
1387 SessionOptions _options;
1388 reactive::Signal<std::set<std::string, std::less<>>> _permitted;
1389 std::map<std::string, int, std::less<>> _recordRefs;
1390 std::map<std::pair<std::string, std::string>, int> _instanceRefs;
1391 std::set<std::string, std::less<>> _watchRecords;
1392 std::set<std::string, std::less<>> _unwatchRecords;
1393 std::set<std::pair<std::string, std::string>> _watchInstances;
1394 std::set<std::pair<std::string, std::string>> _unwatchInstances;
1395 reactive::Signal<std::shared_ptr<FormatContext const>> _format;
1396 reactive::Signal<ValueRecord> _env;
1397 reactive::Signal<ValueRecord> _sessionValues;
1398 reactive::Signal<ValueRecord> _hostValues;
1399 detail::RtScope const* _appScope = nullptr;
1400 std::vector<detail::ScreenCore*> _screens;
1401 reactive::Signal<std::set<std::string, std::less<>>> _hostCommandNames;
1402 std::function<HostCommandResult(std::string const&, Value const&)> _hostCommandRunner;
1403 std::map<std::string, std::shared_ptr<detail::CacheEntry>, std::less<>> _entries;
1404 std::vector<detail::QueryRuntime*> _mounted;
1405 int _userDepth = 0;
1406 bool _lastWriteUser = false;
1407 std::uint64_t _privateTokens = 0;
1408};
1409// ---------------------------------------------------------------------------------------------------------------
1410// Runtime structures
1411// ---------------------------------------------------------------------------------------------------------------
1412
1413/// @brief A model the opener lends to a screen whose alias says `{"from": alias}` (spec 6 §16).
1415 /// @brief The opener's channel.
1416 std::shared_ptr<ModelChannel> channel;
1417 /// @brief The opener's instance token, so the two share cache entries.
1418 std::string token;
1419};
1420
1421class Screen;
1422
1423class Inspector;
1424
1425/// @brief One command of a command list the inspector's log shows (spec 6 §21).
1427 /// @brief The command's key: `set`, `run`, `refetch`, …
1428 std::string kind;
1429 /// @brief The name it acts on (`Command::target`); empty for a command that names none.
1430 std::string target;
1431 /// @brief The command's JSON Pointer in the document.
1432 std::string path;
1433};
1434
1435/// @brief One command list as it ran: the event that ran it, its commands, and the reactive batch they ran in.
1437 /// @brief The JSON Pointer of the list: the event, lifecycle key or step that holds it.
1438 std::string event;
1439 /// @brief Its commands, in order.
1440 std::vector<LoggedCommand> commands;
1441 /// @brief The number of the reactive batch it ran in (`reactive::detail::RuntimeCore::currentBatch`); 0 outside
1442 /// every batch.
1443 std::uint64_t batch = 0;
1444};
1445
1446/// @brief What `Interpreter::mount` is given beside the document.
1448 /// @brief The container the screen's root widget is appended to; `nullptr` makes a backend root.
1450 /// @brief The models the opener lends, by the alias of this screen that borrows them.
1451 std::map<std::string, BorrowedModel, std::less<>> borrowed;
1452 /// @brief The reactive scope depth of the screen's own scope.
1453 std::size_t depth = 0;
1454 /// @brief The screen this mount replaces, such as one whose document changed; `nullptr` for a fresh mount. Each
1455 /// state of the new screen's own scope starts from that screen's value of the state of the same name and
1456 /// type, instead of from its init. Borrowed for the call.
1457 Screen const* keepStateOf = nullptr;
1458};
1459
1460namespace detail {
1461
1462/// @brief A name bound for the expressions of one place: a command's `event`, a step's `as`, `result` in a
1463/// `patch`, `body` in an `optimistic`, `props` in a fallback, `boundary` in a boundary's `error`.
1464struct Binding {
1465 /// @brief The name.
1466 std::string name;
1467 /// @brief Reads its value; a tracked read for a reactive one.
1468 std::function<Value()> read;
1469};
1470
1471/// @brief The names bound at one place, innermost last.
1472using Bindings = std::vector<Binding>;
1473
1474class ScreenCore;
1475class RtScope;
1476struct ElementState;
1477class MutationRuntime;
1478struct CheckRuntime;
1479struct BoundaryRuntime;
1480
1481/// @brief One state declaration at run time.
1483 /// @brief The declaration.
1484 StateDecl const* decl = nullptr;
1485 /// @brief The value, made when the init is evaluated.
1486 std::unique_ptr<reactive::Signal<Value>> value;
1487 /// @brief The scope that declares it, where its init is evaluated.
1488 RtScope const* scope = nullptr;
1489 /// @brief Set while the init is evaluated, so a cyclic read gives `null` instead of recursing.
1490 bool initializing = false;
1491};
1492
1493/// @brief One model alias at run time: the instance it names, and the channel bound to it.
1495 /// @brief The declaration.
1496 ModelDecl const* decl = nullptr;
1497 /// @brief `Private`, `Shared`, `Keyed` or `Borrowed`, the default resolved through the catalog.
1499 /// @brief The instance token: what the cache key holds for the instance; empty while unbound (a `null` key).
1501 /// @brief The channel of the token it was last bound for.
1502 std::shared_ptr<ModelChannel> channel;
1503 /// @brief That token.
1504 std::string boundToken;
1505};
1506
1507/// @brief The values of an `upload` transfer id (spec 6 §18).
1509 /// @brief An idle transfer.
1510 /// @param runtime The runtime.
1511 explicit TransferState(reactive::Runtime& runtime) : progress{runtime}, pending{runtime, false}, error{runtime} {}
1512 /// @brief 0 to 1, or `null`.
1514 /// @brief Whether it is running.
1516 /// @brief The failure's text, or `null`.
1518};
1519
1520/// @brief An element's live values (spec 5 §6).
1522 /// @brief The element's state before any user action.
1523 /// @param runtime The runtime.
1525 : draft{runtime},
1526 committed{runtime},
1527 edited{runtime, false},
1528 touched{runtime, false},
1529 focused{runtime, false},
1530 open{runtime, false},
1531 submitted{runtime, false},
1532 selection{runtime, Value{ValueList{}}},
1533 activeKey{runtime},
1534 checksVersion{runtime, 0},
1535 inner{runtime, nullptr} {}
1536
1537 /// @brief The node.
1538 DocNode const* node = nullptr;
1539 /// @brief The scope that declares it.
1540 RtScope* scope = nullptr;
1541 /// @brief What the user is editing: a text input's text, a checkbox's state, a select's key, a slider's number,
1542 /// a date-time input's timestamp.
1544 /// @brief The committed value of an input whose value no expression binds.
1546 /// @brief Whether the user changed the draft since it last followed the bound value.
1548 /// @brief Whether it has been committed at least once (Enter or focus leaving after an edit).
1550 /// @brief Whether it has keyboard focus. The view tree reports no focus, so it stays false.
1552 /// @brief A dialog's or drawer's `open`, set by `open` and `close`.
1554 /// @brief A dialog's or drawer's `submitted`.
1556 /// @brief A table's selected keys.
1558 /// @brief A table's last activated key.
1560 /// @brief Bumped when a check targeting it comes or goes.
1562 /// @brief A dialog's or drawer's scope while its content is mounted.
1564 /// @brief The checks targeting it.
1565 std::vector<CheckRuntime*> checks;
1566 /// @brief The value an expression binds (`value`, `checked`, `selected`); `nullptr` when the element is
1567 /// uncontrolled.
1569 /// @brief What is wrong now, as a list of strings: `required` unmet, the node's `errors`, the reply's field
1570 /// errors and the failing checks' messages.
1572 /// @brief A table's rows or a select's options, for its counts and `selectedOption`.
1574 /// @brief A `form`'s session (spec 2), made when the element is wired; null when its schema is unknown.
1575 std::unique_ptr<forms::FormSession> form;
1576 /// @brief A `collection`'s session (spec 2), made when the element is wired; null when its view document is
1577 /// unknown or does not build.
1578 std::unique_ptr<forms::CollectionSession> collection;
1579 /// @brief A `wizard`'s session (spec 2), made when the element is wired; null when its wizard document or a
1580 /// step's schema is unknown or does not build.
1581 std::unique_ptr<forms::WizardSession> wizard;
1582};
1583
1584/// @brief One check at run time.
1585struct CheckRuntime {
1586 /// @brief The declaration.
1587 CheckDecl const* decl = nullptr;
1588 /// @brief Whether `assert` is `false`.
1590 /// @brief The message, as text.
1592 /// @brief The elements it targets.
1593 std::vector<ElementState*> targets;
1594
1595 CheckRuntime() = default;
1596 CheckRuntime(CheckRuntime const&) = delete;
1597 CheckRuntime& operator=(CheckRuntime const&) = delete;
1598 CheckRuntime(CheckRuntime&&) = delete;
1599 CheckRuntime& operator=(CheckRuntime&&) = delete;
1600
1601 /// @brief Leaves its targets.
1603 for (ElementState* const target : targets) {
1604 std::erase(target->checks, this);
1605 target->checksVersion.set(target->checksVersion.peek() + 1);
1606 }
1607 }
1608};
1609
1610/// @brief One declared name of a run-time scope.
1611struct NameRef {
1612 /// @brief What it declares.
1614 /// @brief A param's value.
1616 /// @brief The state.
1617 StateSlot* state = nullptr;
1618 /// @brief The `let`.
1620 /// @brief The `let`'s expression, for the boundary's ownership walk.
1622 /// @brief The query.
1624 /// @brief The mutation.
1626 /// @brief The model alias.
1627 ModelAlias* model = nullptr;
1628 /// @brief The element.
1630 /// @brief The transfer.
1632};
1633
1634/// @brief A boundary at run time: the queries it owns (spec 5 §9).
1636 /// @brief The queries read inside its children.
1637 std::vector<QueryRuntime*> queries;
1638};
1639
1640/// @brief One document scope at run time: the screen, a dialog's or drawer's content, a switch case or a row.
1641///
1642/// It lives while anything that reads it does: the view nodes lowered in it hold it by `shared_ptr`, a reply or
1643/// a timer holds it weakly. It belongs to a `ScreenCore`, which outlives it.
1644class RtScope : public std::enable_shared_from_this<RtScope> {
1645public:
1646 /// @brief An empty scope.
1647 /// @param screen The screen.
1648 /// @param enclosing The enclosing scope; `nullptr` for the screen and a component fallback.
1649 /// @param current The row of a row scope; `nullptr` otherwise.
1650 /// @param depth The reactive scope depth of what it owns.
1651 RtScope(ScreenCore& screen, std::shared_ptr<RtScope> enclosing, reactive::Signal<Value> const* current,
1652 std::size_t depth);
1653
1654 RtScope(RtScope const&) = delete;
1655 RtScope& operator=(RtScope const&) = delete;
1656 RtScope(RtScope&&) = delete;
1657 RtScope& operator=(RtScope&&) = delete;
1658
1659 /// @brief Releases what it owns, and tells the dialog it belongs to.
1660 ~RtScope();
1661
1662 /// @brief Resolves a name, innermost scope first.
1663 /// @param name The name.
1664 /// @return The declaration, or `nullptr`.
1665 [[nodiscard]] NameRef const* find(std::string_view name) const {
1666 for (RtScope const* scope = this; scope != nullptr; scope = scope->parent.get()) {
1667 auto const found = scope->names.find(name);
1668 if (found != scope->names.end()) {
1669 return &found->second;
1670 }
1671 }
1672 return nullptr;
1673 }
1674
1675 /// @brief The innermost row, from this scope outwards.
1676 /// @return The row signal, or `nullptr`.
1677 [[nodiscard]] reactive::Signal<Value> const* innermostRow() const {
1678 for (RtScope const* scope = this; scope != nullptr; scope = scope->parent.get()) {
1679 if (scope->row != nullptr) {
1680 return scope->row;
1681 }
1682 }
1683 return nullptr;
1684 }
1685
1686 /// @brief The innermost scope that decides when errors show and is submitted: a dialog's, or the screen's.
1687 /// @return The scope.
1688 [[nodiscard]] RtScope const& policyScope() const {
1689 RtScope const* scope = this;
1690 while (scope->submitted == nullptr && scope->parent != nullptr) {
1691 scope = scope->parent.get();
1692 }
1693 return *scope;
1694 }
1695
1696 /// @brief Whether every element of the scope is valid and no blocking check of it fails. A tracked read.
1697 /// @return It.
1698 [[nodiscard]] bool ready() const;
1699
1700 /// @brief The screen.
1702 /// @brief The enclosing scope.
1703 std::shared_ptr<RtScope> parent;
1704 /// @brief The row of a row scope.
1706 /// @brief The dialog or drawer whose content it is.
1708 /// @brief `touched`, `submit` or `always`, for a scope that decides it.
1709 std::string showErrors;
1710 /// @brief `submitted` of a scope that decides when errors show.
1712 /// @brief Its names.
1713 std::map<std::string, NameRef, std::less<>> names;
1714 /// @brief Its state.
1715 std::vector<std::unique_ptr<StateSlot>> states;
1716 /// @brief Its elements, in document order.
1717 std::vector<std::unique_ptr<ElementState>> elements;
1718 /// @brief Its transfers.
1719 std::vector<std::unique_ptr<TransferState>> transfers;
1720 /// @brief Its model aliases.
1721 std::vector<std::unique_ptr<ModelAlias>> models;
1722 /// @brief Its queries.
1723 std::vector<std::unique_ptr<QueryRuntime>> queries;
1724 /// @brief Its mutations.
1725 std::vector<std::unique_ptr<MutationRuntime>> mutations;
1726 /// @brief Its checks.
1727 std::vector<std::unique_ptr<CheckRuntime>> checks;
1728 /// @brief The reactive nodes it owns; declared last, so they go first.
1730};
1731
1732/// @brief The `Environment` the evaluator reads through: a scope chain and the names bound at one place.
1733class ScopeEnv final : public Environment {
1734public:
1735 /// @brief Reads @p scope.
1736 /// @param scope The scope.
1737 /// @param bindings The names bound here; may be `nullptr`.
1738 /// @param row A row to read instead of the scope's (a table's `key`, evaluated before its row scope exists).
1739 ScopeEnv(RtScope const& scope, Bindings const* bindings, Value const* row = nullptr)
1740 : _scope{&scope}, _bindings{bindings}, _row{row} {}
1741
1742 /// @brief Reads a name: a binding, then the scope chain.
1743 /// @param head The name.
1744 /// @param path The path after it.
1745 /// @return The value.
1746 [[nodiscard]] Value ref(std::string_view head, std::span<std::string const> path) const override;
1747
1748 /// @brief Reads the innermost row.
1749 /// @param path The path.
1750 /// @return The value.
1751 [[nodiscard]] Value row(std::span<std::string const> path) const override {
1752 if (_row != nullptr) {
1753 return walk(*_row, path);
1754 }
1755 reactive::Signal<Value> const* const current = _scope->innermostRow();
1756 return current == nullptr ? Value{} : walk(current->get(), path);
1757 }
1758
1759 /// @brief Reads a query or mutation form.
1760 /// @param form The form.
1761 /// @param name The query or mutation.
1762 /// @return The value.
1763 [[nodiscard]] Value control(ExprKind form, std::string_view name) const override;
1764
1765 /// @brief Reads a customization choice.
1766 /// @param point The point.
1767 /// @param path The path.
1768 /// @return The value.
1769 [[nodiscard]] Value pref(std::string_view point, std::span<std::string const> path) const override;
1770
1771 /// @brief Reads `env`, `session` or `host`.
1772 /// @param form The form.
1773 /// @param name The name.
1774 /// @return The value.
1775 [[nodiscard]] Value client(ExprKind form, std::string_view name) const override;
1776
1777 /// @brief The session's formatting facts.
1778 /// @return They.
1779 [[nodiscard]] FormatContext const& formatting() const override;
1780
1781 /// @brief Reports to the screen's diagnostics.
1782 /// @param path The expression's path.
1783 /// @param reason What went wrong.
1784 void report(std::string_view path, std::string_view reason) const override;
1785
1786private:
1787 RtScope const* _scope;
1788 Bindings const* _bindings;
1789 Value const* _row;
1790};
1791
1792/// @brief Evaluates an expression in a scope.
1793/// @param scope The scope.
1794/// @param bindings The names bound here; may be `nullptr`.
1795/// @param expr The expression; `nullptr` evaluates to `null`.
1796/// @return The value.
1797[[nodiscard]] inline Value evalIn(RtScope const& scope, Bindings const* bindings, ExprPtr const& expr) {
1798 if (!expr) {
1799 return Value{};
1800 }
1801 ScopeEnv const env{scope, bindings};
1802 return evaluate(*expr, env);
1803}
1804
1805/// @brief Whether a value counts as given for `required`: not `null`, not an empty string or list.
1806/// @param value The value.
1807/// @return It.
1808[[nodiscard]] inline bool engaged(Value const& value) {
1809 if (value.isNull()) {
1810 return false;
1811 }
1812 if (std::string const* const text = value.asString()) {
1813 return !text->empty();
1814 }
1815 if (ValueList const* const items = value.asList()) {
1816 return !items->empty();
1817 }
1818 return true;
1819}
1820
1821/// @brief A value as text for a widget: a string as itself, a number in the locale, a timestamp in ISO 8601,
1822/// `null` as nothing, anything else as JSON.
1823/// @param value The value.
1824/// @param format The formatting facts.
1825/// @return The text.
1826[[nodiscard]] inline std::string textOf(Value const& value, FormatContext const& format) {
1827 if (value.isNull()) {
1828 return {};
1829 }
1830 if (std::string const* const text = value.asString()) {
1831 return *text;
1832 }
1833 if (bool const* const flag = value.asBool()) {
1834 return *flag ? "true" : "false";
1835 }
1836 if (Quantity const* const quantity = value.asQuantity()) {
1837 return formatNumber(value, format.numeric).value_or(std::string{}) + " " +
1838 unitDisplay(quantity->unit, format.units);
1839 }
1840 if (auto number = formatNumber(value, format.numeric)) {
1841 return *std::move(number);
1842 }
1843 if (time::DateTime const* const instant = value.asTimestamp()) {
1844 return instant->toIso8601();
1845 }
1846 return canonicalJson(value);
1847}
1848
1849/// @brief A value as a view key: an `int` or a string as itself, anything else as its JSON text.
1850/// @param value The value.
1851/// @return The key.
1852[[nodiscard]] inline Key keyOf(Value const& value) {
1853 if (std::int64_t const* const number = value.asInt()) {
1854 return Key{*number};
1855 }
1856 if (std::string const* const text = value.asString()) {
1857 return Key{*text};
1858 }
1859 return Key{canonicalJson(value)};
1860}
1861
1862/// @brief A view key as a value.
1863/// @param key The key.
1864/// @return An `int` or a string.
1865[[nodiscard]] inline Value valueOfKey(Key const& key) {
1866 if (std::int64_t const* const number = std::get_if<std::int64_t>(&key)) {
1867 return Value{*number};
1868 }
1869 return Value{std::get<std::string>(key)};
1870}
1871
1872/// @brief A list of strings out of a value: a string, a list of strings, or nothing.
1873/// @param value The value.
1874/// @return The strings.
1875[[nodiscard]] inline std::vector<std::string> stringsOf(Value const& value) {
1876 std::vector<std::string> out;
1877 if (std::string const* const text = value.asString()) {
1878 out.push_back(*text);
1879 } else if (ValueList const* const items = value.asList()) {
1880 for (Value const& item : *items) {
1881 if (std::string const* const element = item.asString()) {
1882 out.push_back(*element);
1883 }
1884 }
1885 }
1886 return out;
1887}
1888
1889/// @brief A list of strings as a value.
1890/// @param texts The strings.
1891/// @return The list.
1892[[nodiscard]] inline Value listOfStrings(std::vector<std::string> const& texts) {
1893 ValueList items;
1894 items.reserve(texts.size());
1895 for (std::string const& text : texts) {
1896 items.emplace_back(text);
1897 }
1898 return Value{std::move(items)};
1899}
1900
1901/// @brief A query at run time (spec 5 §5): a key over the scope, an entry of the session's cache, latest wins.
1903public:
1904 /// @brief A query of @p scope, not yet started.
1905 /// @param decl The declaration.
1906 /// @param scope The scope.
1907 /// @param alias The model alias it goes through.
1908 QueryRuntime(QueryDecl const& decl, RtScope& scope, ModelAlias* alias);
1909
1910 QueryRuntime(QueryRuntime const&) = delete;
1911 QueryRuntime& operator=(QueryRuntime const&) = delete;
1912 QueryRuntime(QueryRuntime&&) = delete;
1913 QueryRuntime& operator=(QueryRuntime&&) = delete;
1914
1915 /// @brief Stops its timers and releases its entry.
1916 ~QueryRuntime();
1917
1918 /// @brief Starts it: makes its key and the Effect that follows it, and its refresh timer.
1919 void start();
1920
1921 /// @brief The value: the entry's, kept from the previous key while the new one has none. A tracked read.
1922 /// @return It.
1923 [[nodiscard]] Value value() const;
1924
1925 /// @brief Whether a request is in flight or waiting out its debounce. A tracked read.
1926 /// @return It.
1927 [[nodiscard]] bool pending() const;
1928
1929 /// @brief The failure's text, `null` when none. A tracked read.
1930 /// @return It.
1931 [[nodiscard]] Value error() const;
1932
1933 /// @brief The failure's kind, `null` when none. A tracked read.
1934 /// @return It.
1935 [[nodiscard]] Value errorKind() const;
1936
1937 /// @brief Whether the query has a value or a failure for its current key, or is idle. A tracked read, for a
1938 /// boundary.
1939 /// @return It.
1940 [[nodiscard]] bool settled() const;
1941
1942 /// @brief Refetches the current key now, ending a debounce wait.
1943 void refetch();
1944
1945 /// @brief Fetches the key its body has now, inside the batch that changed the body: switches to that key's entry
1946 /// when it differs from the current one, and fetches the current one otherwise. A backend switch uses it,
1947 /// so a body `onBackendChange` changed is fetched once, with what the recovery set, and the old key is
1948 /// not fetched first.
1949 void refetchCurrent();
1950
1951 /// @brief Marks its entry stale and refetches it.
1952 void invalidate() {
1953 if (auto const& current = _entry.peek()) {
1954 current->stale = true;
1955 }
1956 refetch();
1957 }
1958
1959 /// @brief Forgets its entry without releasing it: the session dropped the cache.
1960 void detach() {
1961 _entry.set(nullptr);
1962 _carried.set(Value{});
1963 _target = {};
1964 }
1965
1966 /// @brief The current entry. A tracked read.
1967 /// @return It, or `nullptr` while idle.
1968 [[nodiscard]] std::shared_ptr<CacheEntry> const& entry() const { return _entry.get(); }
1969
1970 /// @brief The declaration.
1971 /// @return It.
1972 [[nodiscard]] QueryDecl const& decl() const noexcept { return *_decl; }
1973
1974 /// @brief The model type.
1975 /// @return It.
1976 [[nodiscard]] std::string const& typeId() const noexcept { return _typeId; }
1977
1978 /// @brief The channel its alias is bound to now.
1979 /// @return It, or `nullptr` while the alias is unbound.
1980 [[nodiscard]] std::shared_ptr<ModelChannel> channel() const;
1981
1982private:
1983 /// @brief What a key evaluates to: the cache entry it names and the body, or idle.
1984 struct Target {
1985 bool idle = true;
1986 std::string token;
1987 Value body;
1988 std::string bodyText;
1989 bool operator==(Target const& other) const {
1990 return idle == other.idle && token == other.token && bodyText == other.bodyText;
1991 }
1992 };
1993
1994 void switchTo(Target const& target);
1995
1996 QueryDecl const* _decl;
1997 std::string _typeId;
1998 RtScope* _scope;
1999 ModelAlias* _alias;
2001 reactive::Signal<Value> _carried;
2002 reactive::Signal<bool> _waiting;
2003 Target _target;
2004 Target _pendingTarget;
2005 // The query's key, owned by its scope's nodes; set once `start` made it.
2006 reactive::Computed<Target> const* _key = nullptr;
2007 reactive::TimerHandle _debounce;
2008 reactive::TimerHandle _refresh;
2009 std::shared_ptr<bool> _alive = std::make_shared<bool>(true);
2010};
2011
2012/// @brief One run of a mutation, as a command sees it: whether it succeeded, and the reply.
2013using RunDone = std::function<void(bool succeeded, Value const& reply)>;
2014
2015/// @brief A mutation at run time (spec 5 §5): concurrency, optimistic writes and the reply's effects.
2017public:
2018 /// @brief A mutation of @p scope.
2019 /// @param decl The declaration.
2020 /// @param scope The scope.
2021 /// @param alias The model alias it goes through.
2022 MutationRuntime(MutationDecl const& decl, RtScope& scope, ModelAlias* alias);
2023
2024 MutationRuntime(MutationRuntime const&) = delete;
2025 MutationRuntime& operator=(MutationRuntime const&) = delete;
2026 MutationRuntime(MutationRuntime&&) = delete;
2027 MutationRuntime& operator=(MutationRuntime&&) = delete;
2028
2029 /// @brief Drops the replies still to come.
2031
2032 /// @brief Runs it, queues the run or refuses it, as its concurrency says.
2033 /// @param body The body.
2034 /// @param bindings The names bound where the run was started, for `onSuccess` and `onError`.
2035 /// @param done Called once the run settles, or at once when it is refused.
2036 void run(Value body, std::shared_ptr<Bindings const> bindings, RunDone done);
2037
2038 /// @brief Whether a run is in flight or queued. A tracked read.
2039 /// @return It.
2040 [[nodiscard]] bool pending() const { return _inFlight.get() > 0; }
2041
2042 /// @brief The last applied result. A tracked read.
2043 /// @return It.
2044 [[nodiscard]] Value result() const { return _result.get(); }
2045
2046 /// @brief The last applied failure's text, or `null`. A tracked read.
2047 /// @return It.
2048 [[nodiscard]] Value error() const { return _error.get(); }
2049
2050 /// @brief The last applied failure's kind, or `null`. A tracked read.
2051 /// @return It.
2052 [[nodiscard]] Value errorKind() const { return _errorKind.get(); }
2053
2054 /// @brief The declaration.
2055 /// @return It.
2056 [[nodiscard]] MutationDecl const& decl() const noexcept { return *_decl; }
2057
2058 /// @brief The model type.
2059 /// @return It.
2060 [[nodiscard]] std::string const& typeId() const noexcept { return _typeId; }
2061
2062private:
2063 struct Run {
2064 std::uint64_t id = 0;
2065 Value body;
2066 std::shared_ptr<Bindings const> bindings;
2067 RunDone done;
2068 std::string serialKey;
2069 std::vector<std::shared_ptr<CacheEntry>> layered;
2070 };
2071
2072 void send(std::shared_ptr<Run> const& run);
2073 void settle(std::shared_ptr<Run> const& run, std::optional<Value> reply, std::exception_ptr const& error);
2074 void next(std::string const& serialKey);
2075 void applyOptimistic(Run& run);
2076 void dropLayers(Run& run);
2077
2078 MutationDecl const* _decl;
2079 RtScope* _scope;
2080 ModelAlias* _alias;
2081 std::string _typeId;
2082 ScreenCore* _core;
2083 reactive::Signal<int> _inFlight;
2086 reactive::Signal<Value> _errorKind;
2087 std::uint64_t _runs = 0;
2088 std::uint64_t _latest = 0;
2089 std::map<std::string, std::deque<std::shared_ptr<Run>>, std::less<>> _queues;
2090 std::set<std::string, std::less<>> _busy;
2091 std::shared_ptr<bool> _alive = std::make_shared<bool>(true);
2092};
2093
2094} // namespace detail
2095
2096/// @brief What an `Interpreter` is built with.
2098 /// @brief The application's side of the commands; `nullptr` answers every one "not supported". Borrowed.
2100 /// @brief Called with each diagnostic as it is reported: a runtime expression error, a command the host does not
2101 /// support, a node kind this interpreter does not lower.
2102 std::function<void(Refusal const&)> diagnostics;
2103};
2104
2105namespace detail {
2106
2107/// @brief Where a command list runs: its scope, the names bound there, and what it may answer.
2109 /// @brief The scope.
2110 std::shared_ptr<RtScope> scope;
2111 /// @brief The names bound here; a `seq` step's `as` adds to them.
2112 std::shared_ptr<Bindings> bindings;
2113 /// @brief Set by `cancelClose`.
2114 std::shared_ptr<bool> cancelClose;
2115 /// @brief The innermost boundary, for `retry`.
2116 std::shared_ptr<BoundaryRuntime> boundary;
2117};
2118
2119/// @brief A command context without its scope: what a callback the host keeps may hold, so that the host never
2120/// keeps a scope alive past its screen. The callback holds the scope weakly and puts it back.
2121/// @param ctx The context.
2122/// @return The context, its scope dropped.
2123[[nodiscard]] inline CommandCtx detached(CommandCtx const& ctx) {
2124 CommandCtx kept = ctx;
2125 kept.scope = nullptr;
2126 return kept;
2127}
2128
2129/// @brief Called once when a command finishes: whether it succeeded.
2130using Done = std::function<void(bool)>;
2131
2132/// @brief Where a node is lowered: its scope, the names bound there and the boundary that owns its queries.
2133struct LowerCtx {
2134 /// @brief The scope.
2135 std::shared_ptr<RtScope> scope;
2136 /// @brief The names bound here.
2137 std::shared_ptr<Bindings const> bindings;
2138 /// @brief The innermost boundary.
2139 std::shared_ptr<BoundaryRuntime> boundary;
2140};
2141
2142/// @brief An `actionButton` part, lowered in the scope of the list it belongs to: what a screen's head shows and
2143/// an overlay's footer draws as buttons.
2145 /// @brief The action's id, unique in its list.
2146 std::string id;
2147 /// @brief The part's JSON Pointer, where a diagnostic about it is reported.
2148 std::string path;
2149 /// @brief The label.
2151 /// @brief The name of an icon; empty for none.
2153 /// @brief How much it stands out.
2155 /// @brief Whether it can be run.
2157 /// @brief Whether it is shown.
2159 /// @brief Runs its `onClick` as a user event, in one batch; empty when it declares none.
2161};
2162
2163/// @brief A list of lowered `actionButton` parts, in document order.
2164using ActionModels = std::vector<ActionModel>;
2165
2166/// @brief A `headBadge` part, lowered in the screen scope.
2168 /// @brief The badge's text.
2170 /// @brief Its tone.
2172 /// @brief The name of an icon; empty for none.
2174 /// @brief Whether it is shown.
2176};
2177
2178/// @brief A kind's key spec, from the kind table.
2179/// @param kind The kind spec.
2180/// @param key The key.
2181/// @return The spec, or `nullptr` when the kind has no such key of its own.
2182[[nodiscard]] inline KeySpec const* keySpec(KindSpec const& kind, std::string_view key) {
2183 for (KeySpec const& spec : kind.keys) {
2184 if (spec.key == key) {
2185 return &spec;
2186 }
2187 }
2188 return nullptr;
2189}
2190
2191/// @brief Whether a node kind keeps per-element state even without an id: the inputs, dialogs, tables and the forms
2192/// engine's screens.
2193/// @param kind The kind.
2194/// @return It.
2195[[nodiscard]] inline bool statefulKind(std::string_view kind) {
2196 static constexpr std::array<std::string_view, 12> kStateful = {
2197 "textInput", "checkbox", "select", "dateTimeInput", "slider", "filePicker",
2198 "dialog", "drawer", "table", "form", "collection", "wizard"};
2199 return listed(kStateful, kind);
2200}
2201
2202/// @brief How many command lists the inspector's log keeps.
2203inline constexpr std::size_t kCommandLogSize = 256;
2204
2205/// @brief How often, in lowered nodes, the inspector's node list drops the nodes whose scope is gone.
2206inline constexpr std::size_t kPruneLoweredAt = 256;
2207
2208/// @brief The key a command is written with.
2209/// @param kind The command's kind.
2210/// @return Its key, such as `refetch`.
2211[[nodiscard]] inline std::string commandKey(CommandKind kind) {
2212 switch (kind) {
2213 case CommandKind::Set:
2214 return "set";
2215 case CommandKind::Reset:
2216 return "reset";
2217 case CommandKind::Run:
2218 return "run";
2220 return "refetch";
2221 case CommandKind::Open:
2222 return "open";
2223 case CommandKind::Close:
2224 return "close";
2226 return "navigate";
2228 return "return";
2229 case CommandKind::Focus:
2230 return "focus";
2231 case CommandKind::Host:
2232 return "host";
2234 return "switchBackend";
2236 return "resetPrefs";
2238 return "setLocale";
2240 return "notify";
2241 case CommandKind::Seq:
2242 return "seq";
2243 case CommandKind::All:
2244 return "all";
2245 case CommandKind::If:
2246 return "if";
2248 return "cancelClose";
2249 case CommandKind::Retry:
2250 return "retry";
2252 return "signIn";
2254 return "signOut";
2256 return "upload";
2258 return "download";
2259 default:
2260 return "unknown";
2261 }
2262}
2263
2264/// @brief One mounted screen's interpreter: its document, diagnostics, slots and preferences, and the code that
2265/// declares scopes, lowers nodes and runs commands.
2267public:
2268 /// @brief The core of a screen about to mount.
2269 /// @param doc The document.
2270 /// @param owner The session.
2271 /// @param calls The dispatcher.
2272 /// @param options The interpreter's options.
2273 /// @param mount The mount's options.
2274 ScreenCore(std::shared_ptr<Document const> doc, Session& owner, Dispatcher& calls,
2275 InterpreterOptions const& options, MountOptions const& mount)
2276 : session{owner},
2277 dispatcher{calls},
2278 host{options.host},
2279 onDiagnostic{options.diagnostics},
2280 borrowed{mount.borrowed},
2281 depth{mount.depth},
2283 submitted{session.runtime(), false},
2285 recording{owner.options().developmentTools == DevelopmentTools::On},
2286 _doc{std::move(doc)} {
2287 ValueRecord defaults;
2288 for (auto const& [name, point] : _doc->customize) {
2289 defaults.insert_or_assign(name, point.defaultValue);
2290 }
2291 prefDefaults = defaults;
2292 prefs.set(std::move(defaults));
2293 session.attachScreen(*this);
2294 }
2295
2296 ScreenCore(ScreenCore const&) = delete;
2297 ScreenCore& operator=(ScreenCore const&) = delete;
2298 ScreenCore(ScreenCore&&) = delete;
2299 ScreenCore& operator=(ScreenCore&&) = delete;
2300 /// @brief Takes the screen off the session, so a later backend switch does not run its `onBackendChange`.
2301 ~ScreenCore() { session.detachScreen(*this); }
2302
2303 /// @brief Runs a lifecycle list in the document's own scope, as a screen's or app document's own command list.
2304 /// @param key The lifecycle key, such as `onBackendChange`.
2305 void runLifecycleOf(std::string_view key) {
2306 auto const found = _doc->lifecycle.find(key);
2307 auto const scope = rootScope.lock();
2308 if (found == _doc->lifecycle.end() || !scope) {
2309 return;
2310 }
2311 CommandCtx const ctx{.scope = scope,
2312 .bindings = std::make_shared<Bindings>(),
2313 .cancelClose = std::make_shared<bool>(false),
2314 .boundary = nullptr};
2315 runList(found->second, ctx, {});
2316 }
2317
2318 /// @brief The document.
2319 /// @return It.
2320 [[nodiscard]] Document const& doc() const noexcept { return *_doc; }
2321
2322 /// @brief The runtime.
2323 /// @return It.
2324 [[nodiscard]] reactive::Runtime& runtime() const noexcept { return session.runtime(); }
2325
2326 /// @brief Reports a diagnostic once per path.
2327 /// @param path Where.
2328 /// @param reason What.
2329 void report(std::string_view path, std::string_view reason) {
2330 if (inspecting) {
2331 return;
2332 }
2333 std::string key = std::string{path} + '\x1f' + std::string{reason};
2334 if (!reported.insert(std::move(key)).second) {
2335 return;
2336 }
2337 Refusal const refusal{.document = _doc->id, .path = std::string{path}, .reason = std::string{reason}};
2338 diagnostics.push_back(refusal);
2339 if (onDiagnostic) {
2340 onDiagnostic(refusal);
2341 }
2342 }
2343
2344 /// @brief The next slot id: the interpreter numbers every bound property of the document.
2345 /// @return It.
2346 [[nodiscard]] SlotId nextSlot() noexcept { return _slots++; }
2347
2348 // --- Declaring a scope -------------------------------------------------------------------------------------
2349
2350 /// @brief Declares a scope's names and starts its reactive parts.
2351 /// @param scope The scope.
2352 /// @param decl Its declarations; `nullptr` when it declares none.
2353 /// @param content The nodes whose elements belong to it.
2354 /// @param commands The command lists whose transfers belong to it.
2355 void declare(RtScope& scope, ScopeDecl const* decl, std::vector<DocNode const*> const& content,
2356 std::vector<Commands const*> const& commands);
2357
2358 /// @brief Makes a state's signal from its init, once.
2359 /// @param scope The scope that declares it.
2360 /// @param slot The state.
2361 void initState(RtScope const& scope, StateSlot& slot) const {
2362 if (slot.value || slot.initializing) {
2363 return;
2364 }
2365 if (std::optional<Value> keptValue = keptState(scope, *slot.decl)) {
2366 slot.value = std::make_unique<reactive::Signal<Value>>(runtime(), std::move(*keptValue));
2367 return;
2368 }
2369 slot.initializing = true;
2370 Value init = runtime().untracked([&] { return evalIn(scope, nullptr, slot.decl->init); });
2371 slot.value = std::make_unique<reactive::Signal<Value>>(runtime(), std::move(init));
2372 slot.initializing = false;
2373 }
2374
2375 /// @brief Reads an element value. A tracked read.
2376 /// @param element The element.
2377 /// @param name The value's name.
2378 /// @return The value.
2379 [[nodiscard]] Value elementValue(ElementState const& element, std::string_view name) const;
2380
2381 /// @brief The current value of an input, checkbox or select: what `required` tests.
2382 /// @param element The element.
2383 /// @return The value.
2384 [[nodiscard]] Value inputValue(ElementState const& element) const;
2385
2386 // --- Commands ----------------------------------------------------------------------------------------------
2387
2388 /// @brief Runs a command list: each command in order in one batch; done when every command in it is.
2389 /// @param commands The list.
2390 /// @param ctx Where it runs.
2391 /// @param done Called once, with whether every command succeeded; may be empty.
2392 void runList(Commands const& commands, CommandCtx const& ctx, Done done);
2393
2394 /// @brief Runs one command.
2395 /// @param command The command.
2396 /// @param ctx Where it runs.
2397 /// @param done Called once, with whether it succeeded.
2398 void runOne(Command const& command, CommandCtx const& ctx, Done done);
2399
2400 /// @brief Runs a command list for an event of the user's.
2401 /// @param commands The list; `nullptr` runs nothing.
2402 /// @param ctx Where it was lowered.
2403 /// @param event The event's payload.
2404 void runEvent(Commands const* commands, LowerCtx const& ctx, Value event) {
2405 if (commands == nullptr || commands->empty()) {
2406 return;
2407 }
2408 auto bindings = std::make_shared<Bindings>(*ctx.bindings);
2409 bindings->push_back(Binding{.name = "event", .read = [event = std::move(event)] { return event; }});
2410 CommandCtx const command{.scope = ctx.scope,
2411 .bindings = std::move(bindings),
2412 .cancelClose = std::make_shared<bool>(false),
2413 .boundary = ctx.boundary};
2414 session.asUser([&] { runtime().batch([&] { runList(*commands, command, {}); }); });
2415 }
2416
2417 /// @brief Lowers a list of `actionButton` parts into reactive models: label, icon, enabled and visible bound in
2418 /// @p ctx's scope, and a run function that runs the part's `onClick` there as a user event.
2419 /// @param parts The parts, in order.
2420 /// @param ctx The scope the list belongs to.
2421 /// @return The models, in the parts' order.
2422 [[nodiscard]] ActionModels lowerActionParts(std::span<DocNodePtr const> parts, LowerCtx const& ctx);
2423
2424 /// @brief An overlay's actions as the action bar its footer shows: a button per action, test id
2425 /// `action_<id>`, shown and enabled as the action is.
2426 /// @param actions The lowered actions, in order.
2427 /// @return The `ActionBar` node.
2428 [[nodiscard]] static Node actionBarOf(ActionModels actions);
2429
2430 /// @brief Lowers a screen head's `headBadge` parts, bound in @p ctx's scope.
2431 /// @param parts The parts, in order.
2432 /// @param ctx The screen scope.
2433 /// @return The badges, in the parts' order.
2434 [[nodiscard]] std::vector<BadgeModel> lowerBadgeParts(std::span<DocNodePtr const> parts, LowerCtx const& ctx);
2435
2436 // --- Lowering ----------------------------------------------------------------------------------------------
2437
2438 /// @brief Lowers a document node to a view node.
2439 /// @param node The node.
2440 /// @param ctx Where.
2441 /// @return The view node.
2442 [[nodiscard]] Node lower(DocNode const& node, LowerCtx const& ctx);
2443
2444 /// @brief Lowers a node list into one node: the node itself, or a column of them.
2445 /// @param nodes The nodes.
2446 /// @param ctx Where.
2447 /// @return The node.
2448 // NOLINTNEXTLINE(misc-no-recursion): one level per nested node of the document.
2449 [[nodiscard]] Node lowerAll(std::span<DocNodePtr const> nodes, LowerCtx const& ctx) {
2450 if (nodes.size() == 1) {
2451 return lower(*nodes.front(), ctx);
2452 }
2453 Column column;
2454 for (DocNodePtr const& child : nodes) {
2455 column.children.push_back(lower(*child, ctx));
2456 }
2457 return ui::column(std::move(column));
2458 }
2459
2460 // NOLINTBEGIN(cppcoreguidelines-non-private-member-variables-in-classes): the screen's shared state, which its
2461 // scopes and runtimes read and write directly.
2462 /// @brief The session.
2464 /// @brief The dispatcher.
2466 /// @brief The host; may be `nullptr`.
2468 /// @brief The diagnostic callback.
2469 std::function<void(Refusal const&)> onDiagnostic;
2470 /// @brief Moves focus to a mounted element by its id; empty until the screen is mounted, and again once it is
2471 /// unmounting.
2472 std::function<bool(std::string_view)> focusElement;
2473 /// @brief The models the opener lends.
2474 std::map<std::string, BorrowedModel, std::less<>> borrowed;
2475 /// @brief The screen scope's reactive depth.
2476 std::size_t depth;
2477 /// @brief The diagnostics reported, in order.
2478 std::vector<Refusal> diagnostics;
2479 /// @brief The paths and reasons already reported.
2480 std::set<std::string, std::less<>> reported;
2481 /// @brief The customization choices, by point.
2483 /// @brief The points' defaults.
2485 /// @brief The screen's `submitted`.
2487 /// @brief The open dialogs and drawers, the mutation runs and the transfers in flight, across every scope of the
2488 /// screen: what a close waits for (spec 6 §3).
2490 /// @brief Whether this screen records what the inspector reads: its session's `developmentTools`, read once.
2491 bool const recording;
2492
2493 /// @brief Counts pending work in or out.
2494 /// @param delta What started (positive) or ended (negative).
2495 void addPending(int delta) { pendingWork.set(pendingWork.peek() + delta); }
2496 /// @brief The document's own scope, once mounted.
2497 std::weak_ptr<RtScope> rootScope;
2498 /// @brief A state of the screen this one replaces: its declared type's name and its value.
2499 struct KeptState {
2500 /// @brief The declared type's name.
2501 std::string type;
2502 /// @brief The value.
2504 };
2505 /// @brief The states of the own scope of the screen this one replaces, by name (`MountOptions::keepStateOf`).
2506 std::map<std::string, KeptState, std::less<>> keptStates;
2507 /// @brief A node as it was lowered, which the inspector lists while its scope lives.
2509 /// @brief The node.
2510 DocNode const* node = nullptr;
2511 /// @brief The scope it was lowered in.
2512 std::weak_ptr<RtScope> scope;
2513 /// @brief The names bound where it was lowered.
2514 std::weak_ptr<Bindings const> bindings;
2515 };
2516 /// @brief Every node lowered, in lowering order; an entry whose scope is gone is pruned as more are added.
2517 std::vector<LoweredNode> loweredNodes;
2518 /// @brief The screen's live scopes, in creation order.
2519 std::vector<RtScope*> scopes;
2520 /// @brief The latest command lists run, oldest first, at most `kCommandLogSize`.
2521 std::deque<CommandLogEntry> commandLog;
2522 /// @brief Set while the inspector evaluates, so that an expression error it meets is not reported.
2523 bool inspecting = false;
2524 // NOLINTEND(cppcoreguidelines-non-private-member-variables-in-classes)
2525
2526 /// @brief The value a state of the screen's own scope keeps from the screen it replaces: that screen's value of
2527 /// the state of the same name, when its declared type is the same and admits it.
2528 /// @param scope The scope that declares the state.
2529 /// @param decl The declaration.
2530 /// @return The value, or nothing for a state that starts from its init.
2531 [[nodiscard]] std::optional<Value> keptState(RtScope const& scope, StateDecl const& decl) const {
2532 if (keptStates.empty() || rootScope.lock().get() != &scope) {
2533 return std::nullopt;
2534 }
2535 auto const found = keptStates.find(decl.name);
2536 if (found == keptStates.end() || found->second.type != decl.type.name() ||
2537 !decl.type.admits(found->second.value)) {
2538 return std::nullopt;
2539 }
2540 return found->second.value;
2541 }
2542
2543private:
2544 void wireElement(ElementState& element);
2545 void wireErrors(ElementState& element);
2546 void collect(DocNode const& node, RtScope& scope, std::vector<Commands const*>& commands);
2547 Node lowerKind(DocNode const& node, LowerCtx const& ctx, ElementState* element);
2548 Node lowerDialog(DocNode const& node, LowerCtx const& ctx, ElementState* element);
2549 Node lowerSwitch(DocNode const& node, LowerCtx const& ctx);
2550 Node lowerRows(DocNode const& node, LowerCtx const& ctx, ElementState* element);
2551 /// A `rowTone` value as a tone: `neutral`, `info`, `ok`, `warn`, `err`, or `null` for none. Another value is
2552 /// reported, at @p path, while @p noted is still false, and shows no tone.
2553 std::optional<Tone> rowToneOf(Value const& value, std::string const& path, bool& noted);
2554 Node lowerBoundary(DocNode const& node, LowerCtx const& ctx);
2555 Node lowerCustom(DocNode const& node, LowerCtx const& ctx);
2556 /// A form field's control drawn by the host component its overlay names, with the built-in control as the
2557 /// fallback.
2558 Node fieldComponent(forms::FieldView const& view, DocNode const& overlay, LowerCtx const& ctx);
2559 /// A custom node's prop of a declared type over @p read, converted as `lowerCustom` converts a document's.
2560 CustomProp componentProp(std::string const& prop, std::string const& type, std::function<Value()> read);
2561 /// A field component's `change`: the payload read as the `value` prop's type and written as the user's edit, or
2562 /// reported when it does not decode, does not fit the field, or is over the inline cap for a base64 member.
2563 void changeField(forms::FieldState& state, forms::FieldSpec const& spec, std::string const& path,
2564 std::string_view valueType, std::string const& payload);
2565 Node lowerCustomize(DocNode const& node, LowerCtx const& ctx);
2566 [[nodiscard]] std::string arrangedBy(std::string const& elementId) const;
2567 Node lowerArranged(DocNode const& node, LowerCtx const& ctx, ElementState* element, std::string const& point,
2568 Axis axis, int gap);
2569 Node lowerArrangeEditor(DocNode const& node, LowerCtx const& ctx, std::string const& point,
2570 CustomizePoint const& spec, std::function<void(Value)> const& choose);
2571 void wireForm(ElementState& element);
2572 Node lowerForm(DocNode const& node, LowerCtx const& ctx, ElementState* element);
2573 [[nodiscard]] forms::Submitter aliasCalls(ModelAlias* alias);
2574 [[nodiscard]] forms::Submitter aliasSubmits(ModelAlias* alias, std::string read = {});
2575 [[nodiscard]] forms::FormSessionOptions engineOptions() const;
2576 void wireComposite(ElementState& element);
2577 Node lowerComposite(DocNode const& node, LowerCtx const& ctx, ElementState* element);
2578 void presentForm(DocNode const& node, LowerCtx const& ctx, forms::FormViewOptions& options);
2579 std::shared_ptr<RtScope> rowScope(DocNode const& node, LowerCtx const& ctx, reactive::Signal<Value> const& row,
2580 std::vector<DocNode const*> const& content);
2581 void commonOf(DocNode const& node, LowerCtx const& ctx, ElementState* element, Common& common);
2582 void layoutOf(DocNode const& node, LowerCtx const& ctx, LayoutHints& layout);
2583 void fieldOf(DocNode const& node, LowerCtx const& ctx, ElementState* element, FieldState& field);
2584 std::vector<MenuItem> menuItems(std::span<DocNodePtr const> parts, LowerCtx const& ctx);
2585 void ownQueries(DocNode const& node, RtScope const& scope, BoundaryRuntime& boundary,
2586 std::set<std::string, std::less<>>& seen);
2587 void ownQueriesOf(ExprPtr const& expr, RtScope const& scope, BoundaryRuntime& boundary,
2588 std::set<std::string, std::less<>>& seen);
2589 void runMutation(Command const& command, CommandCtx const& ctx, Done done) const;
2590 void runSet(Command const& command, CommandCtx const& ctx, Done const& done);
2591 void runSetApp(Command const& command, std::span<std::string const> rest, Value value, Done const& done);
2592 void writeSlot(StateSlot& slot, std::span<std::string const> within, Value value, Command const& command,
2593 Done const& done);
2594 void runReset(Command const& command, CommandCtx const& ctx, Done const& done);
2595 void runHostCommand(Command const& command, CommandCtx const& ctx, Done const& done);
2596 void runTransfer(Command const& command, CommandCtx const& ctx, Done done);
2597 void runDownload(Command const& command, CommandCtx const& ctx, Value const& file, Done done);
2598 // A download's inline bytes, decoded and under the cap, with its name and type; nothing, reported, otherwise.
2599 std::optional<InlineFile> inlineDownload(Command const& command, CommandCtx const& ctx, Value const& file,
2600 Value const& name);
2601 void runUpload(Command const& command, CommandCtx const& ctx, Value const& file, Done done);
2602 bool unsupported(Command const& command, std::string_view what) {
2603 report(command.path, std::string{what} + " is not supported by the host");
2604 return false;
2605 }
2606
2607 /// @brief A bound property: a constant for a literal, a numbered slot otherwise.
2608 template <typename T, typename Convert>
2609 Prop<T> bindProp(LowerCtx const& ctx, ExprPtr const& expr, T fallback, Convert convert) {
2610 if (!expr) {
2611 return Prop<T>{std::move(fallback)};
2612 }
2613 if (expr->kind == ExprKind::Literal) {
2614 return Prop<T>{convert(expr->literal, session.formatting())};
2615 }
2616 return Prop<T>{ui::slot(
2617 nextSlot(), [scope = ctx.scope, bindings = ctx.bindings, expr, convert, session = &session]() -> T {
2618 return convert(evalIn(*scope, bindings.get(), expr), session->formatting());
2619 })};
2620 }
2621
2622 /// @brief A text's role: a literal one as the loader checked it; a computed one evaluated in its slot, where a
2623 /// name of no role is reported once for its path and drawn `Normal`.
2624 Prop<TextRole> roleProp(LowerCtx const& ctx, ExprPtr const& expr);
2625
2626 /// @brief A property read through @p read, as a numbered slot.
2627 template <typename T, typename Read>
2628 Prop<T> slotProp(Read read) {
2629 return Prop<T>{ui::slot(nextSlot(), std::move(read))};
2630 }
2631
2632 /// @brief Evaluates an expression once, untracked: a property the view tree sets once.
2633 [[nodiscard]] Value once(LowerCtx const& ctx, ExprPtr const& expr) const {
2634 return runtime().untracked([&] { return evalIn(*ctx.scope, ctx.bindings.get(), expr); });
2635 }
2636
2637 /// @brief Keeps a command list in the inspector's log.
2638 void logList(Commands const& commands) {
2639 CommandLogEntry entry{.event = {}, .commands = {}, .batch = runtime().core()->currentBatch()};
2640 std::string_view const first = commands.front().path;
2641 entry.event = std::string{first.substr(0, first.rfind('/') == std::string_view::npos ? 0 : first.rfind('/'))};
2642 for (Command const& command : commands) {
2643 entry.commands.push_back(
2644 LoggedCommand{.kind = commandKey(command.kind), .target = command.target, .path = command.path});
2645 }
2646 if (commandLog.size() == kCommandLogSize) {
2647 commandLog.pop_front();
2648 }
2649 commandLog.push_back(std::move(entry));
2650 }
2651
2652 std::shared_ptr<Document const> _doc;
2653 SlotId _slots = 1;
2654 /// @brief Expires with the screen, so a reply that arrives after it is gone reports nothing.
2655 std::shared_ptr<bool> _alive = std::make_shared<bool>(true);
2656};
2657
2658} // namespace detail
2659
2660/// @brief A status badge of a screen's head, as its handle answers it.
2662 /// @brief The badge's text.
2663 std::string text;
2664 /// @brief Its tone.
2666 /// @brief The name of an icon; empty for none.
2667 std::string icon;
2668
2669 /// @brief Memberwise equality.
2670 /// @return Whether every member matches.
2671 bool operator==(HeadBadge const&) const = default;
2672};
2673
2674/// @brief An action of a screen's head, as its handle answers it.
2676 /// @brief The action's id, what `ScreenHead::run` takes.
2677 std::string id;
2678 /// @brief The label.
2679 std::string label;
2680 /// @brief The name of an icon; empty for none.
2681 std::string icon;
2682 /// @brief How much it stands out.
2684 /// @brief Whether it can be run now.
2685 bool enabled = true;
2686
2687 /// @brief Memberwise equality.
2688 /// @return Whether every member matches.
2689 bool operator==(HeadAction const&) const = default;
2690};
2691
2692/// @brief A mounted screen: its scopes, its view mounted through `Mounted`, and what its host asks of it.
2693///
2694/// Neither copyable nor movable. It belongs to the runtime's owner; the runtime, the session, the dispatcher and
2695/// the backend outlive it. Destroying it runs `onUnmount`, unmounts the view and releases every scope.
2696class Screen {
2697public:
2698 Screen(Screen const&) = delete;
2699 Screen& operator=(Screen const&) = delete;
2700 Screen(Screen&&) = delete;
2701 Screen& operator=(Screen&&) = delete;
2702
2703 /// @brief Runs `onUnmount`, then unmounts.
2705 try {
2706 if (Commands const* const commands = lifecycleList("onUnmount")) {
2707 runCommands(*commands);
2708 }
2709 } catch (...) { // NOLINT(bugprone-empty-catch): a destructor has no one to report to.
2710 }
2711 _core->focusElement = {};
2712 _mounted.reset();
2713 _headActions.clear();
2714 _headStatus.clear();
2715 _scope.reset();
2716 }
2717
2718 /// @brief The root widget.
2719 /// @return It, valid as long as the screen.
2720 [[nodiscard]] Widget& root() const {
2721 if (!_mounted) {
2722 throw std::logic_error{"the screen is not mounted"};
2723 }
2724 return _mounted->root();
2725 }
2726
2727 /// @brief The document's id.
2728 /// @return It.
2729 [[nodiscard]] std::string const& id() const noexcept { return _core->doc().id; }
2730
2731 /// @brief Evaluates an expression in the screen's scope: what a host reads (`title`, `identity`), and what a
2732 /// test inspects.
2733 /// @param expression The expression's JSON.
2734 /// @return The value; `null` for an expression that does not parse.
2735 [[nodiscard]] Value read(Json const& expression) const {
2736 auto parsed = parseExpression(expression);
2737 if (!parsed) {
2738 return Value{};
2739 }
2740 return detail::evalIn(*_scope, nullptr, *parsed);
2741 }
2742
2743 /// @brief Evaluates the document's `title`.
2744 /// @return It, or `null`.
2745 [[nodiscard]] Value title() const { return detail::evalIn(*_scope, nullptr, _core->doc().title); }
2746
2747 /// @brief Evaluates the document's `identity`.
2748 /// @return It, or `null`.
2749 [[nodiscard]] Value identity() const { return detail::evalIn(*_scope, nullptr, _core->doc().identity); }
2750
2751 /// @brief Evaluates the document's `subtitle`, the head's line under the title.
2752 /// @return It, or `null`.
2753 [[nodiscard]] Value subtitle() const { return detail::evalIn(*_scope, nullptr, _core->doc().subtitle); }
2754
2755 /// @brief The head's status badges that are visible now, in order. A tracked read.
2756 /// @return They.
2757 [[nodiscard]] std::vector<HeadBadge> status() const {
2758 std::vector<HeadBadge> shown;
2759 for (detail::BadgeModel const& badge : _headStatus) {
2760 if (badge.visible.evaluate()) {
2761 shown.push_back(HeadBadge{
2762 .text = badge.text.evaluate(), .tone = badge.tone.evaluate(), .icon = badge.icon.evaluate()});
2763 }
2764 }
2765 return shown;
2766 }
2767
2768 /// @brief The head's actions that are visible now, in order, each with whether it is enabled. A tracked read.
2769 /// @return They.
2770 [[nodiscard]] std::vector<HeadAction> actions() const {
2771 std::vector<HeadAction> shown;
2772 for (detail::ActionModel const& action : _headActions) {
2773 if (action.visible.evaluate()) {
2774 shown.push_back(HeadAction{.id = action.id,
2775 .label = action.label.evaluate(),
2776 .icon = action.icon.evaluate(),
2777 .emphasis = action.emphasis,
2778 .enabled = action.enabled.evaluate()});
2779 }
2780 }
2781 return shown;
2782 }
2783
2784 /// @brief Runs a head action's `onClick` as a user event in the screen scope, in one batch.
2785 /// @param actionId The action's id.
2786 /// @return Whether it ran: `false`, with a diagnostic, for an id the head does not hold, or holds hidden or
2787 /// disabled.
2788 bool runAction(std::string_view actionId) {
2789 auto const found = std::ranges::find(_headActions, actionId, &detail::ActionModel::id);
2790 if (found == _headActions.end()) {
2791 _core->report("/actions", "head action " + std::string{actionId} + " is unknown");
2792 return false;
2793 }
2794 auto const [visible, enabled] =
2795 runtime().untracked([&] { return std::pair{found->visible.evaluate(), found->enabled.evaluate()}; });
2796 if (!visible || !enabled) {
2797 _core->report(found->path, "head action " + found->id + (visible ? " is disabled" : " is hidden"));
2798 return false;
2799 }
2800 if (found->run) {
2801 found->run();
2802 }
2803 return true;
2804 }
2805
2806 /// @brief Evaluates the document's `dirty`.
2807 /// @return Whether it is `true`.
2808 [[nodiscard]] bool dirty() const {
2809 Value const value = detail::evalIn(*_scope, nullptr, _core->doc().dirty);
2810 bool const* const flag = value.asBool();
2811 return flag != nullptr && *flag;
2812 }
2813
2814 /// @brief Whether the screen has work a close waits for: an open dialog or drawer, a mutation run, or an upload or
2815 /// download in flight, in any of its scopes (spec 6 §3). A tracked read, so an Effect that reads it runs
2816 /// again when it changes.
2817 /// @return It.
2818 [[nodiscard]] bool pending() const { return _core->pendingWork.get() > 0; }
2819
2820 /// @brief The reactive runtime the screen runs on.
2821 /// @return It.
2822 [[nodiscard]] reactive::Runtime& runtime() const noexcept { return _core->runtime(); }
2823
2824 /// @brief Runs `onClose`, as the host does before it closes the screen.
2825 /// @return False when `onClose` ran `cancelClose`: the screen stays open.
2827 std::shared_ptr<bool const> const cancelled = beginClose();
2828 return cancelled == nullptr || !*cancelled;
2829 }
2830
2831 /// @brief Runs `onClose`, and hands back the flag its `cancelClose` sets.
2832 ///
2833 /// The flag is shared with every command list `onClose` starts, such as the `onResult` of a confirm dialog it
2834 /// opens, so it can still be set after this returns; a host reads it once the screen is no longer `pending()`.
2835 /// @return The flag, or `nullptr` when the document declares no `onClose`.
2836 [[nodiscard]] std::shared_ptr<bool const> beginClose() {
2837 Commands const* const commands = lifecycleList("onClose");
2838 if (commands == nullptr) {
2839 return nullptr;
2840 }
2841 return runCommandsFlag(*commands);
2842 }
2843
2844 /// @brief Whether the document declares a lifecycle command list.
2845 /// @param key The key: `onClose`, `onSave`, …
2846 /// @return It.
2847 [[nodiscard]] bool declares(std::string_view key) const { return lifecycleList(key) != nullptr; }
2848
2849 /// @brief Runs a lifecycle command list: `onSave`, `onBackendChange`, or any other the document declares.
2850 /// @param key The key.
2851 void runLifecycle(std::string_view key) {
2852 if (Commands const* const commands = lifecycleList(key)) {
2853 runCommands(*commands);
2854 }
2855 }
2856
2857 /// @brief The diagnostics reported so far, each path and reason once.
2858 /// @return They.
2859 [[nodiscard]] std::vector<Refusal> const& diagnostics() const noexcept { return _core->diagnostics; }
2860
2861 /// @brief Sets the user's choice at a customization point, as the preferences store loaded or the user made
2862 /// it. A choice that does not fit the point is ignored (spec 5 §12b).
2863 /// @param point The point.
2864 /// @param value The choice.
2865 /// @return Whether it fits and was taken.
2866 bool setPreference(std::string const& point, Value value) {
2867 auto const found = _core->doc().customize.find(point);
2868 if (found == _core->doc().customize.end() || !fits(found->second, value)) {
2869 return false;
2870 }
2871 ValueRecord prefs = _core->prefs.peek();
2872 prefs.insert_or_assign(point, std::move(value));
2873 _core->prefs.set(std::move(prefs));
2874 return true;
2875 }
2876
2877 /// @brief The screen's document, whose `customize` block names the points a preferences store loads.
2878 /// @return It.
2879 [[nodiscard]] Document const& document() const noexcept { return _core->doc(); }
2880
2881 /// @brief What an alias of this screen lends to a screen it opens that borrows it.
2882 /// @param alias The alias.
2883 /// @return Its channel and token; an empty channel when it has none.
2884 [[nodiscard]] BorrowedModel lend(std::string_view alias) const;
2885
2886private:
2887 friend class Interpreter;
2888 friend class Inspector;
2889
2890 Screen() = default;
2891
2892 /// The states of the screen's own scope that have a value, by name: what a screen that replaces it keeps.
2893 [[nodiscard]] std::map<std::string, detail::ScreenCore::KeptState, std::less<>> ownState() const {
2894 std::map<std::string, detail::ScreenCore::KeptState, std::less<>> states;
2895 for (auto const& slot : _scope->states) {
2896 if (slot->value) {
2897 states.insert_or_assign(
2898 slot->decl->name,
2899 detail::ScreenCore::KeptState{.type = slot->decl->type.name(), .value = slot->value->peek()});
2900 }
2901 }
2902 return states;
2903 }
2904
2905 [[nodiscard]] Commands const* lifecycleList(std::string_view key) const {
2906 auto const found = _core->doc().lifecycle.find(key);
2907 return found == _core->doc().lifecycle.end() ? nullptr : &found->second;
2908 }
2909
2910 /// Runs a command list as the screen's own (not a user's); returns whether it ran `cancelClose`.
2911 bool runCommands(Commands const& commands) { return *runCommandsFlag(commands); }
2912
2913 /// Runs a command list as the screen's own; returns the `cancelClose` flag it and what it starts share.
2914 std::shared_ptr<bool const> runCommandsFlag(Commands const& commands) {
2915 detail::CommandCtx const ctx{.scope = _scope,
2916 .bindings = std::make_shared<detail::Bindings>(),
2917 .cancelClose = std::make_shared<bool>(false),
2918 .boundary = nullptr};
2919 _core->runtime().batch([&] { _core->runList(commands, ctx, {}); });
2920 return ctx.cancelClose;
2921 }
2922
2923 [[nodiscard]] static bool fits(CustomizePoint const& point, Value const& value) {
2924 if (point.kind == "choice") {
2925 std::string const* const text = value.asString();
2926 return text != nullptr && listed(std::span<std::string const>{point.options}, *text);
2927 }
2928 if (point.kind == "value") {
2929 return point.type.admits(value);
2930 }
2931 return true;
2932 }
2933
2934 [[nodiscard]] static bool listed(std::span<std::string const> options, std::string const& text) {
2935 return std::ranges::find(options, text) != options.end();
2936 }
2937
2938 std::unique_ptr<detail::ScreenCore> _core;
2939 std::shared_ptr<detail::RtScope> _scope;
2940 std::optional<Mounted> _mounted;
2941 // The head's parts, bound in the screen scope.
2942 std::vector<detail::BadgeModel> _headStatus;
2943 detail::ActionModels _headActions;
2944};
2945
2946/// @brief The app document mounted as the root scope every screen of the session shares (spec 6 §5).
2947///
2948/// Its state, lets, queries and mutations live once for the session. A screen reads them as `{"ref": "app.name"}`,
2949/// a binding on one updates, and `{"set": "app.name", …}` writes the app document's state. A query of the app
2950/// document runs once however many screens read it. Destroying the root runs its `onUnmount`, takes its scope off the
2951/// session and releases its model instances; a host does that before it clears the session on sign-out.
2952class AppRoot {
2953public:
2954 AppRoot(AppRoot const&) = delete;
2955 AppRoot& operator=(AppRoot const&) = delete;
2956 AppRoot(AppRoot&&) = delete;
2957 AppRoot& operator=(AppRoot&&) = delete;
2958
2959 /// @brief Runs `onUnmount`, takes the scope off the session, and releases everything.
2961 try {
2962 if (auto const found = _core->doc().lifecycle.find("onUnmount"); found != _core->doc().lifecycle.end()) {
2963 runCommands(found->second);
2964 }
2965 } catch (...) { // NOLINT(bugprone-empty-catch): a destructor has no one to report to.
2966 }
2967 _core->session.setAppScope(nullptr);
2968 _scope.reset();
2969 }
2970
2971 /// @brief Evaluates an expression in the app document's scope.
2972 /// @param expression The expression's JSON.
2973 /// @return Its value; `null` for an expression that does not parse.
2974 [[nodiscard]] Value read(Json const& expression) const {
2975 auto parsed = parseExpression(expression);
2976 if (!parsed) {
2977 return Value{};
2978 }
2979 return detail::evalIn(*_scope, nullptr, *parsed);
2980 }
2981
2982 /// @brief The diagnostics reported so far.
2983 /// @return They.
2984 [[nodiscard]] std::vector<Refusal> const& diagnostics() const noexcept { return _core->diagnostics; }
2985
2986private:
2987 friend class Interpreter;
2988
2989 AppRoot() = default;
2990
2991 void runCommands(Commands const& commands) {
2992 detail::CommandCtx const ctx{.scope = _scope,
2993 .bindings = std::make_shared<detail::Bindings>(),
2994 .cancelClose = std::make_shared<bool>(false),
2995 .boundary = nullptr};
2996 _core->runtime().batch([&] { _core->runList(commands, ctx, {}); });
2997 }
2998
2999 std::unique_ptr<detail::ScreenCore> _core;
3000 std::shared_ptr<detail::RtScope> _scope;
3001};
3002
3003/// @brief Mounts UI documents (spec 5): one per client process or window, over a session and a dispatcher.
3005public:
3006 /// @brief An interpreter.
3007 /// @param session The session the screens share. Borrowed.
3008 /// @param dispatcher Where model calls go. Borrowed.
3009 /// @param options The host and the diagnostic callback.
3010 Interpreter(Session& session MORPH_LIFETIMEBOUND, Dispatcher& dispatcher MORPH_LIFETIMEBOUND,
3011 InterpreterOptions options = {})
3012 : _session{&session}, _dispatcher{&dispatcher}, _options{std::move(options)} {}
3013
3014 /// @brief Mounts a screen.
3015 ///
3016 /// The document has loaded (`loadDocument`); what mounting adds is what only the opener knows: the params, and
3017 /// the models a `{"from": alias}` instance borrows. A param that is missing and not optional, a param of the
3018 /// wrong type, a borrowed model the opener did not lend and a document without a view are refused.
3019 /// @param doc The document.
3020 /// @param params The params, by name; names the document does not declare are ignored.
3021 /// @param backend The backend to mount on. Borrowed: it outlives the screen.
3022 /// @param options Where to mount, and the models lent.
3023 /// @return The screen, or the refusal.
3024 [[nodiscard]] std::expected<std::unique_ptr<Screen>, Refusal> mount(std::shared_ptr<Document const> doc,
3025 ValueRecord const& params,
3026 IViewBackend& backend,
3027 MountOptions const& options = {});
3028
3029 /// @brief Mounts the app document as the session's root scope (spec 6 §5).
3030 ///
3031 /// Refused: a document whose id is not `app`, and a second app document while one is mounted on the session.
3032 /// @param doc The app document, loaded.
3033 /// @return The root, or the refusal. Keep it as long as the session's screens may read `app.*`.
3034 [[nodiscard]] std::expected<std::unique_ptr<AppRoot>, Refusal> mountApp(std::shared_ptr<Document const> doc);
3035
3036private:
3037 Session* _session;
3038 Dispatcher* _dispatcher;
3039 InterpreterOptions _options;
3040};
3041
3042// ---------------------------------------------------------------------------------------------------------------
3043// Implementation
3044// ---------------------------------------------------------------------------------------------------------------
3045
3046inline void Session::backendChanged(std::string const& backend) {
3047 _runtime->batch([this, &backend] {
3048 std::vector<detail::ScreenCore*> const screens = _screens;
3049 for (detail::ScreenCore* const screen : screens) {
3050 if (std::ranges::find(_screens, screen) != _screens.end()) {
3051 screen->runLifecycleOf("onBackendChange");
3052 }
3053 }
3054 ValueRecord env = _env.peek();
3055 env.insert_or_assign("backend", Value{backend});
3056 _env.set(std::move(env));
3057 for (auto const& [key, entry] : _entries) {
3058 static_cast<void>(key);
3059 markStale(*entry);
3060 }
3061 std::vector<detail::QueryRuntime*> const mounted = _mounted;
3062 for (detail::QueryRuntime* const query : mounted) {
3063 query->refetchCurrent();
3064 }
3065 });
3066}
3067
3068inline void Session::refetchMounted(detail::CacheEntry const& entry) {
3069 std::vector<detail::QueryRuntime*> const mounted = _mounted;
3070 for (detail::QueryRuntime* const query : mounted) {
3071 if (_runtime->untracked([query] { return query->entry(); }).get() == &entry) {
3072 query->refetchCurrent();
3073 }
3074 }
3075}
3076
3077// NOLINTNEXTLINE(bugprone-easily-swappable-parameters): a model type and an action id, as every caller names them.
3078inline void Session::applyReplyEffects(std::string const& typeId, std::string const& actionId, Value const& reply,
3079 ReplyUse use, std::string const& instanceToken) {
3080 if (!_options.catalog.action) {
3081 return;
3082 }
3083 std::optional<ActionInfo> const info = _options.catalog.action(actionId);
3084 if (!info || !info->effects) {
3085 return;
3086 }
3087 detail::Marks marks;
3088 for (ActionEffect const& effect : *info->effects) {
3089 if (effect.kind == "changesInstance") {
3090 for (auto const& [key, entry] : _entries) {
3091 static_cast<void>(key);
3092 if (!entry->evaluate && entry->typeId == typeId && entry->instanceToken == instanceToken) {
3093 marks.add(*entry);
3094 }
3095 }
3096 } else if (effect.record && (effect.kind == "updates" || effect.kind == "changes")) {
3097 applyRecordEffect(effect, *effect.record, *info, reply, use, marks);
3098 }
3099 }
3100 markAndRefetch(marks);
3101}
3102
3103inline void Session::applyRecordEffect(ActionEffect const& effect, std::string const& record, ActionInfo const& info,
3104 Value const& reply, ReplyUse use, detail::Marks& marks) {
3105 // The reply's rows of the record, by key; none when the reply does not hold it keyed, or must only mark.
3106 auto const own = std::ranges::find(info.records, record, &ActionRecordInfo::record);
3107 std::optional<std::string> const ownKey =
3108 effect.kind == "updates" && use == ReplyUse::Applied && own != info.records.end() ? own->keyField
3109 : std::nullopt;
3110 detail::RowsByKey rows;
3111 if (ownKey) {
3112 for (Value const* const row : detail::keyedRowsOf(reply, own->paths, *ownKey)) {
3113 rows.insert_or_assign(detail::canonicalJson(*row->field(*ownKey)), row);
3114 }
3115 }
3116 for (auto const& [key, entry] : _entries) {
3117 static_cast<void>(key);
3118 ActionRecordInfo const* const held = entry->holding(record);
3119 if (held == nullptr) {
3120 continue;
3121 }
3122 if (!ownKey || !held->keyField) {
3123 marks.add(*entry);
3124 continue;
3125 }
3126 if (auto patched = detail::patchHeldRows(entry->confirmed, held->paths, *held->keyField, rows)) {
3127 confirm(*entry, *patched);
3128 }
3129 // A fetch in flight may have read before the write, and would settle over the patch.
3130 if (entry->pending.peek()) {
3131 markStale(*entry);
3132 }
3133 }
3134}
3135
3136inline void Session::markAndRefetch(detail::Marks const& marks) {
3137 for (detail::CacheEntry* const entry : marks.entries) {
3138 markStale(*entry);
3139 }
3140 for (detail::CacheEntry const* const entry : marks.entries) {
3141 refetchMounted(*entry);
3142 }
3143}
3144
3145inline void Session::countTopics(detail::CacheEntry const& entry, int delta) {
3146 if (entry.evaluate) {
3147 return;
3148 }
3149 // One topic: its count, and the queues a change of the count from or to zero goes to.
3150 auto const count = [delta](auto& refs, auto const& topic, auto& watches, auto& unwatches) {
3151 int& refsOf = refs[topic];
3152 refsOf += delta;
3153 if (delta > 0 && refsOf == 1 && unwatches.erase(topic) == 0) {
3154 watches.insert(topic);
3155 } else if (refsOf <= 0) {
3156 refs.erase(topic);
3157 if (watches.erase(topic) == 0) {
3158 unwatches.insert(topic);
3159 }
3160 }
3161 };
3162 if (!entry.records.empty()) {
3163 for (ActionRecordInfo const& record : entry.records) {
3164 count(_recordRefs, record.record, _watchRecords, _unwatchRecords);
3165 }
3166 return;
3167 }
3168 constexpr std::string_view kShared = "shared:";
3169 if (entry.instanceToken.starts_with(kShared)) {
3170 count(_instanceRefs, std::pair{entry.typeId, entry.instanceToken.substr(kShared.size())}, _watchInstances,
3171 _unwatchInstances);
3172 }
3173}
3174
3175inline void Session::sendWatch(std::string_view kind, wire::WatchBody const& body) const {
3176 auto completion = _options.watcher(std::string{kind}, glz::write_json(body).value_or(std::string{}));
3177 completion.then([](std::string const&) {}).onError([](std::exception_ptr const&) {});
3178}
3179
3180inline void Session::flushWatches() {
3181 if (!_options.watcher) {
3182 _watchRecords.clear();
3183 _unwatchRecords.clear();
3184 _watchInstances.clear();
3185 _unwatchInstances.clear();
3186 return;
3187 }
3188 auto const take = [](auto& records, auto& instances) {
3189 wire::WatchBody body;
3190 body.records.assign(records.begin(), records.end());
3191 for (auto const& [modelType, primary] : instances) {
3192 body.instances.push_back(wire::InstanceTopic{.modelType = modelType, .primary = primary, .instance = 0});
3193 }
3194 records.clear();
3195 instances.clear();
3196 return body;
3197 };
3198 if (!_watchRecords.empty() || !_watchInstances.empty()) {
3199 sendWatch(wire::kWatchKind, take(_watchRecords, _watchInstances));
3200 }
3201 if (!_unwatchRecords.empty() || !_unwatchInstances.empty()) {
3202 sendWatch(wire::kUnwatchKind, take(_unwatchRecords, _unwatchInstances));
3203 }
3204}
3205
3207 wire::WatchBody body;
3208 for (auto const& [record, refs] : _recordRefs) {
3209 static_cast<void>(refs);
3210 body.records.push_back(record);
3211 }
3212 for (auto const& [topic, refs] : _instanceRefs) {
3213 static_cast<void>(refs);
3214 body.instances.push_back(
3215 wire::InstanceTopic{.modelType = topic.first, .primary = topic.second, .instance = 0});
3216 }
3217 return body;
3218}
3219
3220inline void Session::markAllStale() {
3221 _runtime->batch([this] {
3222 detail::Marks marks;
3223 for (auto const& [key, entry] : _entries) {
3224 static_cast<void>(key);
3225 if (!entry->evaluate) {
3226 marks.add(*entry);
3227 }
3228 }
3229 markAndRefetch(marks);
3230 });
3231}
3232
3234 // The whole set replaces what the server holds, so the queued changes are superseded.
3235 _watchRecords.clear();
3236 _unwatchRecords.clear();
3237 _watchInstances.clear();
3238 _unwatchInstances.clear();
3239 if (_options.watcher) {
3240 wire::WatchBody body = watchSet();
3241 body.replace = true;
3242 sendWatch(wire::kWatchKind, body);
3243 }
3244 markAllStale();
3245}
3246
3247inline void Session::deliverEvent(wire::EventBody const& event) {
3248 if (event.topic == wire::kResyncTopic) {
3249 reconnected();
3250 return;
3251 }
3252 if (event.topic != wire::kChangesTopic) {
3253 return;
3254 }
3255 if (auto changes = wire::readChangesBody(event.payload.str)) {
3256 applyChanges(*changes);
3257 }
3258}
3259
3260inline void Session::applyChanges(wire::ChangesBody const& changes) {
3261 _runtime->batch([&] {
3262 detail::Marks marks;
3263 for (effects::RecordChange const& change : changes.records) {
3264 markRecordChange(change, marks);
3265 }
3266 for (wire::InstanceTopic const& instance : changes.instances) {
3267 markInstanceChange(instance, marks);
3268 }
3269 markAndRefetch(marks);
3270 });
3271}
3272
3273inline void Session::markRecordChange(effects::RecordChange const& change, detail::Marks& marks) {
3274 // The keys' canonical text, so a key the server wrote is compared with the rows' own.
3275 std::set<std::string, std::less<>> keys;
3276 for (glz::raw_json const& key : change.keys.value_or(std::vector<glz::raw_json>{})) {
3277 auto json = parseJson(key.str);
3278 auto value = json ? valueFromJson(*json) : std::unexpected(json.error());
3279 if (value) {
3280 keys.insert(detail::canonicalJson(*value));
3281 }
3282 }
3283 for (auto const& [name, entry] : _entries) {
3284 static_cast<void>(name);
3285 ActionRecordInfo const* const held = entry->holding(change.record);
3286 if (held == nullptr) {
3287 continue;
3288 }
3289 // An entry still waiting for its first value may come to hold a key; keyless rows cannot be told apart.
3290 if (!change.keys || !held->keyField || !entry->hasValue.peek()) {
3291 marks.add(*entry);
3292 continue;
3293 }
3294 std::string const& keyField = *held->keyField;
3295 bool const holdsKey = std::ranges::any_of(
3296 detail::keyedRowsOf(entry->confirmed, held->paths, keyField),
3297 [&](Value const* row) { return keys.contains(detail::canonicalJson(*row->field(keyField))); });
3298 if (holdsKey) {
3299 marks.add(*entry);
3300 }
3301 }
3302}
3303
3304inline void Session::markInstanceChange(wire::InstanceTopic const& instance, detail::Marks& marks) {
3305 std::string const shared = "shared:" + instance.primary;
3306 for (auto const& [name, entry] : _entries) {
3307 static_cast<void>(name);
3308 if (entry->evaluate || entry->typeId != instance.modelType) {
3309 continue;
3310 }
3311 // Without a primary key, the instance is one of the connection's own private ones.
3312 bool const same =
3313 instance.primary.empty() ? !entry->instanceToken.starts_with("shared") : entry->instanceToken == shared;
3314 if (same) {
3315 marks.add(*entry);
3316 }
3317 }
3318}
3319
3320inline void Session::actionSucceeded(std::string const& typeId, std::string const& actionId, Value const& reply,
3321 ReplyUse use, std::string const& instanceToken) {
3322 _runtime->batch([&] { applyReplyEffects(typeId, actionId, reply, use, instanceToken); });
3323}
3324
3325inline void Session::detachQuery(detail::QueryRuntime& query) { query.detach(); }
3326
3327namespace detail {
3328
3329/// @brief The text of an instance key, as the bridge's string-keyed attach takes it.
3330/// @param key The key's value.
3331/// @return The text; empty for `null`.
3332[[nodiscard]] inline std::string instanceKeyText(Value const& key) {
3333 if (key.isNull()) {
3334 return {};
3335 }
3336 if (std::int64_t const* const number = key.asInt()) {
3337 return std::to_string(*number);
3338 }
3339 if (std::string const* const text = key.asString()) {
3340 return *text;
3341 }
3342 return canonicalJson(key);
3343}
3344
3345/// @brief The channel an alias is bound to for its current token, binding it when the token changed.
3346/// @param alias The alias.
3347/// @param core The screen.
3348/// @return The channel, or `nullptr` while the alias is unbound.
3349[[nodiscard]] inline std::shared_ptr<ModelChannel> channelOf(ModelAlias& alias, ScreenCore& core) {
3350 std::string const token = core.runtime().untracked([&] { return alias.token->get(); });
3351 if (token.empty()) {
3352 return nullptr;
3353 }
3354 if (alias.channel && alias.boundToken == token) {
3355 return alias.channel;
3356 }
3357 std::string const& type = alias.decl->type;
3358 switch (alias.mode) {
3360 auto const found = core.borrowed.find(alias.decl->name);
3361 alias.channel = found == core.borrowed.end() ? nullptr : found->second.channel;
3362 break;
3363 }
3365 // `shared` is for a stateless model, whose instances are interchangeable: its calls go to an instance of
3366 // the alias's own, and what it shares with other screens is the query cache, through the token.
3367 alias.channel = core.dispatcher.bind(type, bridge::BindSharing::Private, {});
3368 break;
3370 alias.channel = core.dispatcher.bind(type, bridge::BindSharing::Shared, token.substr(7));
3371 break;
3374 default:
3375 alias.channel = core.dispatcher.bind(type, bridge::BindSharing::Private, {});
3376 break;
3377 }
3378 alias.boundToken = token;
3379 return alias.channel;
3380}
3381
3382inline RtScope::RtScope(ScreenCore& screen, std::shared_ptr<RtScope> enclosing, reactive::Signal<Value> const* current,
3383 std::size_t depth)
3384 : core{screen}, parent{std::move(enclosing)}, row{current}, nodes{screen.runtime(), depth} {
3385 if (core.recording) {
3386 core.scopes.push_back(this);
3387 }
3388}
3389
3391 if (core.recording) {
3392 std::erase(core.scopes, this);
3393 }
3394 if (owner != nullptr) {
3395 core.addPending(-1);
3396 if (owner->inner.peek() == this) {
3397 owner->inner.set(nullptr);
3398 }
3399 }
3400}
3401
3402// NOLINTNEXTLINE(misc-no-recursion): an element's value can read a nested scope's readiness, one level per nesting.
3403inline bool RtScope::ready() const {
3404 for (auto const& element : elements) {
3405 Value const shown = core.elementValue(*element, "visible");
3406 bool const* const visible = shown.asBool();
3407 ValueList const* const errors = element->errors->get().asList();
3408 if ((visible == nullptr || *visible) && errors != nullptr && !errors->empty()) {
3409 return false;
3410 }
3411 }
3412 for (auto const& check : checks) {
3413 if (check->decl->blocking && check->failing->get()) {
3414 return false;
3415 }
3416 }
3417 return true;
3418}
3419
3420// NOLINTNEXTLINE(misc-no-recursion): an `app.` name is read in the root scope with its head consumed, once per segment.
3421inline Value ScopeEnv::ref(std::string_view head, std::span<std::string const> path) const {
3422 if (head == "app") {
3423 RtScope const* const app = _scope->core.session.appScope();
3424 if (app == nullptr || path.empty()) {
3425 return Value{};
3426 }
3427 return ScopeEnv{*app, nullptr}.ref(path.front(), path.subspan(1));
3428 }
3429 if (_bindings != nullptr) {
3430 for (Binding const& bound : std::views::reverse(*_bindings)) {
3431 if (bound.name == head) {
3432 return walk(bound.read(), path);
3433 }
3434 }
3435 }
3436 NameRef const* const name = _scope->find(head);
3437 if (name == nullptr) {
3438 return Value{};
3439 }
3440 switch (name->category) {
3442 return walk(name->param, path);
3444 _scope->core.initState(*name->state->scope, *name->state);
3445 // State inits are acyclic (`loadDocument` checks it), so the value is made by now.
3446 return walk(name->state->value->get(), path);
3447 case NameCategory::Let:
3448 return walk(name->let->get(), path);
3450 return walk(name->query->value(), path);
3452 if (path.empty()) {
3453 return Value{};
3454 }
3455 return walk(_scope->core.elementValue(*name->element, path.front()), path.subspan(1));
3457 if (path.empty()) {
3458 return Value{};
3459 }
3460 TransferState const& transfer = *name->transfer;
3461 Value value;
3462 if (path.front() == "progress") {
3463 value = transfer.progress.get();
3464 } else if (path.front() == "pending") {
3465 value = Value{transfer.pending.get()};
3466 } else if (path.front() == "error") {
3467 value = transfer.error.get();
3468 }
3469 return walk(value, path.subspan(1));
3470 }
3473 default:
3474 return Value{};
3475 }
3476}
3477
3478// NOLINTNEXTLINE(readability-function-cognitive-complexity): one case per control form and kind of name.
3479inline Value ScopeEnv::control(ExprKind form, std::string_view name) const {
3480 Session const& session = _scope->core.session;
3481 NameRef const* const found = _scope->find(name);
3482 if (form == ExprKind::Can) {
3483 if (found != nullptr && found->category == NameCategory::Query) {
3484 return session.can(found->query->decl().action);
3485 }
3486 if (found != nullptr && found->category == NameCategory::Mutation) {
3487 return session.can(found->mutation->decl().action);
3488 }
3489 return session.can(name);
3490 }
3491 if (found != nullptr && found->category == NameCategory::Query) {
3492 QueryRuntime const& query = *found->query;
3493 if (form == ExprKind::Query) {
3494 return query.value();
3495 }
3496 if (form == ExprKind::Pending) {
3497 return query.pending();
3498 }
3499 if (form == ExprKind::Error) {
3500 return query.error();
3501 }
3502 return form == ExprKind::ErrorKind ? query.errorKind() : Value{};
3503 }
3504 if (found != nullptr && found->category == NameCategory::Mutation) {
3505 MutationRuntime const& mutation = *found->mutation;
3506 if (form == ExprKind::Result) {
3507 return mutation.result();
3508 }
3509 if (form == ExprKind::Pending) {
3510 return mutation.pending();
3511 }
3512 if (form == ExprKind::Error) {
3513 return mutation.error();
3514 }
3515 return form == ExprKind::ErrorKind ? mutation.errorKind() : Value{};
3516 }
3517 return Value{};
3518}
3519
3520inline Value ScopeEnv::pref(std::string_view point, std::span<std::string const> path) const {
3521 ValueRecord const& prefs = _scope->core.prefs.get();
3522 auto const found = prefs.find(point);
3523 return found == prefs.end() ? Value{} : walk(found->second, path);
3524}
3525
3526inline Value ScopeEnv::client(ExprKind form, std::string_view name) const {
3527 return _scope->core.session.client(form, name);
3528}
3529
3530inline FormatContext const& ScopeEnv::formatting() const { return _scope->core.session.formatting(); }
3531
3532inline void ScopeEnv::report(std::string_view path, std::string_view reason) const {
3533 _scope->core.report(path, reason);
3534}
3535
3536// --- Queries ---------------------------------------------------------------------------------------------------
3537
3539 : _decl{&decl},
3540 _typeId{alias->decl->type},
3541 _scope{&scope},
3542 _alias{alias},
3543 _entry{scope.core.runtime()},
3544 _carried{scope.core.runtime()},
3545 _waiting{scope.core.runtime(), false} {
3546 scope.core.session.attachQuery(*this);
3547}
3548
3550 _debounce.cancel();
3551 _refresh.cancel();
3552 _scope->core.session.forgetQuery(*this);
3553 if (auto const& current = _entry.peek()) {
3554 _scope->core.session.release(current);
3555 }
3556}
3557
3558inline std::shared_ptr<ModelChannel> QueryRuntime::channel() const { return channelOf(*_alias, _scope->core); }
3559
3560// NOLINTNEXTLINE(readability-function-cognitive-complexity): the query's key, its fetch and its refresh triggers.
3561inline void QueryRuntime::start() {
3562 RtScope& scope = *_scope;
3563 auto& key = scope.nodes.computed([this]() -> Target {
3564 Target target;
3565 if (_decl->when) {
3566 Value const when = evalIn(*_scope, nullptr, _decl->when);
3567 bool const* const flag = when.asBool();
3568 if (flag == nullptr || !*flag) {
3569 return target;
3570 }
3571 }
3572 std::string token = _alias->token->get();
3573 if (token.empty()) {
3574 return target;
3575 }
3576 Value body = _decl->body ? evalIn(*_scope, nullptr, _decl->body) : Value{ValueRecord{}};
3577 if (body.isNull()) {
3578 return target;
3579 }
3580 target.idle = false;
3581 target.token = std::move(token);
3582 target.bodyText = canonicalJson(body);
3583 target.body = std::move(body);
3584 return target;
3585 });
3586 _key = &key;
3587 reactive::Scheduler* const scheduler = scope.core.session.options().scheduler;
3588 std::weak_ptr<bool> const alive = _alive;
3589 scope.nodes.effect([this, &key, scheduler, alive, first = true]() mutable {
3590 Target target = key.get();
3591 _scope->core.runtime().untracked([&] {
3592 if (first || target.idle || _decl->debounce <= 0 || scheduler == nullptr) {
3593 first = false;
3594 _debounce.cancel();
3595 _waiting.set(false);
3596 switchTo(target);
3597 return;
3598 }
3599 _pendingTarget = std::move(target);
3600 _waiting.set(true);
3601 _debounce = scheduler->after(std::chrono::milliseconds{_decl->debounce}, [this, alive] {
3602 if (alive.expired()) {
3603 return;
3604 }
3605 _scope->core.runtime().batch([this] {
3606 _waiting.set(false);
3607 switchTo(_pendingTarget);
3608 });
3609 });
3610 });
3611 });
3612 if (_decl->refreshEvery > 0 && scheduler != nullptr) {
3613 _refresh = scheduler->every(std::chrono::milliseconds{_decl->refreshEvery}, [this, alive] {
3614 if (alive.expired()) {
3615 return;
3616 }
3617 auto const current = _entry.peek();
3618 if (!current || _waiting.peek() || current->pending.peek()) {
3619 return;
3620 }
3621 _scope->core.runtime().batch([&] { _scope->core.session.fetch(current, channel()); });
3622 });
3623 }
3624}
3625
3626inline void QueryRuntime::switchTo(Target const& target) {
3627 Session& session = _scope->core.session;
3628 std::shared_ptr<CacheEntry> const old = _entry.peek();
3629 if (old && target == _target) {
3630 return;
3631 }
3632 Value shown = old && old->hasValue.peek() ? old->value.peek() : _carried.peek();
3633 _target = target;
3634 if (target.idle) {
3635 _carried.set(Value{});
3636 _entry.set(nullptr);
3637 if (old) {
3638 session.release(old);
3639 }
3640 return;
3641 }
3642 auto fresh = session.acquire(target.token, _typeId, _decl->action, _decl->evaluate, target.body);
3643 _carried.set(std::move(shown));
3644 _entry.set(fresh);
3645 if (old) {
3646 session.release(old);
3647 }
3648 bool const young = fresh->fetchedAt.has_value() &&
3649 session.now() - *fresh->fetchedAt < std::chrono::milliseconds{_decl->staleTime};
3650 if (!fresh->pending.peek() && (!fresh->hasValue.peek() || fresh->stale || !young)) {
3651 session.fetch(fresh, channel());
3652 }
3653}
3654
3656 _scope->core.runtime().batch([this] {
3657 bool switched = false;
3658 if (_waiting.peek()) {
3659 _debounce.cancel();
3660 _waiting.set(false);
3661 switchTo(_pendingTarget);
3662 switched = true;
3663 }
3664 auto const current = _entry.peek();
3665 if (current && !(switched && current->pending.peek())) {
3666 _scope->core.session.fetch(current, channel());
3667 }
3668 });
3669}
3670
3672 if (_key == nullptr) {
3673 refetch();
3674 return;
3675 }
3676 _scope->core.runtime().batch([this] {
3677 _debounce.cancel();
3678 _waiting.set(false);
3679 Target const target = _key->peek();
3680 if (!(_entry.peek() && target == _target)) {
3681 switchTo(target);
3682 return;
3683 }
3684 if (auto const current = _entry.peek(); current && !current->pending.peek()) {
3685 _scope->core.session.fetch(current, channel());
3686 }
3687 });
3688}
3689
3691 auto const& current = _entry.get();
3692 if (current) {
3693 if (current->hasValue.get()) {
3694 return current->value.get();
3695 }
3696 if (!current->error.get().empty()) {
3697 return Value{};
3698 }
3699 }
3700 return _carried.get();
3701}
3702
3703inline bool QueryRuntime::pending() const {
3704 if (_waiting.get()) {
3705 return true;
3706 }
3707 auto const& current = _entry.get();
3708 return current && current->pending.get();
3709}
3710
3712 auto const& current = _entry.get();
3713 if (!current || current->error.get().empty()) {
3714 return Value{};
3715 }
3716 return Value{current->error.get()};
3717}
3718
3720 auto const& current = _entry.get();
3721 if (!current || current->errorKind.get().empty()) {
3722 return Value{};
3723 }
3724 return Value{current->errorKind.get()};
3725}
3726
3727inline bool QueryRuntime::settled() const {
3728 auto const& current = _entry.get();
3729 return !current || current->hasValue.get() || !current->error.get().empty();
3730}
3731
3732// --- Mutations -------------------------------------------------------------------------------------------------
3733
3735 : _decl{&decl},
3736 _scope{&scope},
3737 _alias{alias},
3738 _typeId{alias->decl->type},
3739 _core{&scope.core},
3740 _inFlight{scope.core.runtime(), 0},
3741 _result{scope.core.runtime()},
3742 _error{scope.core.runtime()},
3743 _errorKind{scope.core.runtime()} {}
3744
3746 *_alive = false;
3747 // A run whose reply will now never be read is no longer pending.
3748 if (int const running = _inFlight.peek(); running > 0) {
3749 _core->addPending(-running);
3750 }
3751}
3752
3753// NOLINTBEGIN(misc-no-recursion): a mutation's commands can run another mutation, and a serial queue sends its next
3754// run as the last one settles.
3755inline void MutationRuntime::run(Value body, std::shared_ptr<Bindings const> bindings, RunDone done) {
3756 auto run = std::make_shared<Run>();
3757 run->body = std::move(body);
3758 run->bindings = std::move(bindings);
3759 run->done = std::move(done);
3760 if (_decl->concurrency == Concurrency::Exclusive && _inFlight.peek() > 0) {
3761 run->done(false, Value{});
3762 return;
3763 }
3764 _inFlight.set(_inFlight.peek() + 1);
3765 _core->addPending(1);
3766 if (_decl->concurrency == Concurrency::Serial) {
3767 // The key reads the run's body, so a run keys the same whichever command started it.
3768 Bindings withBody = *run->bindings;
3769 withBody.push_back(Binding{.name = "body", .read = [body = run->body] { return body; }});
3770 Value const key = evalIn(*_scope, &withBody, _decl->serialKey);
3771 run->serialKey = key.isNull() ? std::string{} : canonicalJson(key);
3772 if (_busy.contains(run->serialKey)) {
3773 _queues[run->serialKey].push_back(run);
3774 return;
3775 }
3776 _busy.insert(run->serialKey);
3777 }
3778 send(run);
3779}
3780
3781inline void MutationRuntime::send(std::shared_ptr<Run> const& run) {
3782 run->id = ++_runs;
3783 _latest = run->id;
3784 applyOptimistic(*run);
3785 std::shared_ptr<ModelChannel> const channel = channelOf(*_alias, _scope->core);
3786 if (channel == nullptr) {
3787 settle(run, std::nullopt, std::make_exception_ptr(std::runtime_error{"the model alias has no instance"}));
3788 return;
3789 }
3790 std::weak_ptr<bool> const alive = _alive;
3791 try {
3792 auto completion = channel->execute(_decl->action, canonicalJson(run->body),
3793 CallOptions{.queueOffline = _decl->offlineQueue});
3794 completion
3795 .then([this, alive, run](std::string const& reply) {
3796 if (alive.expired()) {
3797 return;
3798 }
3799 auto json = parseJson(reply);
3800 auto value = json ? valueFromJson(*json) : std::unexpected(json.error());
3801 if (!value) {
3802 settle(run, std::nullopt,
3803 std::make_exception_ptr(
3804 std::runtime_error{"the reply is not a document value: " + value.error()}));
3805 return;
3806 }
3807 settle(run, std::move(*value), nullptr);
3808 })
3809 .onError([this, alive, run](std::exception_ptr const& error) {
3810 if (!alive.expired()) {
3811 settle(run, std::nullopt, error);
3812 }
3813 });
3814 } catch (...) {
3815 settle(run, std::nullopt, std::current_exception());
3816 }
3817}
3818
3819inline void MutationRuntime::applyOptimistic(Run& run) {
3820 if (!_decl->optimistic) {
3821 return;
3822 }
3823 NameRef const* const target = _scope->find(_decl->optimistic->query);
3824 if (target == nullptr || target->category != NameCategory::Query) {
3825 return;
3826 }
3827 auto entry = _scope->core.runtime().untracked([&] { return target->query->entry(); });
3828 if (!entry) {
3829 return;
3830 }
3831 Bindings bindings = *run.bindings;
3832 bindings.push_back(Binding{.name = "body", .read = [body = run.body] { return body; }});
3833 Value key = _scope->core.runtime().untracked([&] { return evalIn(*_scope, &bindings, _decl->optimistic->key); });
3834 Value with = _scope->core.runtime().untracked([&] { return evalIn(*_scope, &bindings, _decl->optimistic->with); });
3835 // Before a value has arrived there is no list to miss.
3836 if (entry->hasValue.peek() &&
3837 patchRows(entry->confirmed, _decl->optimistic->at, key, with).error_or(PatchMiss::NoRow) ==
3838 PatchMiss::NoList) {
3839 _scope->core.report(pointerAppend(_decl->path, "optimistic"),
3840 patchMissReason(_decl->optimistic->query, _decl->optimistic->at));
3841 }
3842 entry->layers.push_back(
3843 OptimisticLayer{.run = run.id, .at = _decl->optimistic->at, .key = std::move(key), .with = std::move(with)});
3844 _scope->core.session.showLayers(*entry);
3845 run.layered.push_back(std::move(entry));
3846}
3847
3848inline void MutationRuntime::dropLayers(Run& run) {
3849 for (auto const& entry : run.layered) {
3850 std::erase_if(entry->layers, [&](OptimisticLayer const& layer) { return layer.run == run.id; });
3851 _scope->core.session.showLayers(*entry);
3852 }
3853 run.layered.clear();
3854}
3855
3856// NOLINTNEXTLINE(readability-function-cognitive-complexity): one branch per outcome a reply can have.
3857inline void MutationRuntime::settle(std::shared_ptr<Run> const& run, std::optional<Value> reply,
3858 std::exception_ptr const& error) {
3859 ScreenCore& core = _scope->core;
3860 Session& session = core.session;
3861 bool const applied = _decl->concurrency != Concurrency::Latest || run->id == _latest;
3862 std::shared_ptr<RtScope> const scope = _scope->shared_from_this();
3863 auto commandCtx = [&](Bindings extra) {
3864 return CommandCtx{.scope = scope,
3865 .bindings = std::make_shared<Bindings>(std::move(extra)),
3866 .cancelClose = std::make_shared<bool>(false),
3867 .boundary = nullptr};
3868 };
3869 Value delivered;
3870 bool succeeded = false;
3871 // NOLINTNEXTLINE(readability-function-cognitive-complexity): one branch per outcome a reply can have.
3872 core.runtime().batch([&] {
3873 _inFlight.set(_inFlight.peek() - 1);
3874 _core->addPending(-1);
3875 if (reply) {
3876 succeeded = true;
3877 // The run's optimistic writes are confirmed by the reply: they become part of the confirmed rows.
3878 for (auto const& entry : run->layered) {
3879 Value confirmed = entry->confirmed;
3880 for (OptimisticLayer const& layer : entry->layers) {
3881 if (layer.run == run->id) {
3882 if (auto patched = patchRows(confirmed, layer.at, layer.key, layer.with)) {
3883 confirmed = std::move(*patched);
3884 }
3885 }
3886 }
3887 std::erase_if(entry->layers, [&](OptimisticLayer const& layer) { return layer.run == run->id; });
3888 session.confirm(*entry, confirmed);
3889 }
3890 run->layered.clear();
3891 std::vector<std::string> secret;
3892 if (session.options().catalog.action) {
3893 if (auto const info = session.options().catalog.action(_decl->action)) {
3894 secret = info->secretFields;
3895 }
3896 }
3897 delivered = withoutFields(*reply, secret);
3898 std::string const token =
3899 _alias == nullptr ? std::string{} : core.runtime().untracked([&] { return _alias->token->get(); });
3900 if (!applied) {
3901 session.actionSucceeded(_typeId, _decl->action, *reply, ReplyUse::Discarded, token);
3902 return;
3903 }
3904 _result.set(*reply);
3905 _error.set(Value{});
3906 _errorKind.set(Value{});
3907 session.actionSucceeded(_typeId, _decl->action, *reply, ReplyUse::Applied, token);
3908 core.runList(_decl->onSuccess, commandCtx(*run->bindings), {});
3909 if (!secret.empty()) {
3910 _result.set(delivered);
3911 }
3912 return;
3913 }
3914 dropLayers(*run);
3915 std::string const kind = session.classify(error);
3916 if (applied) {
3917 _error.set(Value{failureText(error)});
3918 _errorKind.set(Value{kind});
3919 core.runList(_decl->onError, commandCtx(*run->bindings), {});
3920 }
3921 if (kind == "denied" && core.host != nullptr) {
3922 core.host->permissionsStale();
3923 }
3924 });
3925 if (_decl->concurrency == Concurrency::Serial) {
3926 next(run->serialKey);
3927 }
3928 run->done(succeeded, delivered);
3929}
3930
3931inline void MutationRuntime::next(std::string const& serialKey) {
3932 auto const found = _queues.find(serialKey);
3933 if (found == _queues.end() || found->second.empty()) {
3934 _queues.erase(serialKey);
3935 _busy.erase(serialKey);
3936 return;
3937 }
3938 std::shared_ptr<Run> const queued = found->second.front();
3939 found->second.pop_front();
3940 _scope->core.runtime().batch([&] { send(queued); });
3941}
3942// NOLINTEND(misc-no-recursion)
3943
3944} // namespace detail
3945
3946namespace detail {
3947
3948// --- Declaring scopes ------------------------------------------------------------------------------------------
3949
3950/// @brief The element a node of a scope keeps its state in.
3951/// @param scope The scope that declares it.
3952/// @param node The node.
3953/// @return The element, or `nullptr` for a node that keeps none.
3954[[nodiscard]] inline ElementState* elementFor(RtScope const& scope, DocNode const& node) {
3955 for (auto const& element : scope.elements) {
3956 if (element->node == &node) {
3957 return element.get();
3958 }
3959 }
3960 return nullptr;
3961}
3962
3963/// @brief The transfers of the `upload` commands in a list, nested lists included.
3964/// @param commands The list.
3965/// @param out Where the transfer ids go.
3966// NOLINTNEXTLINE(misc-no-recursion): one level per nested command list
3967inline void transfersOf(Commands const& commands, std::vector<std::string>& out) {
3968 for (Command const& command : commands) {
3969 if (command.kind == CommandKind::Upload && !command.transfer.empty()) {
3970 out.push_back(command.transfer);
3971 }
3972 transfersOf(command.commands, out);
3973 transfersOf(command.alternative, out);
3974 }
3975}
3976
3977// NOLINTBEGIN(misc-no-recursion): a document is a tree of nodes and parts, and collecting descends it.
3978// NOLINTNEXTLINE(readability-function-cognitive-complexity): one case per kind of node and part.
3979inline void ScreenCore::collect(DocNode const& node, RtScope& scope, std::vector<Commands const*>& commands) {
3980 KindSpec const* const spec = findKind(node.kind, false);
3981 if (!node.id.empty() || statefulKind(node.kind)) {
3982 auto element = std::make_unique<ElementState>(runtime());
3983 element->node = &node;
3984 element->scope = &scope;
3985 if (!node.id.empty()) {
3986 NameRef name;
3987 name.category = NameCategory::Element;
3988 name.element = element.get();
3989 scope.names.insert_or_assign(node.id, name);
3990 }
3991 wireErrors(*element);
3992 scope.elements.push_back(std::move(element));
3993 }
3994 bool const createsScope = spec != nullptr && (spec->flags & kScope) != 0;
3995 for (auto const& [key, list] : node.events) {
3996 KeySpec const* const keyed = spec == nullptr ? nullptr : keySpec(*spec, key);
3997 if (!createsScope || keyed == nullptr || (keyed->flags & kInner) == 0) {
3998 commands.push_back(&list);
3999 }
4000 }
4001 for (auto const& [chord, list] : node.keys) {
4002 static_cast<void>(chord);
4003 commands.push_back(&list);
4004 }
4005 for (auto const& [key, nodes] : node.children) {
4006 KeySpec const* const keyed = spec == nullptr ? nullptr : keySpec(*spec, key);
4007 if (createsScope && keyed != nullptr && (keyed->flags & kInner) != 0) {
4008 continue;
4009 }
4010 for (DocNodePtr const& child : nodes) {
4011 collect(*child, scope, commands);
4012 }
4013 }
4014 for (auto const& [key, parts] : node.parts) {
4015 static_cast<void>(key);
4016 for (DocNodePtr const& part : parts) {
4017 KindSpec const* const partSpec = findKind(part->kind, true);
4018 if (partSpec != nullptr && (partSpec->flags & kScope) != 0) {
4019 continue;
4020 }
4021 for (auto const& [event, list] : part->events) {
4022 static_cast<void>(event);
4023 commands.push_back(&list);
4024 }
4025 for (auto const& [childKey, nodes] : part->children) {
4026 KeySpec const* const keyed = partSpec == nullptr ? nullptr : keySpec(*partSpec, childKey);
4027 if (keyed != nullptr && (keyed->flags & kInner) != 0) {
4028 continue;
4029 }
4030 for (DocNodePtr const& child : nodes) {
4031 collect(*child, scope, commands);
4032 }
4033 }
4034 }
4035 }
4036}
4037// NOLINTEND(misc-no-recursion)
4038
4039// NOLINTNEXTLINE(readability-function-cognitive-complexity): one section per kind of declaration.
4040inline void ScreenCore::declare(RtScope& scope, ScopeDecl const* decl, std::vector<DocNode const*> const& content,
4041 std::vector<Commands const*> const& commands) {
4042 std::vector<Commands const*> lists = commands;
4043 for (DocNode const* const node : content) {
4044 collect(*node, scope, lists);
4045 }
4046 std::vector<std::string> transferIds;
4047 for (Commands const* const list : lists) {
4048 transfersOf(*list, transferIds);
4049 }
4050 for (std::string const& transferId : transferIds) {
4051 if (scope.names.contains(transferId)) {
4052 continue;
4053 }
4054 scope.transfers.push_back(std::make_unique<TransferState>(runtime()));
4055 NameRef name;
4057 name.transfer = scope.transfers.back().get();
4058 scope.names.insert_or_assign(transferId, name);
4059 }
4060 if (decl != nullptr) {
4061 for (ModelDecl const& model : decl->models) {
4062 auto alias = std::make_unique<ModelAlias>();
4063 alias->decl = &model;
4064 alias->mode = model.instance;
4065 if (alias->mode == InstanceMode::Default) {
4066 std::optional<ModelInfo> const info =
4067 session.options().catalog.model ? session.options().catalog.model(model.type) : std::nullopt;
4068 alias->mode = info && info->stateless ? InstanceMode::Shared : InstanceMode::Private;
4069 }
4070 ModelAlias* const raw = alias.get();
4071 switch (raw->mode) {
4073 raw->token = &scope.nodes.computed([&scope, raw]() -> std::string {
4074 std::string const key = instanceKeyText(evalIn(scope, nullptr, raw->decl->key));
4075 return key.empty() ? std::string{} : "shared:" + key;
4076 });
4077 break;
4079 raw->token = &scope.nodes.computed([] { return std::string{"shared"}; });
4080 break;
4082 auto const found = borrowed.find(model.name);
4083 std::string token = found == borrowed.end() ? std::string{} : found->second.token;
4084 raw->token = &scope.nodes.computed([token = std::move(token)] { return token; });
4085 break;
4086 }
4089 default: {
4090 std::string token = session.privateToken();
4091 raw->token = &scope.nodes.computed([token = std::move(token)] { return token; });
4092 break;
4093 }
4094 }
4095 NameRef name;
4097 name.model = raw;
4098 scope.names.insert_or_assign(model.name, name);
4099 scope.models.push_back(std::move(alias));
4100 }
4101 for (LetDecl const& let : decl->lets) {
4102 NameRef name;
4104 name.letExpr = let.value;
4105 name.let = &scope.nodes.computed([&scope, expr = let.value] { return evalIn(scope, nullptr, expr); });
4106 scope.names.insert_or_assign(let.name, name);
4107 }
4108 for (StateDecl const& state : decl->state) {
4109 auto slot = std::make_unique<StateSlot>();
4110 slot->decl = &state;
4111 slot->scope = &scope;
4112 NameRef name;
4114 name.state = slot.get();
4115 scope.names.insert_or_assign(state.name, name);
4116 scope.states.push_back(std::move(slot));
4117 }
4118 auto aliasOf = [&scope](std::string const& model) -> ModelAlias* {
4119 NameRef const* const found = scope.find(model);
4120 return found != nullptr && found->category == NameCategory::Model ? found->model : nullptr;
4121 };
4122 for (QueryDecl const& query : decl->queries) {
4123 scope.queries.push_back(std::make_unique<QueryRuntime>(query, scope, aliasOf(query.model)));
4124 NameRef name;
4126 name.query = scope.queries.back().get();
4127 scope.names.insert_or_assign(query.name, name);
4128 }
4129 for (MutationDecl const& mutation : decl->mutations) {
4130 scope.mutations.push_back(std::make_unique<MutationRuntime>(mutation, scope, aliasOf(mutation.model)));
4131 NameRef name;
4133 name.mutation = scope.mutations.back().get();
4134 scope.names.insert_or_assign(mutation.name, name);
4135 }
4136 }
4137 for (auto const& slot : scope.states) {
4138 initState(scope, *slot);
4139 }
4140 for (auto const& element : scope.elements) {
4141 wireElement(*element);
4142 }
4143 if (decl == nullptr) {
4144 return;
4145 }
4146 for (CheckDecl const& check : decl->checks) {
4147 auto made = std::make_unique<CheckRuntime>();
4148 made->decl = &check;
4149 made->failing = &scope.nodes.computed([&scope, expr = check.assertion] {
4150 Value const asserted = evalIn(scope, nullptr, expr);
4151 bool const* const flag = asserted.asBool();
4152 return flag != nullptr && !*flag;
4153 });
4154 made->message = &scope.nodes.computed([&scope, expr = check.message, session = &session] {
4155 return textOf(evalIn(scope, nullptr, expr), session->formatting());
4156 });
4157 for (std::string const& target : check.targets) {
4158 NameRef const* const found = scope.find(target);
4159 if (found != nullptr && found->category == NameCategory::Element) {
4160 found->element->checks.push_back(made.get());
4161 made->targets.push_back(found->element);
4162 found->element->checksVersion.set(found->element->checksVersion.peek() + 1);
4163 }
4164 }
4165 scope.checks.push_back(std::move(made));
4166 }
4167 for (auto const& query : scope.queries) {
4168 query->start();
4169 }
4170 for (WatchDecl const& watch : decl->watches) {
4171 auto& source = scope.nodes.computed([&scope, expr = watch.on] { return evalIn(scope, nullptr, expr); });
4172 scope.nodes.effect([this, &scope, &source, watched = &watch, first = true]() mutable {
4173 static_cast<void>(source.get());
4174 if (first) {
4175 first = false;
4176 return;
4177 }
4178 bool const user = session.lastWriteByUser();
4179 if (watched->source == WatchSource::User && !user) {
4180 return;
4181 }
4182 runtime().untracked([&] {
4183 CommandCtx const ctx{.scope = scope.shared_from_this(),
4184 .bindings = std::make_shared<Bindings>(),
4185 .cancelClose = std::make_shared<bool>(false),
4186 .boundary = nullptr};
4187 if (user) {
4188 session.asUser([&] { runList(watched->body, ctx, {}); });
4189 } else {
4190 runList(watched->body, ctx, {});
4191 }
4192 });
4193 });
4194 }
4195}
4196
4197/// @brief What a date input reads from a value: the instant it shows, and whether the value names one at all.
4199 /// @brief The instant shown; empty for an empty field.
4200 std::optional<time::DateTime> instant;
4201 /// @brief Whether the value names a date, an instant or nothing; `false` for text that names no date.
4202 bool readable = true;
4203};
4204
4205/// @brief Reads what a date input shows for @p value, in a display zone @p offsetMinutes from UTC.
4206///
4207/// A timestamp is its instant; a `yyyy-MM-dd` string is midnight of that date in the display zone; a string
4208/// `DateTime::fromIso8601` reads is its instant; `null` and `""` are empty. Any other value is empty and
4209/// unreadable.
4210/// @param value The value.
4211/// @param offsetMinutes The display zone's offset from UTC, in minutes.
4212/// @return The instant, or nothing, and whether the value was readable.
4213[[nodiscard]] inline DateReading dateInstantOf(Value const& value, std::int32_t offsetMinutes) {
4214 if (value.isNull()) {
4215 return {};
4216 }
4217 if (time::DateTime const* const instant = value.asTimestamp()) {
4218 return {.instant = *instant};
4219 }
4220 std::string const* const text = value.asString();
4221 if (text == nullptr) {
4222 return {.instant = std::nullopt, .readable = false};
4223 }
4224 if (text->empty()) {
4225 return {};
4226 }
4227 if (forms::detail::engine::isIsoDate(*text)) {
4228 auto const wall = forms::detail::engine::parseWallClock(std::string{*text} + "T00:00");
4229 if (!wall) {
4230 return {.instant = std::nullopt, .readable = false};
4231 }
4232 auto const utc =
4233 std::chrono::time_point_cast<std::chrono::milliseconds>(*wall - std::chrono::minutes{offsetMinutes});
4234 return {.instant = time::DateTime{utc}};
4235 }
4236 std::optional<time::DateTime> parsed = time::DateTime::fromIso8601(*text);
4237 bool const readable = parsed.has_value();
4238 return {.instant = std::move(parsed), .readable = readable};
4239}
4240
4241/// @brief A date input's date text for @p instant in a display zone @p offsetMinutes from UTC.
4242/// @param instant The instant.
4243/// @param offsetMinutes The display zone's offset from UTC, in minutes.
4244/// @return `yyyy-MM-dd`.
4245[[nodiscard]] inline std::string dateTextOf(time::DateTime const& instant, std::int32_t offsetMinutes) {
4246 auto const local = instant.value + std::chrono::minutes{offsetMinutes};
4247 std::chrono::year_month_day const date{std::chrono::floor<std::chrono::days>(local)};
4248 return std::format("{:04}-{:02}-{:02}", static_cast<int>(date.year()), static_cast<unsigned>(date.month()),
4249 static_cast<unsigned>(date.day()));
4250}
4251
4252/// @brief Whether two values show the same in an input: by text for a text input, by instant for a date input, by
4253/// value otherwise.
4254/// @param kind The input's kind.
4255/// @param lhs One value.
4256/// @param rhs The other.
4257/// @param format The formatting facts.
4258/// @return It.
4259[[nodiscard]] inline bool sameShown(std::string_view kind, Value const& lhs, Value const& rhs,
4260 FormatContext const& format) {
4261 if (kind == "textInput" || kind == "filePicker") {
4262 return textOf(lhs, format) == textOf(rhs, format);
4263 }
4264 if (kind == "dateTimeInput") {
4265 return dateInstantOf(lhs, format.utcOffsetMinutes).instant ==
4266 dateInstantOf(rhs, format.utcOffsetMinutes).instant;
4267 }
4268 return lhs == rhs;
4269}
4270
4271/// @brief The key of a select option: a record's `key`, `id` or `value` field, or the option itself.
4272/// @param option The option.
4273/// @return The key.
4274[[nodiscard]] inline Value optionKey(Value const& option) {
4275 if (option.asRecord() == nullptr) {
4276 return option;
4277 }
4278 for (std::string_view const field : {"key", "id", "value"}) {
4279 if (Value const* const found = option.field(field)) {
4280 return *found;
4281 }
4282 }
4283 return Value{};
4284}
4285
4286// NOLINTNEXTLINE(readability-function-cognitive-complexity): one rule per source of an error.
4287inline void ScreenCore::wireErrors(ElementState& element) {
4288 RtScope& scope = *element.scope;
4289 element.errors = &scope.nodes.computed([this, &element]() -> Value {
4290 static_cast<void>(element.checksVersion.get());
4291 DocNode const& field = *element.node;
4292 RtScope const& owner = *element.scope;
4293 std::vector<std::string> errors;
4294 if (ExprPtr const required = field.prop("required")) {
4295 Value const requiredNow = evalIn(owner, nullptr, required);
4296 bool const* const flag = requiredNow.asBool();
4297 if (flag != nullptr && *flag && !engaged(inputValue(element))) {
4298 errors.emplace_back("Required");
4299 }
4300 }
4301 for (std::string& text : stringsOf(evalIn(owner, nullptr, field.prop("errors")))) {
4302 errors.push_back(std::move(text));
4303 }
4304 if (!field.id.empty()) {
4305 std::vector<std::string> const path{"errors", field.id};
4306 for (auto const& query : owner.queries) {
4307 for (std::string& text : stringsOf(walk(query->value(), path))) {
4308 errors.push_back(std::move(text));
4309 }
4310 }
4311 for (auto const& mutation : owner.mutations) {
4312 for (std::string& text : stringsOf(walk(mutation->result(), path))) {
4313 errors.push_back(std::move(text));
4314 }
4315 }
4316 }
4317 for (CheckRuntime const* const check : element.checks) {
4318 if (check->failing->get()) {
4319 errors.push_back(check->message->get());
4320 }
4321 }
4322 return listOfStrings(errors);
4323 });
4324}
4325
4326// NOLINTNEXTLINE(readability-function-cognitive-complexity): one case per kind of element.
4327inline void ScreenCore::wireElement(ElementState& element) {
4328 DocNode const& node = *element.node;
4329 RtScope& scope = *element.scope;
4330 std::string_view const kind = node.kind;
4331 if (kind == "form") {
4332 wireForm(element);
4333 return;
4334 }
4335 if (kind == "collection" || kind == "wizard") {
4336 wireComposite(element);
4337 return;
4338 }
4339 bool const input = kind == "textInput" || kind == "checkbox" || kind == "select" || kind == "dateTimeInput" ||
4340 kind == "slider" || kind == "filePicker";
4341 if (input) {
4342 std::string_view key = "value";
4343 if (kind == "checkbox") {
4344 key = "checked";
4345 } else if (kind == "select") {
4346 key = "selected";
4347 }
4348 ExprPtr const expr = kind == "filePicker" ? nullptr : node.prop(key);
4349 bool const controlled = expr && (expr->kind != ExprKind::Literal || !expr->literal.isNull());
4350 if (controlled) {
4351 element.bound = &scope.nodes.computed([&scope, expr] { return evalIn(scope, nullptr, expr); });
4352 element.draft.set(runtime().untracked([&] { return element.bound->get(); }));
4353 scope.nodes.effect([this, &element, kind] {
4354 Value const bound = element.bound->get();
4355 runtime().untracked([&] {
4356 if (!element.edited.peek()) {
4357 element.draft.set(bound);
4358 } else if (sameShown(kind, element.draft.peek(), bound, session.formatting())) {
4359 element.edited.set(false);
4360 }
4361 });
4362 });
4363 } else if (kind == "checkbox") {
4364 element.draft.set(false);
4365 }
4366 }
4367 if (kind == "select") {
4368 element.rows =
4369 &scope.nodes.computed([&scope, expr = node.prop("options")] { return evalIn(scope, nullptr, expr); });
4370 if (element.bound == nullptr && node.name("onStale") == "clear") {
4371 scope.nodes.effect([this, &element] {
4372 Value const options = element.rows->get();
4373 runtime().untracked([&] {
4374 Value const selected = element.draft.peek();
4375 if (selected.isNull()) {
4376 return;
4377 }
4378 ValueList const* const items = options.asList();
4379 bool const kept = items != nullptr && std::ranges::any_of(*items, [&](Value const& option) {
4380 return optionKey(option) == selected;
4381 });
4382 if (!kept) {
4383 session.noteWrite();
4384 element.draft.set(Value{});
4385 }
4386 });
4387 });
4388 }
4389 }
4390 if (kind == "table" && node.prop("rows")) {
4391 element.rows =
4392 &scope.nodes.computed([&scope, expr = node.prop("rows")] { return evalIn(scope, nullptr, expr); });
4393 }
4394}
4395
4396/// @brief A JSON text as a document value; `null` when it is not JSON or holds what a value cannot (a fraction).
4397/// @param text The JSON text.
4398/// @return The value.
4399[[nodiscard]] inline Value jsonTextValue(std::string_view text) {
4400 auto const json = parseJson(text);
4401 if (!json) {
4402 return Value{};
4403 }
4404 auto value = valueFromJson(*json);
4405 return value ? *std::move(value) : Value{};
4406}
4407
4408/// @brief One field of a form as a record: `value` (the encoded value, `null` while blank or invalid), `draft` (the
4409/// text), `valid`, `required`, `visible`, `readonly` and `errors`. Tracked.
4410/// @param field The field.
4411/// @return The record.
4412[[nodiscard]] inline Value formFieldValue(forms::FieldState const& field) {
4413 ValueRecord record;
4414 forms::Encoding const& encoding = field.encoded().get();
4415 Value value;
4416 if (encoding.has_value() && encoding->has_value()) {
4417 value = (*encoding)->value.isNull() ? jsonTextValue((*encoding)->json) : (*encoding)->value;
4418 }
4419 std::optional<forms::FieldError> const& error = field.error().get();
4420 record.insert_or_assign("value", std::move(value));
4421 record.insert_or_assign("draft", Value{field.spec().kind == forms::FieldKind::Object ||
4423 ? std::string{}
4424 : field.draft()});
4425 record.insert_or_assign("valid", Value{!error.has_value()});
4426 record.insert_or_assign("required", Value{field.required().get()});
4427 record.insert_or_assign("visible", Value{field.visible().get()});
4428 record.insert_or_assign("readonly", Value{field.readonly().get()});
4429 record.insert_or_assign("errors",
4430 error.has_value() ? Value{ValueList{Value{error->message}}} : Value{ValueList{}});
4431 return Value{std::move(record)};
4432}
4433
4434/// @brief A form's element values (spec 5 §6): `ready`, `body` (the record the form would submit, `null` while not
4435/// ready), `submitted` (whether a submission was sent), `pending`, and each field by its wire name. Tracked.
4436/// @param element The form's element.
4437/// @param name The value's name.
4438/// @return The value; `null` for a name the form does not have, or a form without a session.
4439[[nodiscard]] inline Value formValue(ElementState const& element, std::string_view name) {
4440 forms::FormSession const* const form = element.form.get();
4441 if (form == nullptr) {
4442 return Value{};
4443 }
4444 if (name == "ready") {
4445 return form->ready();
4446 }
4447 if (name == "body") {
4448 std::optional<std::string> const body = form->body();
4449 return body.has_value() ? jsonTextValue(*body) : Value{};
4450 }
4451 if (name == "submitted") {
4452 return form->submitAttempts() > 0;
4453 }
4454 if (name == "pending") {
4455 return form->pending();
4456 }
4457 for (auto const& field : form->fields()) {
4458 if (field->spec().name == name) {
4459 return formFieldValue(*field);
4460 }
4461 }
4462 return Value{};
4463}
4464
4465inline Value ScreenCore::inputValue(ElementState const& element) const {
4466 std::string_view const kind = element.node->kind;
4467 if (kind == "checkbox") {
4468 return elementValue(element, "checked");
4469 }
4470 if (kind == "select") {
4471 return elementValue(element, "selected");
4472 }
4473 return elementValue(element, "value");
4474}
4475
4476// NOLINTNEXTLINE(misc-no-recursion,readability-function-cognitive-complexity): one case per element value.
4477inline Value ScreenCore::elementValue(ElementState const& element, std::string_view name) const {
4478 DocNode const& node = *element.node;
4479 RtScope const& scope = *element.scope;
4480 std::string_view const kind = node.kind;
4481 auto flagOf = [&](std::string_view key, bool fallback) -> Value {
4482 ExprPtr const expr = node.prop(key);
4483 if (!expr) {
4484 return fallback;
4485 }
4486 Value const value = evalIn(scope, nullptr, expr);
4487 bool const* const flag = value.asBool();
4488 return flag != nullptr && *flag;
4489 };
4490 auto errors = [&]() -> Value { return element.errors->get(); };
4491 auto count = [](Value const& list) -> Value {
4492 ValueList const* const items = list.asList();
4493 return static_cast<std::int64_t>(items == nullptr ? 0 : items->size());
4494 };
4495 if (name == "visible" || name == "enabled") {
4496 return flagOf(name, true);
4497 }
4498 if (name == "errors") {
4499 return errors();
4500 }
4501 if (name == "valid") {
4502 Value const current = errors();
4503 ValueList const* const items = current.asList();
4504 return items == nullptr || items->empty();
4505 }
4506 if (name == "shownErrors") {
4507 RtScope const& policy = scope.policyScope();
4508 std::string const mode = policy.showErrors.empty() ? _doc->showErrors : policy.showErrors;
4509 bool const submittedNow = policy.submitted != nullptr && policy.submitted->get();
4510 if (mode == "always" || (mode == "submit" && submittedNow) ||
4511 (mode != "submit" && (submittedNow || element.touched.get()))) {
4512 return errors();
4513 }
4514 return Value{ValueList{}};
4515 }
4516 if (kind == "dialog" || kind == "drawer") {
4517 if (name == "open") {
4518 return node.prop("open") ? flagOf("open", false) : Value{element.open.get()};
4519 }
4520 if (name == "ready") {
4521 RtScope const* const inner = element.inner.get();
4522 return inner != nullptr && inner->ready();
4523 }
4524 if (name == "submitted") {
4525 return element.submitted.get();
4526 }
4527 return Value{};
4528 }
4529 if (kind == "table") {
4530 if (name == "selection") {
4531 return element.selection.get();
4532 }
4533 if (name == "activeKey") {
4534 return element.activeKey.get();
4535 }
4536 if (name == "selectedCount") {
4537 return count(element.selection.get());
4538 }
4539 if (name == "viewCount" || name == "sourceCount") {
4540 return element.rows == nullptr ? Value{std::int64_t{0}} : count(element.rows->get());
4541 }
4542 ExprPtr const rows = node.prop("rows");
4543 if ((name == "pending" || name == "error") && rows && rows->kind == ExprKind::Query) {
4544 ScopeEnv const env{scope, nullptr};
4545 return env.control(name == "pending" ? ExprKind::Pending : ExprKind::Error, rows->name);
4546 }
4547 return name == "pending" ? Value{false} : Value{};
4548 }
4549 if (kind == "form") {
4550 return formValue(element, name);
4551 }
4552 if (name == "draft") {
4553 return element.draft.get();
4554 }
4555 if (name == "dirty") {
4556 return element.edited.get();
4557 }
4558 if (name == "focused") {
4559 return element.focused.get();
4560 }
4561 if (name == "touched") {
4562 return element.touched.get();
4563 }
4564 if (name == "checked" || name == "selected") {
4565 return element.bound != nullptr ? element.bound->get() : element.draft.get();
4566 }
4567 if (name == "selectedOption") {
4568 Value const selected = elementValue(element, "selected");
4569 ValueList const* const options = element.rows == nullptr ? nullptr : element.rows->get().asList();
4570 if (options != nullptr && !selected.isNull()) {
4571 for (Value const& option : *options) {
4572 if (optionKey(option) == selected) {
4573 return option;
4574 }
4575 }
4576 }
4577 return Value{};
4578 }
4579 if (name == "value") {
4580 if (element.bound != nullptr) {
4581 return element.bound->get();
4582 }
4583 return element.committed.get();
4584 }
4585 return Value{};
4586}
4587
4588// --- Commands --------------------------------------------------------------------------------------------------
4589
4590// NOLINTBEGIN(misc-no-recursion): command lists nest
4591inline void ScreenCore::runList(Commands const& commands, CommandCtx const& ctx, Done done) {
4592 if (!done) {
4593 done = [](bool) {};
4594 }
4595 if (commands.empty()) {
4596 done(true);
4597 return;
4598 }
4599 struct Join {
4600 std::size_t left = 0;
4601 bool ok = true;
4602 Done done;
4603 };
4604 if (recording) {
4605 logList(commands);
4606 }
4607 auto join = std::make_shared<Join>(Join{.left = commands.size() + 1, .ok = true, .done = std::move(done)});
4608 auto finish = [join](bool succeeded) {
4609 join->ok = join->ok && succeeded;
4610 if (--join->left == 0) {
4611 join->done(join->ok);
4612 }
4613 };
4614 for (Command const& command : commands) {
4615 runOne(command, ctx, finish);
4616 }
4617 finish(true);
4618}
4619
4620// NOLINTNEXTLINE(readability-function-cognitive-complexity): one case per command of ui/1.
4621inline void ScreenCore::runOne(Command const& command, CommandCtx const& ctx, Done done) {
4622 if (!done) {
4623 done = [](bool) {};
4624 }
4625 RtScope const& scope = *ctx.scope;
4626 auto eval = [&](ExprPtr const& expr) { return evalIn(scope, ctx.bindings.get(), expr); };
4627 auto finish = [&done](bool succeeded) { done(succeeded); };
4628 switch (command.kind) {
4629 case CommandKind::Set:
4630 runSet(command, ctx, done);
4631 return;
4632 case CommandKind::Reset:
4633 runReset(command, ctx, done);
4634 return;
4635 case CommandKind::Run:
4636 runMutation(command, ctx, std::move(done));
4637 return;
4638 case CommandKind::Refetch: {
4639 // A loaded document's `refetch` names a query in scope (`loadDocument` checks it), as `run` names a
4640 // mutation and `open` a dialog.
4641 scope.find(command.target)->query->refetch();
4642 finish(true);
4643 return;
4644 }
4645 case CommandKind::Open:
4646 case CommandKind::Close: {
4647 if (command.kind == CommandKind::Close && command.target == "self") {
4648 finish((host != nullptr && host->closeSelf(_doc->id, std::nullopt)) ||
4649 unsupported(command, "`close: self`"));
4650 return;
4651 }
4652 ElementState& element = *scope.find(command.target)->element;
4653 if (element.node->prop("open")) {
4654 report(command.path, "the dialog's `open` is bound to an expression");
4655 finish(false);
4656 return;
4657 }
4658 bool const opening = command.kind == CommandKind::Open;
4659 if (opening && !element.open.peek()) {
4660 element.submitted.set(false);
4661 }
4662 session.noteWrite();
4663 element.open.set(opening);
4664 finish(true);
4665 return;
4666 }
4667 case CommandKind::Navigate: {
4668 NavigateRequest const request{.screen = command.target,
4669 .route = eval(command.extra),
4670 .params = eval(command.value),
4671 .as = command.option};
4672 if (host == nullptr) {
4673 finish(unsupported(command, "`navigate`"));
4674 return;
4675 }
4676 std::weak_ptr<RtScope> const weak = ctx.scope;
4677 CommandCtx const kept = detached(ctx);
4678 auto onResult = [this, weak, kept, list = &command.commands](Value const& result) {
4679 if (auto const held = weak.lock()) {
4680 auto bindings = std::make_shared<Bindings>(*kept.bindings);
4681 bindings->push_back(Binding{.name = "event", .read = [result] { return result; }});
4682 CommandCtx const next{.scope = held,
4683 .bindings = bindings,
4684 .cancelClose = kept.cancelClose,
4685 .boundary = kept.boundary};
4686 runtime().batch([&] { runList(*list, next, {}); });
4687 }
4688 };
4689 auto onCancel = [this, weak, kept, list = &command.alternative] {
4690 if (auto const held = weak.lock()) {
4691 CommandCtx next = kept;
4692 next.scope = held;
4693 runtime().batch([&] { runList(*list, next, {}); });
4694 }
4695 };
4696 finish(host->navigate(request, std::move(onResult), std::move(onCancel)) ||
4697 unsupported(command, "`navigate`"));
4698 return;
4699 }
4701 finish(host != nullptr && host->closeSelf(_doc->id, eval(command.value))
4702 ? true
4703 : unsupported(command, "`return`"));
4704 return;
4705 case CommandKind::Focus:
4706 if (focusElement && focusElement(command.target)) {
4707 finish(true);
4708 return;
4709 }
4710 report(command.path, "`" + command.target + "` is not mounted");
4711 finish(false);
4712 return;
4713 case CommandKind::Host:
4714 runHostCommand(command, ctx, done);
4715 return;
4717 std::weak_ptr<RtScope> const weak = ctx.scope;
4718 auto committed = [this, weak, target = command.target, path = command.path,
4719 done](std::expected<void, std::string> const& outcome) {
4720 if (!weak.lock()) {
4721 return;
4722 }
4723 if (!outcome) {
4724 report(path, "backendSwitchFailed: " + outcome.error());
4725 done(false);
4726 return;
4727 }
4728 session.backendChanged(target);
4729 done(true);
4730 };
4731 if (host != nullptr && host->switchBackend(command.target, std::move(committed))) {
4732 return;
4733 }
4734 report(command.path, "backend `" + command.target + "` is not listed");
4735 finish(false);
4736 return;
4737 }
4739 ValueRecord prefsNow = prefs.peek();
4740 if (command.target == "all") {
4741 prefsNow = prefDefaults;
4742 } else if (auto const found = prefDefaults.find(command.target); found != prefDefaults.end()) {
4743 prefsNow.insert_or_assign(command.target, found->second);
4744 }
4745 prefs.set(std::move(prefsNow));
4746 if (host != nullptr) {
4747 host->resetPrefs(_doc->id, command.target);
4748 }
4749 finish(true);
4750 return;
4751 }
4753 session.setLocale(command.target);
4754 if (host != nullptr) {
4755 host->setLocale(command.target);
4756 }
4757 finish(true);
4758 return;
4759 case CommandKind::Notify: {
4760 std::string const text = textOf(eval(command.value), session.formatting());
4761 finish(host != nullptr && host->notify(text, command.option) ? true : unsupported(command, "`notify`"));
4762 return;
4763 }
4764 case CommandKind::Seq: {
4765 // A step that waits for a reply keeps the rest of the sequence in that reply's handler, which the
4766 // dispatcher may hold past the screen: the sequence holds its scope weakly and stops once it is gone.
4767 CommandCtx const inner = detached(CommandCtx{.scope = nullptr,
4768 .bindings = std::make_shared<Bindings>(*ctx.bindings),
4769 .cancelClose = ctx.cancelClose,
4770 .boundary = ctx.boundary});
4771 std::weak_ptr<RtScope> const weak = ctx.scope;
4772 auto step = std::make_shared<std::function<void(std::size_t)>>();
4773 *step = [this, inner, weak, list = &command.commands, done = std::move(done),
4774 weakStep = std::weak_ptr<std::function<void(std::size_t)>>{step}](std::size_t index) {
4775 auto const held = weak.lock();
4776 if (index == list->size() || !held) {
4777 done(held != nullptr);
4778 return;
4779 }
4780 auto const self = weakStep.lock();
4781 CommandCtx current = inner;
4782 current.scope = held;
4783 runOne(list->at(index), current, [self, index, done](bool succeeded) {
4784 if (!succeeded) {
4785 done(false);
4786 return;
4787 }
4788 (*self)(index + 1);
4789 });
4790 };
4791 (*step)(0);
4792 return;
4793 }
4794 case CommandKind::All: {
4795 CommandCtx const inner{.scope = ctx.scope,
4796 .bindings = std::make_shared<Bindings>(*ctx.bindings),
4797 .cancelClose = ctx.cancelClose,
4798 .boundary = ctx.boundary};
4799 runList(command.commands, inner, std::move(done));
4800 return;
4801 }
4802 case CommandKind::If: {
4803 Value const condition = eval(command.value);
4804 bool const* const flag = condition.asBool();
4805 if (flag == nullptr) {
4806 finish(true);
4807 return;
4808 }
4809 runList(*flag ? command.commands : command.alternative, ctx, std::move(done));
4810 return;
4811 }
4813 *ctx.cancelClose = true;
4814 finish(true);
4815 return;
4816 case CommandKind::Retry:
4817 // `retry` is valid only inside a boundary's `error`, which is lowered with its boundary.
4818 for (QueryRuntime* const query : ctx.boundary->queries) {
4819 if (!runtime().untracked([&] { return query->error(); }).isNull()) {
4820 query->refetch();
4821 }
4822 }
4823 finish(true);
4824 return;
4825 case CommandKind::SignIn: {
4826 NameRef const* const found = scope.find(command.target);
4827 Value const reply = found != nullptr && found->category == NameCategory::Mutation
4828 ? runtime().untracked([&] { return found->mutation->result(); })
4829 : Value{};
4830 session.clear();
4831 finish(host != nullptr && host->signIn(reply) ? true : unsupported(command, "`signIn`"));
4832 return;
4833 }
4835 session.clear();
4836 if (host != nullptr) {
4837 host->signOut();
4838 }
4839 finish(true);
4840 return;
4843 default:
4844 runTransfer(command, ctx, std::move(done));
4845 return;
4846 }
4847}
4848
4849// NOLINTNEXTLINE(readability-function-cognitive-complexity): a single run, or a run per list item.
4850inline void ScreenCore::runMutation(Command const& command, CommandCtx const& ctx, Done done) const {
4851 RtScope const& scope = *ctx.scope;
4852 MutationRuntime& mutation = *scope.find(command.target)->mutation;
4853 RtScope const& policy = scope.policyScope();
4854 if (policy.submitted != nullptr) {
4855 policy.submitted->set(true);
4856 }
4857 if (policy.owner != nullptr && !runtime().untracked([&] { return policy.ready(); })) {
4858 done(false);
4859 return;
4860 }
4861 RtScope const& declaring = *[&]() -> RtScope const* {
4862 for (RtScope const* candidate = &scope; candidate != nullptr; candidate = candidate->parent.get()) {
4863 if (candidate->names.contains(command.target)) {
4864 return candidate;
4865 }
4866 }
4867 return &scope;
4868 }();
4869 auto bodyFor = [&](Bindings const& bindings) {
4870 if (command.value) {
4871 return evalIn(scope, &bindings, command.value);
4872 }
4873 if (mutation.decl().body) {
4874 return evalIn(declaring, &bindings, mutation.decl().body);
4875 }
4876 return Value{ValueRecord{}};
4877 };
4878 if (!command.extra) {
4879 Value body = bodyFor(*ctx.bindings);
4880 mutation.run(std::move(body), std::make_shared<Bindings const>(*ctx.bindings),
4881 [bindings = ctx.bindings, bindAs = command.binding, done = std::move(done)](bool succeeded,
4882 Value const& reply) {
4883 if (succeeded && !bindAs.empty()) {
4884 bindings->push_back(Binding{.name = bindAs, .read = [reply] { return reply; }});
4885 }
4886 done(succeeded);
4887 });
4888 return;
4889 }
4890 Value const each = evalIn(scope, ctx.bindings.get(), command.extra);
4891 ValueList const items = each.asList() == nullptr ? ValueList{} : *each.asList();
4892 if (items.empty()) {
4893 done(true);
4894 return;
4895 }
4896 struct Each {
4897 std::size_t left = 0;
4898 bool ok = true;
4899 Done done;
4900 };
4901 auto state = std::make_shared<Each>(Each{.left = items.size(), .ok = true, .done = std::move(done)});
4902 std::vector<Value> bodies;
4903 std::vector<std::shared_ptr<Bindings const>> bound;
4904 for (Value const& item : items) {
4905 Bindings bindings = *ctx.bindings;
4906 if (!command.binding.empty()) {
4907 bindings.push_back(Binding{.name = command.binding, .read = [item] { return item; }});
4908 }
4909 bodies.push_back(bodyFor(bindings));
4910 bound.push_back(std::make_shared<Bindings const>(std::move(bindings)));
4911 }
4912 if (mutation.decl().concurrency != Concurrency::Exclusive) {
4913 for (std::size_t index = 0; index < bodies.size(); ++index) {
4914 mutation.run(bodies.at(index), bound.at(index), [state](bool succeeded, Value const&) {
4915 state->ok = state->ok && succeeded;
4916 if (--state->left == 0) {
4917 state->done(state->ok);
4918 }
4919 });
4920 }
4921 return;
4922 }
4923 // An exclusive mutation refuses a run while another is in flight, so its elements go one after another.
4924 auto step = std::make_shared<std::function<void(std::size_t)>>();
4925 *step = [&mutation, bodies, bound, state,
4926 weakStep = std::weak_ptr<std::function<void(std::size_t)>>{step}](std::size_t index) {
4927 if (index == bodies.size()) {
4928 state->done(state->ok);
4929 return;
4930 }
4931 auto const self = weakStep.lock();
4932 mutation.run(bodies.at(index), bound.at(index), [self, state, index](bool succeeded, Value const&) {
4933 if (!succeeded) {
4934 state->done(false);
4935 return;
4936 }
4937 (*self)(index + 1);
4938 });
4939 };
4940 (*step)(0);
4941}
4942
4943// NOLINTEND(misc-no-recursion)
4944inline void ScreenCore::writeSlot(StateSlot& slot, std::span<std::string const> within, Value value,
4945 Command const& command, Done const& done) {
4946 slot.scope->core.initState(*slot.scope, slot);
4947 Type const& type = slot.decl->type;
4948 if (within.empty() && !type.isAny() && !type.admits(value)) {
4949 report(command.path, "`set` writes a value that is not a " + type.name());
4950 done(false);
4951 return;
4952 }
4953 Value next = withPath(slot.value->peek(), within, std::move(value));
4954 session.noteWrite();
4955 slot.value->set(std::move(next));
4956 done(true);
4957}
4958
4959inline void ScreenCore::runSetApp(Command const& command, std::span<std::string const> rest, Value value,
4960 Done const& done) {
4961 if (RtScope const* const app = session.appScope(); app != nullptr && !rest.empty()) {
4962 NameRef const* const named = app->find(rest.front());
4963 if (named != nullptr && named->category == NameCategory::State) {
4964 writeSlot(*named->state, rest.subspan(1), std::move(value), command, done);
4965 return;
4966 }
4967 report(command.path, "`app." + rest.front() + "` is not state of the app document");
4968 done(false);
4969 return;
4970 }
4971 std::string name;
4972 for (std::string const& segment : rest) {
4973 name += name.empty() ? segment : "." + segment;
4974 }
4975 done(host != nullptr && host->setAppValue(name, value) ? true : unsupported(command, "`set` of `app`"));
4976}
4977
4978inline void ScreenCore::runSet(Command const& command, CommandCtx const& ctx, Done const& done) {
4979 RtScope const& scope = *ctx.scope;
4980 auto path = splitPath(command.target);
4981 if (!path || path->empty()) {
4982 done(false);
4983 return;
4984 }
4985 std::string const head = path->front();
4986 std::span<std::string const> const rest = std::span<std::string const>{*path}.subspan(1);
4987 Value value = evalIn(scope, ctx.bindings.get(), command.value);
4988 if (head == "app") {
4989 runSetApp(command, rest, std::move(value), done);
4990 return;
4991 }
4992 NameRef const* const found = scope.find(head);
4993 if (found != nullptr && found->category == NameCategory::State) {
4994 writeSlot(*found->state, rest, std::move(value), command, done);
4995 return;
4996 }
4997 if (found != nullptr && found->category == NameCategory::Element) {
4998 ElementState& element = *found->element;
4999 session.noteWrite();
5000 element.draft.set(value);
5001 if (element.bound == nullptr) {
5002 element.committed.set(std::move(value));
5003 }
5004 done(true);
5005 return;
5006 }
5007 report(command.path, "`set` names nothing writable here");
5008 done(false);
5009}
5010
5011inline void ScreenCore::runReset(Command const& command, CommandCtx const& ctx, Done const& done) {
5012 RtScope const& scope = *ctx.scope;
5013 NameRef const* const found = scope.find(command.target);
5014 auto resetStates = [this](RtScope const& owner) {
5015 for (auto const& slot : owner.states) {
5016 if (slot->value) {
5017 Value init = runtime().untracked([&] { return evalIn(owner, nullptr, slot->decl->init); });
5018 slot->value->set(std::move(init));
5019 }
5020 }
5021 for (auto const& element : owner.elements) {
5022 element->draft.set(element->bound != nullptr ? runtime().untracked([&] { return element->bound->get(); })
5023 : Value{});
5024 element->committed.set(Value{});
5025 element->edited.set(false);
5026 element->touched.set(false);
5027 }
5028 };
5029 bool known = true;
5030 session.noteWrite();
5031 if (found != nullptr && found->category == NameCategory::State) {
5032 StateSlot& slot = *found->state;
5033 initState(*slot.scope, slot);
5034 slot.value->set(runtime().untracked([&] { return evalIn(*slot.scope, nullptr, slot.decl->init); }));
5035 } else if (found != nullptr && found->category == NameCategory::Element) {
5036 ElementState& element = *found->element;
5037 element.submitted.set(false);
5038 if (RtScope const* const inner = element.inner.peek()) {
5039 resetStates(*inner);
5040 }
5041 } else {
5042 report(command.path, "`reset` names no state or dialog here");
5043 known = false;
5044 }
5045 done(known);
5046}
5047
5048inline void ScreenCore::runHostCommand(Command const& command, CommandCtx const& ctx, Done const& done) {
5049 Value const args = evalIn(*ctx.scope, ctx.bindings.get(), command.value);
5050 if (auto const& run = session.hostCommandRunner()) {
5051 HostCommandResult const result = run(command.target, args);
5052 if (result.kind == HostCommandResult::Kind::Missing) {
5053 report(command.path, "host command `" + command.target + "` is missing");
5054 } else if (result.kind == HostCommandResult::Kind::Refused) {
5055 report(command.path, result.reason);
5056 }
5057 done(result.kind == HostCommandResult::Kind::Ran);
5058 return;
5059 }
5060 bool const known = host != nullptr && host->hostCommand(command.target, args);
5061 if (!known) {
5062 report(command.path, "host command `" + command.target + "` is missing");
5063 }
5064 done(known);
5065}
5066
5067// NOLINTNEXTLINE(readability-function-cognitive-complexity): a download and an upload, each settled.
5068/// @brief A transfer's share of its screen's pending count.
5069///
5070/// A transfer is pending from the host accepting it to its settle: its completion carries on the command list that
5071/// started it, which the close protocol and save-and-close wait for. The count is taken once the host accepts, so a
5072/// refused transfer never counts, and a host that settles before it returns leaves nothing taken. It is given back
5073/// once, also when the transfer settles after its scope is gone, but not after the screen itself is gone, since the
5074/// count went with it.
5076public:
5077 /// @brief A hold that counts nothing yet.
5078 /// @param core The screen whose count it moves.
5079 /// @param alive Expires with the screen.
5080 TransferHold(ScreenCore& core, std::weak_ptr<bool> alive) : _core{&core}, _alive{std::move(alive)} {}
5081
5082 /// @brief Counts the transfer, unless it has already settled.
5083 void take() {
5084 if (_state == State::NotTaken) {
5085 _state = State::Taken;
5086 _core->addPending(1);
5087 }
5088 }
5089
5090 /// @brief Gives the count back, if one was taken and the screen is still there; the transfer counts no more.
5091 void release() {
5092 if (_state == State::Taken && !_alive.expired()) {
5093 _core->addPending(-1);
5094 }
5095 _state = State::Released;
5096 }
5097
5098private:
5099 enum class State : std::uint8_t { NotTaken, Taken, Released };
5100
5101 ScreenCore* _core;
5102 std::weak_ptr<bool> _alive;
5103 State _state = State::NotTaken;
5104};
5105
5106/// @brief Bytes as base64 (RFC 4648, with padding): how a document carries an inline file.
5107/// @param bytes The bytes.
5108/// @return The text.
5109[[nodiscard]] inline std::string base64Encode(std::string_view bytes) {
5110 static constexpr std::string_view kAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5111 std::string out;
5112 out.reserve(((bytes.size() + 2) / 3) * 4);
5113 for (std::size_t index = 0; index < bytes.size(); index += 3) {
5114 std::size_t const left = std::min<std::size_t>(3, bytes.size() - index);
5115 std::uint32_t group = 0;
5116 for (std::size_t offset = 0; offset < 3; ++offset) {
5117 group <<= 8U;
5118 if (offset < left) {
5119 group |= static_cast<unsigned char>(bytes.at(index + offset));
5120 }
5121 }
5122 for (std::size_t digit = 0; digit < 4; ++digit) {
5123 out.push_back(digit <= left ? kAlphabet.at((group >> (18U - (6U * digit))) & 0x3FU) : '=');
5124 }
5125 }
5126 return out;
5127}
5128
5129/// @brief Base64 text as bytes (RFC 4648, with or without padding).
5130/// @param text The text.
5131/// @return The bytes, or `std::nullopt` for text that is not base64.
5132[[nodiscard]] inline std::optional<std::string> base64Decode(std::string_view text) {
5133 while (!text.empty() && text.back() == '=') {
5134 text.remove_suffix(1);
5135 }
5136 if (text.size() % 4 == 1) {
5137 return std::nullopt;
5138 }
5139 auto digitOf = [](char chr) -> int {
5140 if (chr >= 'A' && chr <= 'Z') {
5141 return chr - 'A';
5142 }
5143 if (chr >= 'a' && chr <= 'z') {
5144 return chr - 'a' + 26;
5145 }
5146 if (chr >= '0' && chr <= '9') {
5147 return chr - '0' + 52;
5148 }
5149 if (chr == '+') {
5150 return 62;
5151 }
5152 return chr == '/' ? 63 : -1;
5153 };
5154 std::string out;
5155 out.reserve((text.size() * 3) / 4);
5156 std::uint32_t group = 0;
5157 int bits = 0;
5158 for (char const chr : text) {
5159 int const digit = digitOf(chr);
5160 if (digit < 0) {
5161 return std::nullopt;
5162 }
5163 group = (group << 6U) | static_cast<std::uint32_t>(digit);
5164 bits += 6;
5165 if (bits >= 8) {
5166 bits -= 8;
5167 out.push_back(static_cast<char>((group >> static_cast<std::uint32_t>(bits)) & 0xFFU));
5168 }
5169 }
5170 return out;
5171}
5172
5173/// @brief How many bytes base64 text decodes to, read from its length alone.
5174/// @param text The text.
5175/// @return The byte count.
5176[[nodiscard]] inline std::size_t base64Size(std::string_view text) {
5177 while (!text.empty() && text.back() == '=') {
5178 text.remove_suffix(1);
5179 }
5180 return (text.size() * 3) / 4;
5181}
5182
5183/// @brief A byte count as a person reads it: `512 bytes`, `2.4 KiB`, `1 MiB`.
5184/// @param bytes The count.
5185/// @return The text.
5186[[nodiscard]] inline std::string sizeText(std::size_t bytes) {
5187 constexpr std::size_t kKiB = 1024;
5188 if (bytes < kKiB) {
5189 return std::to_string(bytes) + " bytes";
5190 }
5191 bool const mebi = bytes >= kKiB * kKiB;
5192 std::size_t const unit = mebi ? kKiB * kKiB : kKiB;
5193 // One decimal place, rounded half up, and none when it is zero.
5194 std::size_t const tenths = ((bytes * 10) + (unit / 2)) / unit;
5195 std::string text = std::to_string(tenths / 10);
5196 if (tenths % 10 != 0) {
5197 text += "." + std::to_string(tenths % 10);
5198 }
5199 return text + (mebi ? " MiB" : " KiB");
5200}
5201
5202/// @brief A document's inline file content as bytes: base64 text, or a list of integers 0 to 255.
5203/// @param value The value.
5204/// @return The bytes, or why the value is neither.
5205[[nodiscard]] inline std::expected<std::string, std::string> inlineBytes(Value const& value) {
5206 if (std::string const* const text = value.asString()) {
5207 if (std::optional<std::string> bytes = base64Decode(*text)) {
5208 return *std::move(bytes);
5209 }
5210 return std::unexpected(std::string{"the text is not base64"});
5211 }
5212 if (ValueList const* const list = value.asList()) {
5213 std::string bytes;
5214 bytes.reserve(list->size());
5215 for (Value const& item : *list) {
5216 std::int64_t const* const byte = item.asInt();
5217 if (byte == nullptr || *byte < 0 || *byte > 255) {
5218 return std::unexpected(std::string{"a byte list holds integers 0 to 255"});
5219 }
5220 bytes.push_back(static_cast<char>(static_cast<unsigned char>(*byte)));
5221 }
5222 return bytes;
5223 }
5224 return std::unexpected(std::string{"inline bytes are base64 text or a list of bytes"});
5225}
5226
5227inline void ScreenCore::runTransfer(Command const& command, CommandCtx const& ctx, Done done) {
5228 Value const file = evalIn(*ctx.scope, ctx.bindings.get(), command.value);
5229 if (command.kind == CommandKind::Download) {
5230 runDownload(command, ctx, file, std::move(done));
5231 } else {
5232 runUpload(command, ctx, file, std::move(done));
5233 }
5234}
5235
5236inline void ScreenCore::runDownload(Command const& command, CommandCtx const& ctx, Value const& file, Done done) {
5237 Value const name = evalIn(*ctx.scope, ctx.bindings.get(), command.extra);
5238 auto const hold = std::make_shared<TransferHold>(*this, _alive);
5239 auto const settled = [this, weak = std::weak_ptr<RtScope>{ctx.scope}, target = command.target,
5240 preview = command.option == "preview", done,
5241 hold](std::expected<Value, std::string> const& reply) {
5242 auto const held = weak.lock();
5243 if (!held) {
5244 hold->release();
5245 return;
5246 }
5247 runtime().batch([&] {
5248 bool const succeeded = reply.has_value();
5249 NameRef const* const found = succeeded && preview ? held->find(target) : nullptr;
5250 if (found != nullptr && found->category == NameCategory::State) {
5251 initState(*found->state->scope, *found->state);
5252 session.noteWrite();
5253 found->state->value->set(*reply);
5254 }
5255 done(succeeded);
5256 hold->release();
5257 });
5258 };
5259 if (command.bytes) {
5260 std::optional<InlineFile> const inlined = inlineDownload(command, ctx, file, name);
5261 if (!inlined) {
5262 done(false);
5263 return;
5264 }
5265 if (host == nullptr || !host->saveFile(*inlined, command.option, settled)) {
5266 unsupported(command, "`download`");
5267 done(false);
5268 return;
5269 }
5270 hold->take();
5271 return;
5272 }
5273 if (host == nullptr || !host->download(file, command.option, name, settled)) {
5274 unsupported(command, "`download`");
5275 done(false);
5276 return;
5277 }
5278 hold->take();
5279}
5280
5281// Inline bytes are checked before the host is asked: content over the cap, or that does not decode, never reaches it.
5282inline std::optional<InlineFile> ScreenCore::inlineDownload(
5283 Command const& command, CommandCtx const& ctx,
5284 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): the command's `file` and `name`, in its order.
5285 Value const& file, Value const& name) {
5286 std::size_t const cap = session.options().maxInlineFileBytes;
5287 std::size_t size = 0;
5288 if (std::string const* const text = file.asString()) {
5289 size = base64Size(*text);
5290 } else if (ValueList const* const list = file.asList()) {
5291 size = list->size();
5292 }
5293 std::expected<std::string, std::string> bytes = size > cap ? std::unexpected(std::string{}) : inlineBytes(file);
5294 if (size > cap || (bytes && bytes->size() > cap)) {
5295 report(command.path, "download: " + sizeText(bytes ? bytes->size() : size) + " is over the " + sizeText(cap) +
5296 " inline cap; serve it through the file store");
5297 return std::nullopt;
5298 }
5299 if (!bytes) {
5300 report(command.path, "download: " + bytes.error());
5301 return std::nullopt;
5302 }
5303 return InlineFile{
5304 .name = textOf(name, session.formatting()),
5305 .mediaType = command.mediaType
5306 ? textOf(evalIn(*ctx.scope, ctx.bindings.get(), command.mediaType), session.formatting())
5307 : std::string{},
5308 .bytes = *std::move(bytes)};
5309}
5310
5311// NOLINTNEXTLINE(readability-function-cognitive-complexity): one branch per outcome an upload can have.
5312inline void ScreenCore::runUpload(Command const& command, CommandCtx const& ctx, Value const& file, Done done) {
5313 RtScope const& scope = *ctx.scope;
5314 std::weak_ptr<RtScope> const weak = ctx.scope;
5315 auto const hold = std::make_shared<TransferHold>(*this, _alive);
5316 NameRef const* const transferName = command.transfer.empty() ? nullptr : scope.find(command.transfer);
5317 TransferState* const transfer =
5318 transferName != nullptr && transferName->category == NameCategory::Transfer ? transferName->transfer : nullptr;
5319 auto const progress = [weak, transfer](Value fraction) {
5320 if (weak.lock() && transfer != nullptr) {
5321 transfer->progress.set(std::move(fraction));
5322 }
5323 };
5324 // Settles with the members the mutation's body gets beside its own (the reference or the inline file), or the
5325 // failure.
5326 auto const settled = [this, weak, kept = detached(ctx), transfer, command = &command, done,
5327 hold](std::expected<ValueRecord, std::string> reply) {
5328 auto const held = weak.lock();
5329 if (!held) {
5330 hold->release();
5331 return;
5332 }
5333 runtime().batch([&] {
5334 if (transfer != nullptr) {
5335 transfer->pending.set(false);
5336 transfer->error.set(reply ? Value{} : Value{reply.error()});
5337 }
5338 if (!reply) {
5339 done(false);
5340 hold->release();
5341 return;
5342 }
5343 NameRef const* const found = held->find(command->target);
5344 // The mutation's body cannot name the reference (a body is checked where the mutation is declared),
5345 // so the reference is added to it under `as`, `file` by default.
5346 MutationRuntime& mutation = *found->mutation;
5347 Value const base = evalIn(*held, kept.bindings.get(), mutation.decl().body);
5348 ValueRecord fields = base.asRecord() == nullptr ? ValueRecord{} : *base.asRecord();
5349 for (auto& [member, value] : *reply) {
5350 fields.insert_or_assign(member, std::move(value));
5351 }
5352 // The run takes its own count before this one is given back, so pending never reads zero in between.
5353 mutation.run(Value{std::move(fields)}, std::make_shared<Bindings const>(*kept.bindings),
5354 [done](bool succeeded, Value const&) { done(succeeded); });
5355 hold->release();
5356 });
5357 };
5358 if (host == nullptr) {
5359 unsupported(command, "`upload`");
5360 done(false);
5361 return;
5362 }
5363 if (transfer != nullptr) {
5364 transfer->pending.set(true);
5365 transfer->progress.set(Value{std::int64_t{0}});
5366 transfer->error.set(Value{});
5367 }
5368 NameRef const* const target = scope.find(command.target);
5369 UploadRequest request{.file = file, .modelType = {}, .action = {}};
5370 if (target != nullptr && target->category == NameCategory::Mutation && target->mutation != nullptr) {
5371 request.modelType = target->mutation->typeId();
5372 request.action = target->mutation->decl().action;
5373 }
5374 std::string const member = command.binding.empty() ? std::string{"file"} : command.binding;
5375 bool started = false;
5376 if (command.bytes) {
5377 std::size_t const cap = session.options().maxInlineFileBytes;
5378 started = host->readFile(
5379 file, cap,
5380 [this, settled, progress, cap, member, command = &command](std::expected<InlineFile, std::string> read) {
5381 if (!read) {
5382 settled(std::unexpected(read.error()));
5383 return;
5384 }
5385 if (read->bytes.size() > cap) {
5386 // The host should have refused it; the cap holds whether or not it did.
5387 std::string why = "upload: " + sizeText(read->bytes.size()) + " is over the " + sizeText(cap) +
5388 " inline cap; send it through the file store";
5389 report(command->path, why);
5390 settled(std::unexpected(std::move(why)));
5391 return;
5392 }
5393 progress(Value{std::int64_t{1}});
5394 ValueRecord added;
5395 added.insert_or_assign(member, Value{base64Encode(read->bytes)});
5396 if (!command->nameAs.empty()) {
5397 added.insert_or_assign(command->nameAs, Value{read->name});
5398 }
5399 if (!command->typeAs.empty()) {
5400 added.insert_or_assign(command->typeAs, Value{read->mediaType});
5401 }
5402 settled(std::move(added));
5403 });
5404 } else {
5405 started = host->uploadFor(request, progress, [settled, member](std::expected<Value, std::string> reference) {
5406 if (!reference) {
5407 settled(std::unexpected(reference.error()));
5408 return;
5409 }
5410 ValueRecord added;
5411 added.insert_or_assign(member, *std::move(reference));
5412 settled(std::move(added));
5413 });
5414 }
5415 if (!started) {
5416 if (transfer != nullptr) {
5417 transfer->pending.set(false);
5418 transfer->progress.set(Value{});
5419 }
5420 unsupported(command, "`upload`");
5421 done(false);
5422 return;
5423 }
5424 hold->take();
5425}
5426
5427} // namespace detail
5428
5429namespace detail {
5430
5431// --- Lowering --------------------------------------------------------------------------------------------------
5432
5433/// @brief A value as a `bool` property: `true` only for `true`.
5434/// @param value The value.
5435/// @param format Unused.
5436/// @return It.
5437[[nodiscard]] inline bool boolOf(Value const& value, FormatContext const& format) {
5438 static_cast<void>(format);
5439 bool const* const flag = value.asBool();
5440 return flag != nullptr && *flag;
5441}
5442
5443/// @brief A value as a text property.
5444/// @param value The value.
5445/// @param format The formatting facts.
5446/// @return Its text.
5447[[nodiscard]] inline std::string stringOf(Value const& value, FormatContext const& format) {
5448 return textOf(value, format);
5449}
5450
5451/// @brief A value as a custom component's text prop (spec 5 §10): an `int` or `exact` as plain decimal text, so no
5452/// id or amount loses precision; `json` as its JSON text; `string` as the formatted text.
5453/// @param value The value.
5454/// @param type The prop type the component declares.
5455/// @param format The formatting facts, for `string`.
5456/// @return The text.
5457[[nodiscard]] inline std::string componentText(Value const& value, std::string_view type,
5458 FormatContext const& format) {
5459 if (type == "json") {
5460 return canonicalJson(value);
5461 }
5462 if (type == "int" || type == "exact") {
5463 if (std::int64_t const* const whole = value.asInt()) {
5464 return std::to_string(*whole);
5465 }
5466 if (Decimal const* const number = value.asDecimal()) {
5467 return detail::decimalText(number->value, static_cast<std::uint32_t>(number->value.decimalPlaces.value));
5468 }
5469 if (Quantity const* const quantity = value.asQuantity()) {
5470 return detail::decimalText(quantity->value,
5471 static_cast<std::uint32_t>(quantity->value.decimalPlaces.value)) +
5472 " " + quantity->unit;
5473 }
5474 }
5475 return textOf(value, format);
5476}
5477
5478/// @brief A value as a custom component's `number` prop: the one kind that crosses as a double.
5479/// @param value The value.
5480/// @return It; zero for anything that is not a number.
5481[[nodiscard]] inline double componentNumber(Value const& value) {
5482 if (std::int64_t const* const whole = value.asInt()) {
5483 return static_cast<double>(*whole);
5484 }
5485 if (Decimal const* const number = value.asDecimal()) {
5486 return number->value.toDouble();
5487 }
5488 if (Quantity const* const quantity = value.asQuantity()) {
5489 return quantity->value.toDouble();
5490 }
5491 return 0.0;
5492}
5493
5494/// @brief A custom component's event payload as a value: `i:<int64>` an `int`, `s:<text>` a `string`, anything else
5495/// JSON text read as the event's declared type, and text that is none of those a `string`.
5496/// @param payload The payload, as the backend reports it.
5497/// @param type The payload type the component declares; null for an undeclared event.
5498/// @return The value.
5499[[nodiscard]] inline Value componentPayload(std::string const& payload, Type const* type) {
5500 std::string_view const text{payload};
5501 if (text.starts_with("i:")) {
5502 std::int64_t whole = 0;
5503 std::string_view const digits = text.substr(2);
5504 char const* const last = std::to_address(digits.end());
5505 auto const [end, error] = std::from_chars(std::to_address(digits.begin()), last, whole);
5506 if (error == std::errc{} && end == last) {
5507 return Value{whole};
5508 }
5509 }
5510 if (text.starts_with("s:")) {
5511 return Value{std::string{text.substr(2)}};
5512 }
5513 if (auto json = parseJson(text); json && type != nullptr) {
5514 if (auto value = valueFromJson(*json, *type)) {
5515 return std::move(*value);
5516 }
5517 }
5518 return Value{payload};
5519}
5520
5521/// @brief A value as an integer: an `int`, or a decimal truncated toward zero.
5522/// @param value The value.
5523/// @param fallback What anything else gives.
5524/// @return It.
5525[[nodiscard]] inline std::int64_t intOf(Value const& value, std::int64_t fallback) {
5526 if (std::int64_t const* const number = value.asInt()) {
5527 return *number;
5528 }
5529 if (Decimal const* const number = value.asDecimal()) {
5530 return static_cast<std::int64_t>(number->value.toDouble());
5531 }
5532 return fallback;
5533}
5534
5535/// @brief A text role by name.
5536/// @param name `normal`, `muted`, `heading` (or `title`), `section`, `error`, `success`.
5537/// @return The role; `nullopt` for any other name.
5538[[nodiscard]] inline std::optional<TextRole> roleNamed(std::string_view name) {
5539 if (name == "normal") {
5540 return TextRole::Normal;
5541 }
5542 if (name == "muted") {
5543 return TextRole::Muted;
5544 }
5545 if (name == "heading" || name == "title") {
5546 return TextRole::Heading;
5547 }
5548 if (name == "section") {
5549 return TextRole::Section;
5550 }
5551 if (name == "error") {
5552 return TextRole::Error;
5553 }
5554 if (name == "success") {
5555 return TextRole::Success;
5556 }
5557 return std::nullopt;
5558}
5559
5560/// @brief A text role by name, as `roleNamed` reads it.
5561/// @param value The name.
5562/// @param format Unused.
5563/// @return The role; `Normal` for any other name.
5564[[nodiscard]] inline TextRole roleOf(Value const& value, FormatContext const& format) {
5565 return roleNamed(textOf(value, format)).value_or(TextRole::Normal);
5566}
5567
5568/// @brief A tone by name.
5569/// @param value `neutral`, `info`, `ok` (or `success`), `warn` (or `warning`), `err` (or `error`).
5570/// @param format Unused.
5571/// @return The tone; `Neutral` for anything else.
5572[[nodiscard]] inline Tone toneOf(Value const& value, FormatContext const& format) {
5573 std::string const text = textOf(value, format);
5574 if (text == "info") {
5575 return Tone::Info;
5576 }
5577 if (text == "ok" || text == "success") {
5578 return Tone::Ok;
5579 }
5580 if (text == "warn" || text == "warning") {
5581 return Tone::Warn;
5582 }
5583 if (text == "err" || text == "error") {
5584 return Tone::Err;
5585 }
5586 return Tone::Neutral;
5587}
5588
5589/// @brief A sizing by its document form: `"content"`, `{"fixed": n}` or `{"stretch": w}`.
5590/// @param value The value.
5591/// @return The sizing.
5592[[nodiscard]] inline Sizing sizingOf(Value const& value) {
5593 if (Value const* const fixed = value.field("fixed")) {
5594 return Sizing::fixed(static_cast<int>(intOf(*fixed, 0)));
5595 }
5596 if (Value const* const stretch = value.field("stretch")) {
5597 return Sizing::stretch(static_cast<int>(intOf(*stretch, 1)));
5598 }
5599 return Sizing::content();
5600}
5601
5602/// @brief Whether a value is a sizing of one dimension: `"content"`, `{"fixed": n}` or `{"stretch": w}`.
5603/// @param value The value.
5604/// @return Whether `sizingOf` reads it as written rather than as content by default.
5605[[nodiscard]] inline bool isSizing(Value const& value) {
5606 if (std::string const* const text = value.asString()) {
5607 return *text == "content";
5608 }
5609 ValueRecord const* const record = value.asRecord();
5610 return record != nullptr && record->size() == 1 && (record->contains("fixed") || record->contains("stretch")) &&
5611 record->begin()->second.asInt() != nullptr;
5612}
5613
5614/// @brief An alignment by name, as the loader checked it: `start`, `center`, `end`, or stretch for none.
5615/// @param name The name; empty for none.
5616/// @return The alignment.
5617[[nodiscard]] inline Align alignOf(std::string_view name) {
5618 if (name == "start") {
5619 return Align::Start;
5620 }
5621 if (name == "center") {
5622 return Align::Center;
5623 }
5624 if (name == "end") {
5625 return Align::End;
5626 }
5627 return Align::Stretch;
5628}
5629
5630/// @brief A justification by name, as the loader checked it: `center`, `end`, `between`, or start for none.
5631/// @param name The name; empty for none.
5632/// @return The justification.
5633[[nodiscard]] inline Justify justifyOf(std::string_view name) {
5634 if (name == "center") {
5635 return Justify::Center;
5636 }
5637 if (name == "end") {
5638 return Justify::End;
5639 }
5640 if (name == "between") {
5641 return Justify::Between;
5642 }
5643 return Justify::Start;
5644}
5645
5646/// @brief An axis by name: `horizontal`, or vertical for anything else.
5647/// @param value The value.
5648/// @return The axis.
5649[[nodiscard]] inline Axis axisOf(Value const& value) {
5650 std::string const* const text = value.asString();
5651 return text != nullptr && *text == "horizontal" ? Axis::Horizontal : Axis::Vertical;
5652}
5653
5654/// @brief A step's state by name: `active`, `done`, `failed`, or pending for anything else.
5655/// @param name The name.
5656/// @return The state.
5657[[nodiscard]] inline StepState stepStateOf(std::string_view name) {
5658 if (name == "active") {
5659 return StepState::Active;
5660 }
5661 if (name == "done") {
5662 return StepState::Done;
5663 }
5664 return name == "failed" ? StepState::Failed : StepState::Pending;
5665}
5666
5667/// @brief The options of a select, from a list of scalars or of records with a key and a `label`.
5668/// @param options The list.
5669/// @param format The formatting facts.
5670/// @return The options.
5671[[nodiscard]] inline std::vector<SelectOption> selectOptions(Value const& options, FormatContext const& format) {
5672 std::vector<SelectOption> out;
5673 if (ValueList const* const items = options.asList()) {
5674 for (Value const& option : *items) {
5675 Value const key = optionKey(option);
5676 Value const* const label = option.field("label");
5677 out.push_back(SelectOption{.key = keyOf(key), .label = textOf(label != nullptr ? *label : key, format)});
5678 }
5679 }
5680 return out;
5681}
5682
5683/// @brief Content whose scope is made when it mounts and released when it unmounts: a one-row `forEach` whose
5684/// row view builds it.
5685/// @param build Builds the content, its scope included.
5686/// @return The node.
5687[[nodiscard]] inline Node lazy(std::function<Node()> build) {
5688 return ui::forEach<int>([] { return std::vector<int>{0}; }, [](int const&) { return Key{std::int64_t{0}}; },
5689 [build = std::move(build)](reactive::Signal<int> const&) { return build(); });
5690}
5691
5692/// @brief A column of content that holds its scope for as long as it is mounted: its `visible` is a binding
5693/// that keeps the scope alive and reads nothing.
5694/// @param children The content.
5695/// @param scope The scope.
5696/// @param keys The chords of the content.
5697/// @return The node.
5698[[nodiscard]] inline Node holding(std::vector<Node> children, std::shared_ptr<RtScope> scope,
5699 std::vector<KeyBinding> keys = {}) {
5700 Column column{.children = std::move(children)};
5701 column.common.visible = Prop<bool>{[held = std::move(scope)] { return held != nullptr; }};
5702 column.common.keys = std::move(keys);
5703 return ui::column(std::move(column));
5704}
5705
5706// NOLINTBEGIN(misc-no-recursion): a menu nests, and lowering descends the document.
5707inline Node ScreenCore::lower(DocNode const& node, LowerCtx const& ctx) {
5708 if (recording) {
5709 if (loweredNodes.size() >= kPruneLoweredAt && loweredNodes.size() % kPruneLoweredAt == 0) {
5710 std::erase_if(loweredNodes, [](LoweredNode const& entry) { return entry.scope.expired(); });
5711 }
5712 loweredNodes.push_back(LoweredNode{.node = &node, .scope = ctx.scope, .bindings = ctx.bindings});
5713 }
5714 return lowerKind(node, ctx, elementFor(*ctx.scope, node));
5715}
5716
5717inline Prop<TextRole> ScreenCore::roleProp(LowerCtx const& ctx, ExprPtr const& expr) {
5718 if (!expr || expr->kind == ExprKind::Literal) {
5719 return bindProp<TextRole>(ctx, expr, TextRole::Normal, roleOf);
5720 }
5721 return slotProp<TextRole>([this, scope = ctx.scope, bindings = ctx.bindings, expr] {
5722 Value const value = evalIn(*scope, bindings.get(), expr);
5723 std::optional<TextRole> const role = roleNamed(textOf(value, session.formatting()));
5724 if (!role) {
5725 report(expr->path, "role is " + canonicalJson(value) +
5726 "; a role is normal, muted, heading, section, error or success");
5727 }
5728 return role.value_or(TextRole::Normal);
5729 });
5730}
5731
5732// The size request: `sizing` (the width, or both dimensions), `alignSelf`, and the bounds.
5733inline void ScreenCore::layoutOf(DocNode const& node, LowerCtx const& ctx, LayoutHints& layout) {
5734 if (ExprPtr const sizing = node.prop("sizing")) {
5735 // The width, or both dimensions from a `{width, height}` record, one left out being content. The loader
5736 // refuses a literal of another form; a computed one that is none is reported and lowers to content.
5737 Value const value = once(ctx, sizing);
5738 Value const* const width = value.field("width");
5739 Value const* const height = value.field("height");
5740 bool const record = width != nullptr || height != nullptr;
5741 bool const valid = record ? (width == nullptr || isSizing(*width)) && (height == nullptr || isSizing(*height))
5742 : isSizing(value);
5743 if (!valid) {
5744 report(sizing->path, "sizing is " + canonicalJson(value) +
5745 "; a sizing is \"content\", {\"fixed\": n}, {\"stretch\": w}, or a record of "
5746 "width and height of them");
5747 } else if (record) {
5748 layout.width = width != nullptr ? sizingOf(*width) : Sizing::content();
5749 layout.height = height != nullptr ? sizingOf(*height) : Sizing::content();
5750 } else {
5751 layout.width = sizingOf(value);
5752 }
5753 }
5754 if (std::string_view const placed = node.name("alignSelf"); !placed.empty()) {
5755 layout.alignSelf = alignOf(placed);
5756 }
5757 // Literal integers, checked at load.
5758 for (auto const& [key, bound] :
5759 {std::pair{"minWidth", &LayoutHints::minWidth}, std::pair{"maxWidth", &LayoutHints::maxWidth},
5760 std::pair{"minHeight", &LayoutHints::minHeight}, std::pair{"maxHeight", &LayoutHints::maxHeight}}) {
5761 if (ExprPtr const expr = node.prop(key); expr != nullptr && expr->literal.asInt() != nullptr) {
5762 layout.*bound = static_cast<int>(*expr->literal.asInt());
5763 }
5764 }
5765}
5766
5767inline void ScreenCore::commonOf(DocNode const& node, LowerCtx const& ctx, ElementState* element, Common& common) {
5768 static_cast<void>(element);
5769 common.visible = bindProp<bool>(ctx, node.prop("visible"), true, boolOf);
5770 common.enabled = bindProp<bool>(ctx, node.prop("enabled"), true, boolOf);
5771 common.tooltip = bindProp<std::string>(ctx, node.prop("tooltip"), std::string{}, stringOf);
5772 layoutOf(node, ctx, common.layout);
5773 common.testId = std::string{node.name("testId")};
5774 common.focusId = node.id;
5775 common.surface = std::string{node.name("surface")};
5776 if (ExprPtr const autofocus = node.prop("autofocus")) {
5777 common.autofocus = boolOf(once(ctx, autofocus), session.formatting());
5778 }
5779 for (DocNodePtr const& a11y : node.partList("a11y")) {
5780 common.a11y.name = bindProp<std::string>(ctx, a11y->prop("name"), std::string{}, stringOf);
5781 common.a11y.role = std::string{a11y->name("role")};
5782 }
5783 for (DocNodePtr const& drag : node.partList("drag")) {
5784 ExprPtr const payload = drag->prop("payload");
5785 common.dragKey = slotProp<std::optional<Key>>(
5786 [scope = ctx.scope, bindings = ctx.bindings, payload]() -> std::optional<Key> {
5787 Value const value = evalIn(*scope, bindings.get(), payload);
5788 return value.isNull() ? std::nullopt : std::optional<Key>{keyOf(value)};
5789 });
5790 }
5791 for (DocNodePtr const& drop : node.partList("drop")) {
5792 ExprPtr const accepts = drop->prop("accepts");
5793 common.accepts = [this, scope = ctx.scope, bindings = ctx.bindings, accepts](Key const& key) {
5794 if (!accepts) {
5795 return true;
5796 }
5797 Value const accepted = runtime().untracked([&] { return evalIn(*scope, bindings.get(), accepts); });
5798 if (ValueList const* const items = accepted.asList()) {
5799 return std::ranges::any_of(*items, [&](Value const& item) { return keyOf(item) == key; });
5800 }
5801 return accepted.isNull() || keyOf(accepted) == key;
5802 };
5803 common.onDrop = [this, ctx, commands = drop->event("onDrop")](Key const& key) {
5804 runEvent(commands, ctx, valueOfKey(key));
5805 };
5806 }
5807 for (auto const& [chord, commands] : node.keys) {
5808 common.keys.push_back(
5809 KeyBinding{.chord = chord, .onPress = [this, ctx, list = &commands] { runEvent(list, ctx, Value{}); }});
5810 }
5811}
5812
5813inline void ScreenCore::fieldOf(DocNode const& node, LowerCtx const& ctx, ElementState* element, FieldState& field) {
5814 field.readonly = bindProp<bool>(ctx, node.prop("readonly"), false, boolOf);
5815 field.required = bindProp<bool>(ctx, node.prop("required"), false, boolOf);
5816 field.stale = bindProp<bool>(ctx, node.prop("stale"), false, boolOf);
5817 if (element != nullptr) {
5818 field.errors = slotProp<std::vector<std::string>>(
5819 [this, scope = ctx.scope, element] { return stringsOf(elementValue(*element, "shownErrors")); });
5820 } else if (ExprPtr const errors = node.prop("errors")) {
5821 field.errors = slotProp<std::vector<std::string>>([scope = ctx.scope, bindings = ctx.bindings, errors] {
5822 return stringsOf(evalIn(*scope, bindings.get(), errors));
5823 });
5824 }
5825}
5826
5827inline std::vector<MenuItem> ScreenCore::menuItems(std::span<DocNodePtr const> parts, LowerCtx const& ctx) {
5828 std::vector<MenuItem> items;
5829 for (DocNodePtr const& part : parts) {
5830 MenuItem item;
5831 item.label = bindProp<std::string>(ctx, part->prop("label"), std::string{}, stringOf);
5832 if (Commands const* const commands = part->event("onClick")) {
5833 item.onSelect = [this, ctx, commands] { runEvent(commands, ctx, Value{}); };
5834 }
5835 if (ExprPtr const icon = part->prop("icon")) {
5836 item.icon = textOf(once(ctx, icon), session.formatting());
5837 }
5838 item.keys = std::string{part->name("keys")};
5839 if (ExprPtr const checked = part->prop("checked")) {
5840 item.checked = slotProp<std::optional<bool>>(
5841 [scope = ctx.scope, bindings = ctx.bindings, checked]() -> std::optional<bool> {
5842 Value const value = evalIn(*scope, bindings.get(), checked);
5843 bool const* const flag = value.asBool();
5844 return flag == nullptr ? std::nullopt : std::optional<bool>{*flag};
5845 });
5846 }
5847 item.enabled = bindProp<bool>(ctx, part->prop("enabled"), true, boolOf);
5848 item.items = menuItems(part->partList("items"), ctx);
5849 items.push_back(std::move(item));
5850 }
5851 return items;
5852}
5853
5854inline ActionModels ScreenCore::lowerActionParts(std::span<DocNodePtr const> parts, LowerCtx const& ctx) {
5855 ActionModels models;
5856 models.reserve(parts.size());
5857 for (DocNodePtr const& part : parts) {
5858 ActionModel model{.id = part->id,
5859 .path = part->path,
5860 .label = bindProp<std::string>(ctx, part->prop("label"), std::string{}, stringOf),
5861 .icon = bindProp<std::string>(ctx, part->prop("icon"), std::string{}, stringOf),
5862 .emphasis = ButtonEmphasis::Normal,
5863 .enabled = bindProp<bool>(ctx, part->prop("enabled"), true, boolOf),
5864 .visible = bindProp<bool>(ctx, part->prop("visible"), true, boolOf),
5865 .run = {}};
5866 if (part->name("emphasis") == "primary") {
5867 model.emphasis = ButtonEmphasis::Primary;
5868 } else if (part->name("emphasis") == "danger") {
5869 model.emphasis = ButtonEmphasis::Danger;
5870 } else if (part->name("emphasis") == "ghost") {
5871 model.emphasis = ButtonEmphasis::Ghost;
5872 }
5873 if (Commands const* const commands = part->event("onClick")) {
5874 model.run = [this, ctx, commands] { runEvent(commands, ctx, Value{}); };
5875 }
5876 models.push_back(std::move(model));
5877 }
5878 return models;
5879}
5880
5882 ActionBar bar;
5883 for (ActionModel& action : actions) {
5884 Button button{.label = std::move(action.label),
5885 .emphasis = action.emphasis,
5886 .onClick = std::move(action.run),
5887 .common = {}};
5888 button.common.visible = std::move(action.visible);
5889 button.common.enabled = std::move(action.enabled);
5890 button.common.testId = "action_" + action.id;
5891 bar.children.push_back(ui::button(std::move(button)));
5892 }
5893 return ui::actionBar(std::move(bar));
5894}
5895
5896inline std::vector<BadgeModel> ScreenCore::lowerBadgeParts(std::span<DocNodePtr const> parts, LowerCtx const& ctx) {
5897 std::vector<BadgeModel> badges;
5898 badges.reserve(parts.size());
5899 for (DocNodePtr const& part : parts) {
5900 badges.push_back(BadgeModel{.text = bindProp<std::string>(ctx, part->prop("text"), std::string{}, stringOf),
5901 .tone = bindProp<Tone>(ctx, part->prop("tone"), Tone::Neutral, toneOf),
5902 .icon = bindProp<std::string>(ctx, part->prop("icon"), std::string{}, stringOf),
5903 .visible = bindProp<bool>(ctx, part->prop("visible"), true, boolOf)});
5904 }
5905 return badges;
5906}
5907
5908// NOLINTNEXTLINE(readability-function-cognitive-complexity): one case per kind of ui/1.
5909inline Node ScreenCore::lowerKind(DocNode const& node, LowerCtx const& ctx, ElementState* element) {
5910 std::string_view const kind = node.kind;
5911 FormatContext const& format = session.formatting();
5912 auto children = [&](std::string_view key) {
5913 std::vector<Node> out;
5914 for (DocNodePtr const& child : node.nodes(key)) {
5915 out.push_back(lower(*child, ctx));
5916 }
5917 return out;
5918 };
5919 auto child = [&](std::string_view key) -> Node {
5920 auto const nodes = node.nodes(key);
5921 return nodes.empty() ? Node{} : lowerAll(nodes, ctx);
5922 };
5923 auto onceInt = [&](std::string_view key, std::int64_t fallback) {
5924 ExprPtr const expr = node.prop(key);
5925 return expr ? intOf(once(ctx, expr), fallback) : fallback;
5926 };
5927 auto onceBool = [&](std::string_view key, bool fallback) {
5928 ExprPtr const expr = node.prop(key);
5929 return expr ? boolOf(once(ctx, expr), format) : fallback;
5930 };
5931 auto onceText = [&](std::string_view key) {
5932 ExprPtr const expr = node.prop(key);
5933 return expr ? textOf(once(ctx, expr), format) : std::string{};
5934 };
5935 auto textProp = [&](std::string_view key) {
5936 return bindProp<std::string>(ctx, node.prop(key), std::string{}, stringOf);
5937 };
5938 // A field shows no caption of its own, so its label is what names it to assistive technology. An `a11y` part
5939 // that gives a name was written for that purpose and wins; one that gives only a role leaves the label.
5940 auto nameByLabel = [&](Common& common) {
5941 bool const named = std::ranges::any_of(node.partList("a11y"),
5942 [](DocNodePtr const& a11y) { return a11y->prop("name") != nullptr; });
5943 if (node.prop("label") && !named) {
5944 common.a11y.name = textProp("label");
5945 }
5946 };
5947 // A standalone input draws its label as a caption only when the document asks for it (`showLabel`).
5948 auto captionOf = [&](auto& spec) {
5949 if (node.prop("label")) {
5950 spec.label = textProp("label");
5951 }
5952 spec.showLabel = node.flag("showLabel");
5953 };
5954 auto action = [&](std::string_view key) -> Action {
5955 Commands const* const commands = node.event(key);
5956 if (commands == nullptr) {
5957 return {};
5958 }
5959 return [this, ctx, commands] { runEvent(commands, ctx, Value{}); };
5960 };
5961 auto actionPart = [&]() {
5963 for (DocNodePtr const& part : node.partList("action")) {
5964 button.label = bindProp<std::string>(ctx, part->prop("label"), std::string{}, stringOf);
5965 if (Commands const* const commands = part->event("onClick")) {
5966 button.onClick = [this, ctx, commands] { runEvent(commands, ctx, Value{}); };
5967 }
5968 }
5969 return button;
5970 };
5971 auto read = [element, this](std::string_view name) {
5972 return [element, this, name = std::string{name}] { return elementValue(*element, name); };
5973 };
5974
5975 if (kind == "text") {
5976 Text spec{.text = textProp("text"), .role = roleProp(ctx, node.prop("role"))};
5977 fieldOf(node, ctx, element, spec.field);
5978 commonOf(node, ctx, element, spec.common);
5979 return ui::text(std::move(spec));
5980 }
5981 if (kind == "button") {
5982 Button spec{.label = textProp("label")};
5983 spec.icon = onceText("icon");
5984 if (node.name("emphasis") == "primary") {
5985 spec.emphasis = ButtonEmphasis::Primary;
5986 } else if (node.name("emphasis") == "danger") {
5987 spec.emphasis = ButtonEmphasis::Danger;
5988 } else if (node.name("emphasis") == "ghost") {
5989 spec.emphasis = ButtonEmphasis::Ghost;
5990 }
5991 if (node.name("style") == "item") {
5992 spec.style = ButtonStyle::Item;
5993 spec.selected = bindProp<bool>(ctx, node.prop("selected"), false, boolOf);
5994 }
5995 Commands const* const onClick = node.event("onClick");
5996 Commands const* const onActivate = node.event("onActivate");
5997 if (onClick != nullptr || onActivate != nullptr) {
5998 spec.onClick = [this, ctx, onClick, onActivate] {
5999 runEvent(onClick, ctx, Value{});
6000 runEvent(onActivate, ctx, Value{});
6001 };
6002 }
6003 commonOf(node, ctx, element, spec.common);
6004 return ui::button(std::move(spec));
6005 }
6006 if (kind == "textInput") {
6007 std::string const commitOn = node.name("commitOn").empty() ? "change" : std::string{node.name("commitOn")};
6008 auto commit = [this, ctx, element, commitOn, onChange = node.event("onChange")](std::string const& when,
6009 std::string const& text) {
6010 if (commitOn != when) {
6011 return;
6012 }
6013 if (element->bound == nullptr) {
6014 session.noteWrite();
6015 element->committed.set(Value{text});
6016 }
6017 runEvent(onChange, ctx, Value{text});
6018 };
6019 TextInput spec;
6020 spec.value =
6021 slotProp<std::string>([this, element] { return textOf(element->draft.get(), session.formatting()); });
6022 spec.onChange = [this, element, commit](std::string text) {
6023 session.asUser([&] {
6024 session.noteWrite();
6025 element->draft.set(Value{text});
6026 element->edited.set(true);
6027 });
6028 commit("change", text);
6029 };
6030 spec.onCommit = [this, ctx, element, commit, onCommit = node.event("onCommit")](std::string const& text) {
6031 element->touched.set(true);
6032 commit("blur", text);
6033 runEvent(onCommit, ctx, Value{text});
6034 };
6035 spec.onSubmit = [this, ctx, commit, onSubmit = node.event("onSubmit")](std::string const& text) {
6036 commit("enter", text);
6037 runEvent(onSubmit, ctx, Value{text});
6038 };
6039 spec.placeholder = textProp("placeholder");
6040 std::string const mode = onceText("mode");
6041 spec.mode = TextInputMode::SingleLine;
6042 if (mode == "multiline") {
6043 spec.mode = TextInputMode::Multiline;
6044 } else if (mode == "password") {
6045 spec.mode = TextInputMode::Password;
6046 }
6047 spec.icon = onceText("icon");
6048 fieldOf(node, ctx, element, spec.field);
6049 commonOf(node, ctx, element, spec.common);
6050 nameByLabel(spec.common);
6051 captionOf(spec);
6052 return ui::textInput(std::move(spec));
6053 }
6054 if (kind == "checkbox") {
6055 Checkbox spec{.label = textProp("label"), .checked = slotProp<bool>([read = read("checked")] {
6056 Value const value = read();
6057 bool const* const flag = value.asBool();
6058 return flag != nullptr && *flag;
6059 })};
6060 spec.onToggle = [this, ctx, element, onToggle = node.event("onToggle"),
6061 onChange = node.event("onChange")](bool checked) {
6062 if (element->bound == nullptr) {
6063 session.asUser([&] {
6064 session.noteWrite();
6065 element->draft.set(checked);
6066 });
6067 }
6068 runEvent(onToggle, ctx, Value{checked});
6069 runEvent(onChange, ctx, Value{checked});
6070 };
6071 fieldOf(node, ctx, element, spec.field);
6072 commonOf(node, ctx, element, spec.common);
6073 return ui::checkbox(std::move(spec));
6074 }
6075 if (kind == "select") {
6076 Select spec;
6077 spec.options = slotProp<std::vector<SelectOption>>(
6078 [this, element] { return selectOptions(element->rows->get(), session.formatting()); });
6079 spec.selected = slotProp<std::optional<Key>>([read = read("selected")]() -> std::optional<Key> {
6080 Value const selected = read();
6081 return selected.isNull() ? std::nullopt : std::optional<Key>{keyOf(selected)};
6082 });
6083 spec.onSelect = [this, ctx, element, onChoose = node.event("onChoose"),
6084 onChange = node.event("onChange")](Key const& key) {
6085 Value const chosen = valueOfKey(key);
6086 if (element->bound == nullptr) {
6087 session.asUser([&] {
6088 session.noteWrite();
6089 element->draft.set(chosen);
6090 });
6091 }
6092 runEvent(onChoose, ctx, chosen);
6093 runEvent(onChange, ctx, chosen);
6094 };
6095 spec.style = onceText("style") == "radio" ? SelectStyle::Radio : SelectStyle::Dropdown;
6096 fieldOf(node, ctx, element, spec.field);
6097 commonOf(node, ctx, element, spec.common);
6098 nameByLabel(spec.common);
6099 captionOf(spec);
6100 return ui::select(std::move(spec));
6101 }
6102 if (kind == "slider") {
6103 Slider spec;
6104 spec.value = slotProp<std::int64_t>([read = read("value"), element] {
6105 return intOf(element->bound != nullptr ? read() : element->draft.get(), 0);
6106 });
6107 spec.minimum = onceInt("min", 0);
6108 spec.maximum = onceInt("max", 100);
6109 spec.step = onceInt("step", 1);
6110 spec.onChange = [this, ctx, element, onChange = node.event("onChange")](std::int64_t value) {
6111 session.asUser([&] {
6112 session.noteWrite();
6113 element->draft.set(value);
6114 element->edited.set(true);
6115 if (element->bound == nullptr) {
6116 element->committed.set(value);
6117 }
6118 });
6119 runEvent(onChange, ctx, Value{value});
6120 };
6121 spec.onCommit = [this, ctx, element, onCommit = node.event("onCommit")](std::int64_t value) {
6122 element->touched.set(true);
6123 runEvent(onCommit, ctx, Value{value});
6124 };
6125 fieldOf(node, ctx, element, spec.field);
6126 commonOf(node, ctx, element, spec.common);
6127 nameByLabel(spec.common);
6128 return ui::slider(std::move(spec));
6129 }
6130 if (kind == "dateTimeInput") {
6131 DateTimeInput spec;
6132 DateMode const mode = onceText("mode") == "date" ? DateMode::Date : DateMode::DateTime;
6133 std::optional<TypeKind> heldAtLoad;
6134 if (auto const found = _doc->dateValueKinds.find(&node); found != _doc->dateValueKinds.end()) {
6135 heldAtLoad = found->second;
6136 }
6137 std::string const valuePath = pointerAppend(node.path, "value");
6138 std::int32_t const offsetMinutes = format.utcOffsetMinutes;
6139 spec.value = slotProp<std::optional<time::Timestamp>>([this, element, offsetMinutes,
6140 valuePath]() -> std::optional<time::Timestamp> {
6141 DateReading const reading = dateInstantOf(element->draft.get(), offsetMinutes);
6142 if (!reading.readable) {
6143 report(valuePath, "`dateTimeInput` shows a timestamp or an ISO-8601 date or date-time string");
6144 }
6145 return reading.instant ? std::optional<time::Timestamp>{time::Timestamp{*reading.instant}} : std::nullopt;
6146 });
6147 spec.onChange = [this, ctx, element, heldAtLoad, mode, offsetMinutes, onChange = node.event("onChange"),
6148 onCommit = node.event("onCommit")](std::optional<time::Timestamp> value) {
6149 bool asString = element->bound != nullptr && element->bound->get().asString() != nullptr;
6150 if (heldAtLoad) {
6151 asString = *heldAtLoad == TypeKind::String;
6152 }
6153 std::optional<time::DateTime> const instant = value ? value->value : std::nullopt;
6154 Value entered;
6155 if (!asString) {
6156 if (instant) {
6157 entered = Value{*instant};
6158 }
6159 } else if (!instant) {
6160 entered = Value{std::string{}};
6161 } else if (mode == DateMode::Date) {
6162 entered = Value{dateTextOf(*instant, offsetMinutes)};
6163 } else {
6164 entered = Value{instant->toIso8601()};
6165 }
6166 element->touched.set(true);
6167 session.asUser([&] {
6168 session.noteWrite();
6169 element->draft.set(entered);
6170 element->edited.set(true);
6171 if (element->bound == nullptr) {
6172 element->committed.set(entered);
6173 }
6174 });
6175 runEvent(onChange, ctx, entered);
6176 runEvent(onCommit, ctx, entered);
6177 };
6178 spec.mode = mode;
6179 spec.offsetMinutes = offsetMinutes;
6180 fieldOf(node, ctx, element, spec.field);
6181 commonOf(node, ctx, element, spec.common);
6182 nameByLabel(spec.common);
6183 captionOf(spec);
6184 return ui::dateTimeInput(std::move(spec));
6185 }
6186 if (kind == "filePicker") {
6187 FilePicker spec;
6188 spec.path =
6189 slotProp<std::string>([this, element] { return textOf(element->draft.get(), session.formatting()); });
6190 spec.onPicked = [this, ctx, element, onPick = node.event("onPick")](std::string path) {
6191 session.asUser([&] {
6192 session.noteWrite();
6193 element->draft.set(Value{path});
6194 element->committed.set(Value{path});
6195 });
6196 runEvent(onPick, ctx, Value{path});
6197 };
6198 spec.mode = onceText("mode") == "save" ? FilePickerMode::Save : FilePickerMode::Open;
6199 fieldOf(node, ctx, element, spec.field);
6200 commonOf(node, ctx, element, spec.common);
6201 nameByLabel(spec.common);
6202 return ui::filePicker(std::move(spec));
6203 }
6204 if (kind == "menu") {
6205 Menu spec{.items = menuItems(node.partList("items"), ctx)};
6206 commonOf(node, ctx, element, spec.common);
6207 return ui::menu(std::move(spec));
6208 }
6209 if (kind == "column" || kind == "row") {
6210 int const gap = static_cast<int>(onceInt("gap", 0));
6211 if (std::string const arrangement = arrangedBy(node.id); !arrangement.empty()) {
6212 return lowerArranged(node, ctx, element, arrangement, kind == "column" ? Axis::Vertical : Axis::Horizontal,
6213 gap);
6214 }
6215 if (kind == "column") {
6216 Column spec{.children = children("children"),
6217 .gap = gap,
6218 .align = alignOf(node.name("align")),
6219 .justify = justifyOf(node.name("justify"))};
6220 commonOf(node, ctx, element, spec.common);
6221 return ui::column(std::move(spec));
6222 }
6223 Row spec{.children = children("children"),
6224 .gap = gap,
6225 .align = alignOf(node.name("align")),
6226 .justify = justifyOf(node.name("justify"))};
6227 commonOf(node, ctx, element, spec.common);
6228 return ui::row(std::move(spec));
6229 }
6230 if (kind == "grid") {
6231 Grid spec{.columns = static_cast<int>(onceInt("columns", 1)),
6232 .gap = static_cast<int>(onceInt("gap", 0)),
6233 .align = alignOf(node.name("align"))};
6234 for (DocNodePtr const& cell : node.nodes("children")) {
6235 ExprPtr const span = cell->prop("span");
6236 spec.cells.push_back(
6237 GridCell{.node = lower(*cell, ctx), .span = static_cast<int>(span ? intOf(once(ctx, span), 1) : 1)});
6238 }
6239 commonOf(node, ctx, element, spec.common);
6240 return ui::grid(std::move(spec));
6241 }
6242 if (kind == "spacer") {
6243 Spacer spec;
6244 commonOf(node, ctx, element, spec.common);
6245 return ui::spacer(std::move(spec));
6246 }
6247 if (kind == "panel") {
6248 Panel spec{.title = textProp("title"), .padding = static_cast<int>(onceInt("padding", 0))};
6249 spec.header = child("header");
6250 spec.child = child("children");
6251 spec.collapsible = onceBool("collapsible", false);
6252 spec.collapsed = bindProp<bool>(ctx, node.prop("collapsed"), false, boolOf);
6253 if (Commands const* const commands = node.event("onToggle")) {
6254 spec.onToggle = [this, ctx, commands](bool collapsed) { runEvent(commands, ctx, Value{collapsed}); };
6255 }
6256 commonOf(node, ctx, element, spec.common);
6257 return ui::panel(std::move(spec));
6258 }
6259 if (kind == "scroll") {
6260 ExprPtr const axis = node.prop("axis");
6261 Scroll spec{.child = child("children"), .axis = axis ? axisOf(once(ctx, axis)) : Axis::Vertical};
6262 commonOf(node, ctx, element, spec.common);
6263 return ui::scroll(std::move(spec));
6264 }
6265 if (kind == "switch") {
6266 return lowerSwitch(node, ctx);
6267 }
6268 if (kind == "tabs") {
6269 Tabs spec;
6270 for (DocNodePtr const& tab : node.partList("tabs")) {
6271 ExprPtr const label = tab->prop("label");
6272 auto const nodes = tab->nodes("children");
6273 spec.tabs.push_back(Tab{.label = label ? textOf(once(ctx, label), format) : std::string{},
6274 .node = nodes.empty() ? Node{} : lowerAll(nodes, ctx)});
6275 }
6276 // A document names no index, so the node keeps the user's pick itself: a bound index is the controlled one,
6277 // whose page follows the pick, where a constant would move only the bar's highlight.
6278 auto const picked = std::make_shared<reactive::Signal<std::size_t>>(runtime(), std::size_t{0});
6279 spec.selected = [picked] { return picked->get(); };
6280 spec.onSelect = [picked](std::size_t index) { picked->set(index); };
6281 commonOf(node, ctx, element, spec.common);
6282 return ui::tabs(std::move(spec));
6283 }
6284 if (kind == "dialog" || kind == "drawer") {
6285 return lowerDialog(node, ctx, element);
6286 }
6287 if (kind == "busy") {
6288 Busy spec{.active = bindProp<bool>(ctx, node.prop("active"), false, boolOf), .label = textProp("label")};
6289 commonOf(node, ctx, element, spec.common);
6290 return ui::busy(std::move(spec));
6291 }
6292 if (kind == "forEach" || kind == "table") {
6293 return lowerRows(node, ctx, element);
6294 }
6295 if (kind == "banner") {
6296 Banner spec{.tone = bindProp<Tone>(ctx, node.prop("tone"), Tone::Info, toneOf), .text = textProp("text")};
6297 spec.action = actionPart();
6298 spec.dismissible = onceBool("dismissible", false);
6299 spec.onDismiss = action("onDismiss");
6300 commonOf(node, ctx, element, spec.common);
6301 return ui::banner(std::move(spec));
6302 }
6303 if (kind == "badge") {
6304 Badge spec{.tone = bindProp<Tone>(ctx, node.prop("tone"), Tone::Neutral, toneOf), .text = textProp("text")};
6305 spec.icon = onceText("icon");
6306 commonOf(node, ctx, element, spec.common);
6307 return ui::badge(std::move(spec));
6308 }
6309 if (kind == "progress") {
6310 Progress spec;
6311 spec.value = slotProp<std::optional<double>>(
6312 [scope = ctx.scope, bindings = ctx.bindings, expr = node.prop("value")]() -> std::optional<double> {
6313 Value const value = evalIn(*scope, bindings.get(), expr);
6314 if (std::int64_t const* const number = value.asInt()) {
6315 return static_cast<double>(*number);
6316 }
6317 if (Decimal const* const number = value.asDecimal()) {
6318 return number->value.toDouble();
6319 }
6320 return std::nullopt;
6321 });
6322 spec.label = textProp("label");
6323 commonOf(node, ctx, element, spec.common);
6324 return ui::progress(std::move(spec));
6325 }
6326 if (kind == "steps") {
6327 Steps spec;
6328 spec.items =
6329 slotProp<std::vector<Step>>([this, scope = ctx.scope, bindings = ctx.bindings, expr = node.prop("items")] {
6330 std::vector<Step> steps;
6331 Value const items = evalIn(*scope, bindings.get(), expr);
6332 if (ValueList const* const list = items.asList()) {
6333 for (Value const& item : *list) {
6334 Value const* const label = item.field("label");
6335 Value const* const state = item.field("state");
6336 std::string const name = state == nullptr ? std::string{} : textOf(*state, {});
6337 steps.push_back(
6338 Step{.label = label == nullptr ? std::string{} : textOf(*label, session.formatting()),
6339 .state = stepStateOf(name)});
6340 }
6341 }
6342 return steps;
6343 });
6344 spec.current = slotProp<std::optional<std::size_t>>(
6345 [scope = ctx.scope, bindings = ctx.bindings, expr = node.prop("current")]() -> std::optional<std::size_t> {
6346 std::int64_t const index = intOf(evalIn(*scope, bindings.get(), expr), -1);
6347 return index < 0 ? std::nullopt : std::optional<std::size_t>{static_cast<std::size_t>(index)};
6348 });
6349 commonOf(node, ctx, element, spec.common);
6350 return ui::steps(std::move(spec));
6351 }
6352 if (kind == "keyValue") {
6353 KeyValue spec;
6354 spec.items = slotProp<std::vector<KeyValueItem>>(
6355 [this, scope = ctx.scope, bindings = ctx.bindings, expr = node.prop("items")] {
6356 std::vector<KeyValueItem> out;
6357 Value const items = evalIn(*scope, bindings.get(), expr);
6358 if (ValueList const* const list = items.asList()) {
6359 for (Value const& item : *list) {
6360 Value const* const label = item.field("label");
6361 Value const* const value = item.field("value");
6362 out.push_back(KeyValueItem{
6363 .label = label == nullptr ? std::string{} : textOf(*label, session.formatting()),
6364 .value = value == nullptr ? std::string{} : textOf(*value, session.formatting())});
6365 }
6366 }
6367 return out;
6368 });
6369 commonOf(node, ctx, element, spec.common);
6370 return ui::keyValue(std::move(spec));
6371 }
6372 if (kind == "emptyState") {
6373 EmptyState spec{.title = textProp("title"), .text = textProp("text")};
6374 spec.icon = onceText("icon");
6375 spec.action = actionPart();
6376 commonOf(node, ctx, element, spec.common);
6377 return ui::emptyState(std::move(spec));
6378 }
6379 if (kind == "splitter") {
6380 ExprPtr const orientation = node.prop("orientation");
6381 Splitter spec{.children = children("children"),
6382 .orientation = orientation && axisOf(once(ctx, orientation)) == Axis::Vertical
6385 commonOf(node, ctx, element, spec.common);
6386 return ui::splitter(std::move(spec));
6387 }
6388 if (kind == "collapsible") {
6389 Collapsible spec{.title = textProp("title"), .open = bindProp<bool>(ctx, node.prop("open"), true, boolOf)};
6390 if (Commands const* const commands = node.event("onToggle")) {
6391 spec.onToggle = [this, ctx, commands](bool open) { runEvent(commands, ctx, Value{open}); };
6392 }
6393 spec.header = child("header");
6394 spec.child = child("children");
6395 commonOf(node, ctx, element, spec.common);
6396 return ui::collapsible(std::move(spec));
6397 }
6398 if (kind == "dropZone") {
6399 DropZone spec;
6400 spec.accept = stringsOf(node.prop("accept") ? once(ctx, node.prop("accept")) : Value{});
6401 spec.multiple = onceBool("multiple", false);
6402 if (Commands const* const commands = node.event("onDrop")) {
6403 spec.onDrop = [this, ctx, commands](std::vector<std::string> const& paths) {
6404 runEvent(commands, ctx, listOfStrings(paths));
6405 };
6406 }
6407 spec.child = child("children");
6408 commonOf(node, ctx, element, spec.common);
6409 return ui::dropZone(std::move(spec));
6410 }
6411 if (kind == "boundary") {
6412 return lowerBoundary(node, ctx);
6413 }
6414 if (kind == "custom") {
6415 return lowerCustom(node, ctx);
6416 }
6417 if (kind == "customize") {
6418 return lowerCustomize(node, ctx);
6419 }
6420 if (kind == "form") {
6421 return lowerForm(node, ctx, element);
6422 }
6423 if (kind == "collection" || kind == "wizard") {
6424 return lowerComposite(node, ctx, element);
6425 }
6426 report(node.path, "`" + node.kind + "` is a node kind this interpreter does not lower");
6427 return ui::text(Text{.text = std::string{node.kind}, .role = TextRole::Muted});
6428}
6429
6430/// @brief A document field overlay's expressions, evaluated once, as the engine's overlay.
6431/// @param scope The scope the form is declared in.
6432/// @param part The `fields` entry.
6433/// @param format How values read as text and numbers.
6434/// @return The overlay.
6435[[nodiscard]] inline forms::FieldOverlay overlayOf(RtScope const& scope, DocNode const& part,
6436 FormatContext const& format) {
6437 auto const read = [&](std::string_view key) -> std::optional<Value> {
6438 ExprPtr const expr = part.prop(key);
6439 if (!expr) {
6440 return std::nullopt;
6441 }
6442 return scope.core.runtime().untracked([&] { return evalIn(scope, nullptr, expr); });
6443 };
6444 auto const text = [&](std::string_view key) -> std::optional<std::string> {
6445 auto const value = read(key);
6446 return value.has_value() && !value->isNull() ? std::optional<std::string>{textOf(*value, format)}
6447 : std::nullopt;
6448 };
6449 auto const flag = [&](std::string_view key) {
6450 auto const value = read(key);
6451 bool const* const set = value.has_value() ? value->asBool() : nullptr;
6452 return set != nullptr && *set;
6453 };
6454 forms::FieldOverlay overlay{.omit = flag("omit"), .hidden = flag("hidden")};
6455 overlay.label = text("label");
6456 overlay.unit = text("unit");
6457 overlay.widget = text("widget");
6458 if (auto const decimals = read("decimals");
6459 decimals.has_value() && decimals->asInt() != nullptr && *decimals->asInt() >= 0) {
6460 overlay.decimals = static_cast<std::uint32_t>(std::min<std::int64_t>(*decimals->asInt(), 18));
6461 }
6462 if (auto const blankAs = text("blankAs")) {
6463 overlay.blankAs = *blankAs == "empty" ? forms::BlankAs::Empty : forms::BlankAs::Omit;
6464 }
6465 return overlay;
6466}
6467
6468/// @brief A lowering context whose scope is held weakly: what an Effect the scope itself owns may keep, so the scope
6469/// does not keep itself alive.
6471 /// @brief The scope, weakly.
6472 std::weak_ptr<RtScope> scope;
6473 /// @brief The names bound there.
6474 std::shared_ptr<Bindings const> bindings;
6475 /// @brief The innermost boundary.
6476 std::shared_ptr<BoundaryRuntime> boundary;
6477};
6478
6479/// @brief Detaches a lowering context's scope.
6480/// @param ctx The context.
6481/// @return The context, its scope held weakly.
6482[[nodiscard]] inline DetachedLower detachedLower(LowerCtx const& ctx) {
6483 return DetachedLower{.scope = ctx.scope, .bindings = ctx.bindings, .boundary = ctx.boundary};
6484}
6485
6486/// @brief Puts a detached context's scope back.
6487/// @param held The detached context.
6488/// @return The context; its scope is null once the scope is gone.
6489[[nodiscard]] inline LowerCtx attached(DetachedLower const& held) {
6490 return LowerCtx{.scope = held.scope.lock(), .bindings = held.bindings, .boundary = held.boundary};
6491}
6492
6493// Builds the form's session when its element is declared, so its values can be read before the form is drawn.
6494inline void ScreenCore::wireForm(ElementState& element) {
6495 DocNode const& node = *element.node;
6496 RtScope& scope = *element.scope;
6497 std::string const action{node.name("action")};
6498 Catalog const& catalog = session.options().catalog;
6499 std::optional<std::string> const schema = catalog.schema ? catalog.schema(action) : std::nullopt;
6500 if (!schema.has_value()) {
6501 report(node.path, "no schema for `" + action + "`: the catalog has none for a `form` to build from");
6502 return;
6503 }
6504 NameRef const* const alias = scope.find(node.name("model"));
6505 if (alias == nullptr || alias->model == nullptr) {
6506 report(node.path, "`" + std::string{node.name("model")} + "` is not a model alias");
6507 return;
6508 }
6509 forms::FieldOverlays overlays;
6510 // `fields` is one record part holding a `field` part per overlaid member.
6511 for (DocNodePtr const& record : node.partList("fields")) {
6512 for (DocNodePtr const& part : record->partList("fields")) {
6513 overlays.insert_or_assign(part->id, overlayOf(scope, *part, session.formatting()));
6514 }
6515 }
6516 auto model = forms::FormModel::fromSchema(action, *schema, overlays);
6517 if (!model) {
6518 report(node.path,
6519 "the form of `" + action + "` does not build: " + model.error().path + ": " + model.error().message);
6520 return;
6521 }
6522 if (node.name("submit") == "explicit") {
6523 model = model->withSubmitMode(forms::SubmitMode::Explicit);
6524 } else if (node.name("submit") == "auto") {
6525 model = model->withSubmitMode(forms::SubmitMode::Automatic);
6526 }
6527 element.form = std::make_unique<forms::FormSession>(runtime(), *std::move(model), aliasSubmits(alias->model),
6528 aliasCalls(alias->model), engineOptions());
6529 if (ExprPtr const prefill = node.prop("prefill")) {
6530 // A prefill follows its expression. A value of the record being edited (the first value, or one under the
6531 // same `key`) keeps the fields the user changed; another record, a new `key`, or any later value of a form
6532 // without one, loads every field.
6533 forms::FormSession* const form = element.form.get();
6534 struct Followed {
6535 bool loaded = false;
6536 Value key;
6537 };
6538 auto const followed = std::make_shared<Followed>();
6539 scope.nodes.effect([this, &scope, prefill, key = node.prop("key"), form, followed] {
6540 Value const value = evalIn(scope, nullptr, prefill);
6541 if (value.isNull()) {
6542 return;
6543 }
6544 runtime().untracked([&] {
6545 Value const current = key ? evalIn(scope, nullptr, key) : Value{};
6546 bool const same = !followed->loaded || (key && current == followed->key);
6547 followed->loaded = true;
6548 followed->key = current;
6549 std::string const json = glz::write_json(valueToJson(value)).value_or(std::string{});
6550 if (same) {
6551 form->refill(json);
6552 } else {
6553 form->prefill(json);
6554 }
6555 });
6556 });
6557 }
6558}
6559
6560inline Node ScreenCore::lowerForm(DocNode const& node, LowerCtx const& ctx, ElementState* element) {
6561 forms::FormSession* const form = element == nullptr ? nullptr : element->form.get();
6562 if (form == nullptr) {
6563 return ui::text(Text{.text = std::string{node.name("action")}, .role = TextRole::Muted});
6564 }
6565 // The form's events, each run once per change of the count it watches.
6566 auto const watch = [this, &ctx](Commands const* commands, auto count, auto payload) {
6567 if (commands == nullptr || commands->empty()) {
6568 return;
6569 }
6570 auto const seen = std::make_shared<std::uint64_t>(runtime().untracked(count));
6571 ctx.scope->nodes.effect([this, held = detachedLower(ctx), commands, count, payload, seen] {
6572 std::uint64_t const now = count();
6573 if (now == *seen) {
6574 return;
6575 }
6576 *seen = now;
6577 runtime().untracked([&] { runEvent(commands, attached(held), payload()); });
6578 });
6579 };
6580 watch(
6581 node.event("onSubmit"), [form] { return form->submitAttempts(); },
6582 [form] {
6583 std::optional<std::string> const body = form->body();
6584 return body.has_value() ? jsonTextValue(*body) : Value{};
6585 });
6586 watch(
6587 node.event("onSuccess"), [form] { return form->successCount(); },
6588 [form] {
6589 std::optional<std::string> const& reply = form->lastReply();
6590 return reply.has_value() ? jsonTextValue(*reply) : Value{};
6591 });
6592 if (Commands const* const onError = node.event("onError"); onError != nullptr && !onError->empty()) {
6593 // Each failure is a new exception: run once per failure, with its text.
6594 auto const seen =
6595 std::make_shared<std::exception_ptr>(runtime().untracked([form] { return form->lastError(); }));
6596 ctx.scope->nodes.effect([this, held = detachedLower(ctx), onError, form, seen] {
6597 std::exception_ptr const error = form->lastError();
6598 if (error == nullptr || error == *seen) {
6599 return;
6600 }
6601 *seen = error;
6602 runtime().untracked([&] { runEvent(onError, attached(held), Value{failureText(error)}); });
6603 });
6604 }
6605 forms::FormViewOptions options{.showTitle = false};
6606 presentForm(node, ctx, options);
6607 options.title = bindProp<std::string>(ctx, node.prop("title"), std::string{}, stringOf);
6608 // A field whose overlay names a component is drawn by it; only top-level fields take one, as every overlay.
6609 for (DocNodePtr const& record : node.partList("fields")) {
6610 for (DocNodePtr const& overlay : record->partList("fields")) {
6611 if (!overlay->name("component").empty()) {
6612 options.overrides.byField(overlay->id,
6613 [this, ctx, field = overlay.get()](forms::FieldView const& view) {
6614 return fieldComponent(view, *field, ctx);
6615 });
6616 }
6617 }
6618 }
6619 Column column{.children = {forms::formView(*form, std::move(options))}};
6620 commonOf(node, ctx, element, column.common);
6621 return ui::column(std::move(column));
6622}
6623
6624/// @brief A component's `change` payload as the wire value of the field it draws, read as the `value` prop's
6625/// declared type.
6626/// @param payload The payload, as the backend reports it.
6627/// @param type The `value` prop's type.
6628/// @return The wire value, or why the payload is not one.
6629[[nodiscard]] inline std::expected<forms::detail::engine::Json, std::string> changeWire(std::string const& payload,
6630 std::string_view type) {
6631 std::string_view text{payload};
6632 if (type == "string") {
6633 if (text.starts_with("s:")) {
6634 text.remove_prefix(2);
6635 }
6636 // Parentheses: braces would pick the initializer-list constructor and make a one-element array.
6637 return forms::detail::engine::Json(std::string{text});
6638 }
6639 if (text.starts_with("i:")) {
6640 text.remove_prefix(2);
6641 }
6642 std::optional<forms::detail::engine::Json> json = forms::detail::engine::parseJson(text);
6643 if (!json) {
6644 return std::unexpected(std::string{"it is not JSON"});
6645 }
6646 bool const fits = type == "json" || json->is_null() || (type == "bool" && json->is_boolean()) ||
6647 ((type == "int" || type == "number") && json->is_number()) ||
6648 (type == "exact" && (json->is_number() || json->is_string()));
6649 if (!fits) {
6650 bool const vowel = type == "int" || type == "exact";
6651 return std::unexpected("it is not " + std::string{vowel ? "an " : "a "} + std::string{type});
6652 }
6653 return *std::move(json);
6654}
6655
6656/// @brief What a form fills of a field component's props: the field's value, errors, read-only and required state,
6657/// and label, each a tracked read of the field.
6658/// @param prop The prop's name.
6659/// @param state The field.
6660/// @return The read, or empty for a prop the form does not fill.
6661[[nodiscard]] inline std::function<Value()> formFilled(std::string const& prop, forms::FieldState* state) {
6662 if (prop == "value") {
6663 return [state] {
6664 forms::Encoding const& encoding = state->encoded().get();
6665 if (!encoding.has_value() || !encoding->has_value()) {
6666 return Value{};
6667 }
6668 return (*encoding)->value.isNull() ? jsonTextValue((*encoding)->json) : (*encoding)->value;
6669 };
6670 }
6671 if (prop == "errors") {
6672 return [state] {
6673 std::optional<forms::FieldError> const& error = state->error().get();
6674 return error.has_value() ? Value{ValueList{Value{error->message}}} : Value{ValueList{}};
6675 };
6676 }
6677 if (prop == "readonly") {
6678 return [state] { return Value{state->readonly().get()}; };
6679 }
6680 if (prop == "required") {
6681 return [state] { return Value{state->required().get()}; };
6682 }
6683 if (prop == "label") {
6684 return [state] { return Value{state->label()}; };
6685 }
6686 return {};
6687}
6688
6689// NOLINTNEXTLINE(bugprone-easily-swappable-parameters): a prop's name, then its declared type.
6690inline CustomProp ScreenCore::componentProp(std::string const& prop, std::string const& type,
6691 std::function<Value()> read) {
6692 if (type == "bool") {
6693 return CustomProp{.name = prop, .value = slotProp<bool>([this, read = std::move(read)] {
6694 return boolOf(read(), session.formatting());
6695 })};
6696 }
6697 if (type == "number") {
6698 return CustomProp{.name = prop,
6699 .value = slotProp<double>([read = std::move(read)] { return componentNumber(read()); })};
6700 }
6701 return CustomProp{.name = prop, .value = slotProp<std::string>([this, read = std::move(read), type] {
6702 return componentText(read(), type, session.formatting());
6703 })};
6704}
6705
6706inline void ScreenCore::changeField(forms::FieldState& state, forms::FieldSpec const& spec, std::string const& path,
6707 std::string_view valueType, std::string const& payload) {
6708 auto wire = changeWire(payload, valueType);
6709 // A base64 member holds a file's bytes, which a document moves inline only under the session's cap.
6710 if (std::size_t const cap = session.options().maxInlineFileBytes;
6711 wire && spec.bytes && wire->is_string() && base64Size(wire->get_string()) > cap) {
6712 report(path, "field " + spec.name + ": " + std::to_string(base64Size(wire->get_string())) +
6713 " bytes is over the " + std::to_string(cap) + " inline cap");
6714 return;
6715 }
6716 if (wire) {
6717 if (auto edited = state.edit(*wire); !edited) {
6718 wire = std::unexpected(edited.error());
6719 }
6720 }
6721 if (!wire) {
6722 report(path, "field " + spec.name + ": the component's change does not decode: " + wire.error());
6723 }
6724}
6725
6726inline Node ScreenCore::fieldComponent(forms::FieldView const& view, DocNode const& overlay, LowerCtx const& ctx) {
6727 std::string const name{overlay.name("component")};
6728 // A loaded document's field component is declared, with a `value` prop and a `change` event.
6729 Component const& component = _doc->components.at(name);
6730 forms::FieldState* const state = &view.state();
6731 Custom spec{.name = name,
6732 .fallback = view.defaultControl(),
6733 .props = {},
6734 .minVersion =
6735 static_cast<int>(std::clamp<std::int64_t>(component.version, 0, std::numeric_limits<int>::max())),
6736 .onEvent = {}};
6737 for (auto const& [prop, type] : component.props) {
6738 if (prop == "value" && type == "json") {
6739 // The wire JSON itself, so an object's or a collection's every digit reaches the component.
6740 spec.props.push_back(CustomProp{.name = prop, .value = slotProp<std::string>([state] {
6741 forms::Encoding const& encoding = state->encoded().get();
6742 return encoding.has_value() && encoding->has_value()
6743 ? (*encoding)->json
6744 : std::string{"null"};
6745 })});
6746 continue;
6747 }
6748 std::function<Value()> read = formFilled(prop, state);
6749 if (!read) {
6750 ExprPtr given;
6751 for (DocNodePtr const& record : overlay.partList("props")) {
6752 if (ExprPtr const expr = record->prop(prop)) {
6753 given = expr;
6754 }
6755 }
6756 read = [scope = ctx.scope, bindings = ctx.bindings, given] {
6757 return given ? evalIn(*scope, bindings.get(), given) : Value{};
6758 };
6759 }
6760 spec.props.push_back(componentProp(prop, type, std::move(read)));
6761 }
6762 spec.onEvent = [this, state, path = overlay.path, fieldSpec = &view.spec(),
6763 valueType = component.props.at("value")](std::string const& event, std::string const& payload) {
6764 if (event == "change") {
6765 changeField(*state, *fieldSpec, path, valueType, payload);
6766 }
6767 };
6768 return ui::custom(std::move(spec));
6769}
6770
6771// Every call goes through the alias's channel, bound for its current instance when the call is made.
6772inline forms::Submitter ScreenCore::aliasCalls(ModelAlias* alias) {
6773 return [this, alias](std::string_view actionId, std::string const& body) -> async::Completion<std::string> {
6774 std::shared_ptr<ModelChannel> const channel = channelOf(*alias, *this);
6775 if (channel == nullptr) {
6776 auto [failed, promise] = async::Completion<std::string>::makeSettleable(&runtime().owner());
6777 promise.reject(std::make_exception_ptr(std::runtime_error{"the node's model alias is not bound"}));
6778 return std::move(failed);
6779 }
6780 return channel->execute(std::string{actionId}, body, CallOptions{});
6781 };
6782}
6783
6784// The calls of a form, collection or wizard: a success other than of @p read, the collection's own query, tells the
6785// session, which applies the action's effects, as a mutation's success does.
6786inline forms::Submitter ScreenCore::aliasSubmits(ModelAlias* alias, std::string read) {
6787 return [this, alias, call = aliasCalls(alias), read = std::move(read)](
6788 std::string_view actionId, std::string body) -> async::Completion<std::string> {
6789 async::Completion<std::string> completion = call(actionId, std::move(body));
6790 if (actionId != read) {
6791 completion.then([this, alive = std::weak_ptr<bool>{_alive}, alias, typeId = alias->decl->type,
6792 action = std::string{actionId}](std::string const& reply) {
6793 if (alive.expired()) {
6794 return;
6795 }
6796 auto json = parseJson(reply);
6797 auto value = json ? valueFromJson(*json) : std::unexpected(json.error());
6798 std::string const token = runtime().untracked([&] { return alias->token->get(); });
6799 session.actionSucceeded(typeId, action, value ? *value : Value{}, ReplyUse::Applied, token);
6800 });
6801 }
6802 return completion;
6803 };
6804}
6805
6806// The session's locale, translations and display zone, as every form the engine builds here reads them.
6807inline forms::FormSessionOptions ScreenCore::engineOptions() const {
6808 FormatContext const& format = session.formatting();
6809 forms::FormSessionOptions options{};
6810 options.locale = format.numeric;
6811 options.bcp47 = format.locale;
6812 options.translations = format.translations;
6813 options.displayOffsetMinutes = format.utcOffsetMinutes;
6814 return options;
6815}
6816
6817// Builds a collection's or wizard's session when its element is declared. The node's `action` is the collection's
6818// query or the wizard's first step; the catalog answers the document for it, and every call (the query, the actions,
6819// every step's submission) goes through the node's model alias.
6820inline void ScreenCore::wireComposite(ElementState& element) {
6821 DocNode const& node = *element.node;
6822 RtScope const& scope = *element.scope;
6823 bool const collection = node.kind == "collection";
6824 std::string const action{node.name("action")};
6825 Catalog const& catalog = session.options().catalog;
6826 auto const& lookup = collection ? catalog.view : catalog.wizard;
6827 std::optional<std::string> const document = lookup ? lookup(action) : std::nullopt;
6828 if (!document.has_value()) {
6829 report(node.path, std::string{"no "} + (collection ? "view" : "wizard") + " document for `" + action +
6830 "`: the catalog has none for a `" + node.kind + "` to build from");
6831 return;
6832 }
6833 NameRef const* const alias = scope.find(node.name("model"));
6834 if (alias == nullptr || alias->model == nullptr) {
6835 report(node.path, "`" + std::string{node.name("model")} + "` is not a model alias");
6836 return;
6837 }
6838 forms::SchemaLookup const schemas = catalog.schema ? forms::SchemaLookup{catalog.schema} : forms::SchemaLookup{};
6839 forms::Submitter const call = aliasCalls(alias->model);
6840 if (collection) {
6841 auto model = forms::CollectionModel::fromSchema(*document);
6842 if (!model) {
6843 report(node.path, "the collection of `" + action + "` does not build: " + model.error().path + ": " +
6844 model.error().message);
6845 return;
6846 }
6847 if (model->query() != action) {
6848 report(node.path, "the view document of `" + action + "` lists `" + std::string{model->query()} + "`");
6849 return;
6850 }
6851 std::string query{model->query()};
6852 element.collection = std::make_unique<forms::CollectionSession>(runtime(), *std::move(model), schemas,
6853 aliasSubmits(alias->model, std::move(query)),
6854 call, engineOptions());
6855 return;
6856 }
6857 // The wizard's id, the stem of its translation keys, is the action it starts with.
6858 auto model = forms::WizardModel::fromSchema(action, *document);
6859 if (!model) {
6860 report(node.path,
6861 "the wizard of `" + action + "` does not build: " + model.error().path + ": " + model.error().message);
6862 return;
6863 }
6864 if (model->steps().front().action != action) {
6865 report(node.path,
6866 "the wizard document of `" + action + "` starts with `" + model->steps().front().action + "`");
6867 return;
6868 }
6869 try {
6870 element.wizard = std::make_unique<forms::WizardSession>(runtime(), *std::move(model), schemas,
6871 aliasSubmits(alias->model), call, engineOptions());
6872 } catch (std::invalid_argument const& error) {
6873 report(node.path, error.what());
6874 }
6875}
6876
6877// The presentation keys a form, a collection and a wizard share, but the title: the label of the explicit-mode button
6878// and the status line naming it, bound in the node's scope; whether the status line shows, the grid's columns and the
6879// field layout, as read at load.
6880inline void ScreenCore::presentForm(DocNode const& node, LowerCtx const& ctx, forms::FormViewOptions& options) {
6881 if (ExprPtr const label = node.prop("submitLabel")) {
6882 options.submitLabel = bindProp<std::string>(ctx, label, std::string{}, stringOf);
6883 }
6884 if (auto const shown = node.flags.find("showStatus"); shown != node.flags.end()) {
6885 options.showStatus = shown->second;
6886 }
6887 if (ExprPtr const columns = node.prop("columns"); columns != nullptr && columns->literal.asInt() != nullptr) {
6888 options.gridColumns = static_cast<int>(*columns->literal.asInt());
6889 options.flatGridColumns = options.gridColumns;
6890 }
6891 if (node.name("fieldLayout") == "inline") {
6892 options.fieldLayout = forms::FieldLayout::Inline;
6893 }
6894}
6895
6896inline Node ScreenCore::lowerComposite(DocNode const& node, LowerCtx const& ctx, ElementState* element) {
6897 // The editor's and the steps' titles are the dialog's and the header's; the forms do not repeat them. A `title`
6898 // replaces the view's own heading.
6899 forms::FormViewOptions form{};
6900 form.showTitle = false;
6901 presentForm(node, ctx, form);
6902 Prop<std::string> title = bindProp<std::string>(ctx, node.prop("title"), std::string{}, stringOf);
6903 Node view;
6904 if (element != nullptr && element->collection != nullptr) {
6905 forms::CollectionViewOptions options{};
6906 options.form = std::move(form);
6907 options.title = std::move(title);
6908 view = forms::collectionView(*element->collection, std::move(options));
6909 } else if (element != nullptr && element->wizard != nullptr) {
6910 forms::WizardViewOptions options{};
6911 options.form = std::move(form);
6912 options.title = std::move(title);
6913 view = forms::wizardView(*element->wizard, options);
6914 } else {
6915 return ui::text(Text{.text = std::string{node.name("action")}, .role = TextRole::Muted});
6916 }
6917 Column column{.children = {std::move(view)}};
6918 commonOf(node, ctx, element, column.common);
6919 return ui::column(std::move(column));
6920}
6921
6922// NOLINTNEXTLINE(readability-function-cognitive-complexity): an overlay's scope, content, actions and keys are one lowering.
6923// NOLINTNEXTLINE(readability-function-cognitive-complexity): an overlay's scope, content, actions and keys are one lowering.
6924inline Node ScreenCore::lowerDialog(DocNode const& node, LowerCtx const& ctx, ElementState* element) {
6925 Prop<bool> open = node.prop("open") ? bindProp<bool>(ctx, node.prop("open"), false, boolOf)
6926 : slotProp<bool>([element] { return element->open.get(); });
6927 Node body = lazy([this, ctx, &node, element]() -> Node {
6928 auto inner = std::make_shared<RtScope>(*this, ctx.scope, nullptr, ctx.scope->nodes.depth() + 2);
6929 inner->owner = element;
6930 addPending(1);
6931 inner->submitted = &element->submitted;
6932 inner->showErrors = std::string{node.name("showErrors")};
6933 std::vector<DocNode const*> content;
6934 for (DocNodePtr const& child : node.nodes("children")) {
6935 content.push_back(child.get());
6936 }
6937 std::vector<Commands const*> lists;
6938 for (std::string_view const key : {"default", "cancel"}) {
6939 if (Commands const* const commands = node.event(key)) {
6940 lists.push_back(commands);
6941 }
6942 }
6943 for (DocNodePtr const& action : node.partList("actions")) {
6944 if (Commands const* const commands = action->event("onClick")) {
6945 lists.push_back(commands);
6946 }
6947 }
6948 declare(*inner, node.scope ? &*node.scope : nullptr, content, lists);
6949 element->inner.set(inner.get());
6950 LowerCtx const innerCtx{.scope = inner, .bindings = ctx.bindings, .boundary = ctx.boundary};
6951 std::vector<Node> children;
6952 children.reserve(content.size());
6953 for (DocNode const* const child : content) {
6954 children.push_back(lower(*child, innerCtx));
6955 }
6956 // The overlay's actions end its scoped content, so they bind in its scope; the mount puts them in its footer.
6957 if (ActionModels actions = lowerActionParts(node.partList("actions"), innerCtx); !actions.empty()) {
6958 children.push_back(actionBarOf(std::move(actions)));
6959 }
6960 std::vector<KeyBinding> keys;
6961 if (Commands const* const commands = node.event("default")) {
6962 keys.push_back(KeyBinding{
6963 .chord = "Enter", .onPress = [this, innerCtx, commands] { runEvent(commands, innerCtx, Value{}); }});
6964 }
6965 if (Commands const* const commands = node.event("cancel")) {
6966 keys.push_back(KeyBinding{
6967 .chord = "Esc", .onPress = [this, innerCtx, commands] { runEvent(commands, innerCtx, Value{}); }});
6968 }
6969 return holding(std::move(children), inner, std::move(keys));
6970 });
6971 Action onDismiss = [this, ctx, element, commands = node.event("onDismiss")] {
6972 session.asUser([&] {
6973 session.noteWrite();
6974 element->open.set(false);
6975 });
6976 runEvent(commands, ctx, Value{});
6977 };
6978 if (node.kind == "drawer") {
6979 ExprPtr const side = node.prop("side");
6980 Drawer spec{
6981 .open = std::move(open),
6982 .title = bindProp<std::string>(ctx, node.prop("title"), std::string{}, stringOf),
6983 .child = std::move(body),
6984 .onDismiss = std::move(onDismiss),
6985 .side = side && textOf(once(ctx, side), session.formatting()) == "start" ? Side::Start : Side::End};
6986 commonOf(node, ctx, element, spec.common);
6987 return ui::drawer(std::move(spec));
6988 }
6989 Dialog spec{.open = std::move(open),
6990 .title = bindProp<std::string>(ctx, node.prop("title"), std::string{}, stringOf),
6991 .child = std::move(body),
6992 .onDismiss = std::move(onDismiss)};
6993 commonOf(node, ctx, element, spec.common);
6994 return ui::dialog(std::move(spec));
6995}
6996
6997inline Node ScreenCore::lowerSwitch(DocNode const& node, LowerCtx const& ctx) {
6998 std::vector<ExprPtr> whens;
6999 Switch spec;
7000 std::int64_t index = 0;
7001 for (DocNodePtr const& part : node.partList("cases")) {
7002 whens.push_back(part->prop("when"));
7003 DocNode const* const casePart = part.get();
7004 spec.cases.push_back(SwitchCase{
7005 .key = Key{index++},
7006 .node = lazy([this, ctx, casePart]() -> Node {
7007 auto inner = std::make_shared<RtScope>(*this, ctx.scope, nullptr, ctx.scope->nodes.depth() + 2);
7008 std::vector<DocNode const*> content;
7009 for (DocNodePtr const& child : casePart->nodes("children")) {
7010 content.push_back(child.get());
7011 }
7012 declare(*inner, casePart->scope ? &*casePart->scope : nullptr, content, {});
7013 LowerCtx const innerCtx{.scope = inner, .bindings = ctx.bindings, .boundary = ctx.boundary};
7014 std::vector<Node> children;
7015 children.reserve(content.size());
7016 for (DocNode const* const child : content) {
7017 children.push_back(lower(*child, innerCtx));
7018 }
7019 return holding(std::move(children), inner);
7020 })});
7021 }
7022 spec.selector = slotProp<Key>(
7023 [scope = ctx.scope, bindings = ctx.bindings, selected = node.prop("on"), whens = std::move(whens)] {
7024 Value const value = evalIn(*scope, bindings.get(), selected);
7025 for (std::size_t at = 0; at < whens.size(); ++at) {
7026 if (evalIn(*scope, bindings.get(), whens.at(at)) == value) {
7027 return Key{static_cast<std::int64_t>(at)};
7028 }
7029 }
7030 return Key{std::int64_t{-1}};
7031 });
7032 auto const fallback = node.nodes("default");
7033 spec.fallback = fallback.empty() ? Node{} : lowerAll(fallback, ctx);
7034 commonOf(node, ctx, nullptr, spec.common);
7035 return ui::switchOf(std::move(spec));
7036}
7037
7038inline std::shared_ptr<RtScope> ScreenCore::rowScope(DocNode const& node, LowerCtx const& ctx,
7039 reactive::Signal<Value> const& row,
7040 std::vector<DocNode const*> const& content) {
7041 auto scope = std::make_shared<RtScope>(*this, ctx.scope, &row, ctx.scope->nodes.depth() + 2);
7042 declare(*scope, node.scope ? &*node.scope : nullptr, content, {});
7043 return scope;
7044}
7045
7046// NOLINTNEXTLINE(readability-function-cognitive-complexity): a list of rows and a table.
7047inline Node ScreenCore::lowerRows(DocNode const& node, LowerCtx const& ctx, ElementState* element) {
7048 ExprPtr const rowsExpr = node.prop("rows");
7049 if (!rowsExpr) {
7050 report(node.path, "a model-backed `table` needs the table engine, which this interpreter does not lower yet");
7051 return ui::text(Text{.text = std::string{"table"}, .role = TextRole::Muted});
7052 }
7053 auto rows = [scope = ctx.scope, bindings = ctx.bindings, rowsExpr] {
7054 Value const value = evalIn(*scope, bindings.get(), rowsExpr);
7055 ValueList const* const items = value.asList();
7056 return items == nullptr ? std::vector<Value>{} : std::vector<Value>{items->begin(), items->end()};
7057 };
7058 auto keyFn = [scope = ctx.scope, bindings = ctx.bindings, keyExpr = node.prop("key")](Value const& row) -> Key {
7059 if (!keyExpr) {
7060 Value const rowKey = rowId(row);
7061 return keyOf(rowKey.isNull() ? row : rowKey);
7062 }
7063 ScopeEnv const env{*scope, bindings.get(), &row};
7064 return keyOf(evaluate(*keyExpr, env));
7065 };
7066 if (node.kind == "forEach") {
7067 std::vector<DocNode const*> content;
7068 for (DocNodePtr const& child : node.nodes("children")) {
7069 content.push_back(child.get());
7070 }
7071 auto rowView = [this, ctx, &node, content](reactive::Signal<Value> const& row) -> Node {
7072 auto scope = rowScope(node, ctx, row, content);
7073 LowerCtx const rowCtx{.scope = scope, .bindings = ctx.bindings, .boundary = ctx.boundary};
7074 std::vector<Node> children;
7075 children.reserve(content.size());
7076 for (DocNode const* const child : content) {
7077 children.push_back(lower(*child, rowCtx));
7078 }
7079 return holding(std::move(children), scope);
7080 };
7081 ExprPtr const axis = node.prop("axis");
7082 ExprPtr const gap = node.prop("gap");
7083 return ui::forEach<Value>(std::move(rows), std::move(keyFn), std::move(rowView),
7084 axis ? axisOf(once(ctx, axis)) : Axis::Vertical,
7085 gap ? static_cast<int>(intOf(once(ctx, gap), 0)) : 0);
7086 }
7087 std::vector<TableColumn> columns;
7088 std::vector<DocNode const*> cells;
7089 std::vector<std::string> fields;
7090 // One entry per column, in the same order as `cells`/`fields`. Captured by `cellsOf` below (not `columns`
7091 // itself, which is moved into `ui::table<Value>(...)` further down and so cannot be read from inside a lambda
7092 // that outlives this function) so the default-cell closure can apply each column's role to its generated cell.
7093 std::vector<TextRole> roles;
7094 FormatContext const& format = session.formatting();
7095 if (ExprPtr const derived = node.prop("columns")) {
7096 Value const list = once(ctx, derived);
7097 if (ValueList const* const items = list.asList()) {
7098 for (Value const& item : *items) {
7099 Value const* const field = item.field("id");
7100 Value const* const label = item.field("label");
7101 fields.push_back(field == nullptr ? std::string{} : textOf(*field, format));
7102 columns.push_back(
7103 TableColumn{.label = textOf(label != nullptr ? *label : Value{fields.back()}, format)});
7104 cells.push_back(nullptr);
7105 roles.push_back(TextRole::Normal);
7106 }
7107 }
7108 }
7109 for (DocNodePtr const& part : node.partList("columns")) {
7110 ExprPtr const label = part->prop("label");
7111 ExprPtr const width = part->prop("width");
7112 ExprPtr const role = part->prop("role");
7113 TextRole const columnRole = role ? roleOf(once(ctx, role), format) : TextRole::Normal;
7114 columns.push_back(TableColumn{.label = label ? textOf(once(ctx, label), format) : std::string{},
7115 .width = width ? sizingOf(once(ctx, width)) : Sizing::content(),
7116 .role = columnRole});
7117 cells.push_back(part.get());
7118 fields.emplace_back(part->id);
7119 roles.push_back(columnRole);
7120 }
7121 auto registry = std::make_shared<std::map<std::string, std::weak_ptr<RtScope>, std::less<>>>();
7122 // A value that names no tone is reported once for the table, whichever row and value it was.
7123 auto toneReported = std::make_shared<bool>(false);
7124 auto cellsOf = [this, ctx, &node, cells, fields, roles, registry, keyFn,
7125 toneReported](reactive::Signal<Value> const& row) {
7126 std::vector<DocNode const*> content;
7127 for (DocNode const* const column : cells) {
7128 if (column != nullptr) {
7129 for (DocNodePtr const& cell : column->nodes("cell")) {
7130 content.push_back(cell.get());
7131 }
7132 }
7133 }
7134 auto scope = rowScope(node, ctx, row, content);
7135 (*registry)[canonicalJson(valueOfKey(keyFn(row.peek())))] = scope;
7136 LowerCtx const rowCtx{.scope = scope, .bindings = ctx.bindings, .boundary = ctx.boundary};
7137 std::vector<Node> out;
7138 for (std::size_t index = 0; index < cells.size(); ++index) {
7139 DocNode const* const column = cells.at(index);
7140 auto const nodes = column == nullptr ? std::span<DocNodePtr const>{} : column->nodes("cell");
7141 if (!nodes.empty()) {
7142 out.push_back(lowerAll(nodes, rowCtx));
7143 continue;
7144 }
7145 out.push_back(ui::text(Text{.text = slotProp<std::string>([this, &row, field = fields.at(index)] {
7146 Value const* const value = row.get().field(field);
7147 return value == nullptr ? std::string{}
7148 : textOf(*value, session.formatting());
7149 }),
7150 .role = roles.at(index)}));
7151 }
7152 if (!out.empty()) {
7153 out.front() = holding({out.front()}, scope);
7154 }
7155 TableRowView view{.cells = std::move(out), .tone = std::nullopt};
7156 if (ExprPtr const toneExpr = node.prop("rowTone")) {
7157 view.tone = slotProp<std::optional<Tone>>(
7158 [this, scope, bindings = ctx.bindings, toneExpr, toneReported]() -> std::optional<Tone> {
7159 return rowToneOf(evalIn(*scope, bindings.get(), toneExpr), toneExpr->path, *toneReported);
7160 });
7161 }
7162 return view;
7163 };
7164 TableOptions options;
7165 options.showHeader = node.prop("showHeader") ? boolOf(once(ctx, node.prop("showHeader")), format) : true;
7166 if (ExprPtr const threshold = node.prop("virtualizeThreshold")) {
7167 options.virtualize.threshold =
7168 static_cast<std::size_t>(std::max<std::int64_t>(0, intOf(once(ctx, threshold), 2000)));
7169 }
7170 if (ExprPtr const overscan = node.prop("virtualizeOverscan")) {
7171 options.virtualize.overscan =
7172 static_cast<std::size_t>(std::max<std::int64_t>(0, intOf(once(ctx, overscan), 10)));
7173 }
7174 std::string const mode =
7175 node.prop("selectionMode") ? textOf(once(ctx, node.prop("selectionMode")), format) : std::string{};
7176 options.selectionMode = SelectionMode::None;
7177 if (mode == "single") {
7178 options.selectionMode = SelectionMode::Single;
7179 } else if (mode == "multiple") {
7180 options.selectionMode = SelectionMode::Multiple;
7181 }
7182 options.selection = slotProp<std::vector<Key>>([element] {
7183 std::vector<Key> keys;
7184 if (ValueList const* const items = element->selection.get().asList()) {
7185 for (Value const& item : *items) {
7186 keys.push_back(keyOf(item));
7187 }
7188 }
7189 return keys;
7190 });
7191 options.onSelectionChange = [this, element](std::vector<Key> const& keys) {
7192 ValueList items;
7193 for (Key const& key : keys) {
7194 items.push_back(valueOfKey(key));
7195 }
7196 session.asUser([&] {
7197 session.noteWrite();
7198 element->selection.set(Value{std::move(items)});
7199 });
7200 };
7201 options.onActivate = [this, ctx, element, registry, commands = node.event("onActivate")](Key const& key) {
7202 Value const activated = valueOfKey(key);
7203 session.asUser([&] {
7204 session.noteWrite();
7205 element->activeKey.set(activated);
7206 });
7207 auto const found = registry->find(canonicalJson(activated));
7208 std::shared_ptr<RtScope> const scope = found == registry->end() ? nullptr : found->second.lock();
7209 LowerCtx const rowCtx{.scope = scope ? scope : ctx.scope, .bindings = ctx.bindings, .boundary = ctx.boundary};
7210 runEvent(commands, rowCtx, activated);
7211 };
7212 commonOf(node, ctx, element, options.common);
7213 std::span<DocNodePtr const> const empty = node.nodes("empty");
7214 if (empty.empty()) {
7215 return ui::table<Value>(std::move(columns), std::move(rows), std::move(keyFn), std::move(cellsOf),
7216 std::move(options));
7217 }
7218 // With `empty`, a column holds the table and the content shown in its place while `rows` is an empty list; a
7219 // `null` (a query not answered yet) shows the table. The column is placed as the table would be: it takes the
7220 // node's visibility and layout, and every other common property stays on the table, so its test id and the
7221 // element id `focus` finds name the table.
7222 auto const listed = [scope = ctx.scope, bindings = ctx.bindings, rowsExpr] {
7223 ValueList const* const items = evalIn(*scope, bindings.get(), rowsExpr).asList();
7224 return items == nullptr || !items->empty();
7225 };
7226 Column wrapper;
7227 wrapper.common.visible = std::move(options.common.visible);
7228 wrapper.common.layout = options.common.layout;
7229 options.common.visible = slotProp<bool>(listed);
7230 Column content{.children = {lowerAll(empty, ctx)}};
7231 content.common.visible = slotProp<bool>([listed] { return !listed(); });
7232 wrapper.children.push_back(ui::table<Value>(std::move(columns), std::move(rows), std::move(keyFn),
7233 std::move(cellsOf), std::move(options)));
7234 wrapper.children.push_back(ui::column(std::move(content)));
7235 return ui::column(std::move(wrapper));
7236}
7237
7238inline std::optional<Tone> ScreenCore::rowToneOf(Value const& value, std::string const& path, bool& noted) {
7239 if (value.isNull()) {
7240 return std::nullopt;
7241 }
7242 static constexpr std::array<std::pair<std::string_view, Tone>, 5> kTones = {{
7243 {"neutral", Tone::Neutral},
7244 {"info", Tone::Info},
7245 {"ok", Tone::Ok},
7246 {"warn", Tone::Warn},
7247 {"err", Tone::Err},
7248 }};
7249 if (std::string const* const text = value.asString()) {
7250 // A loop, not std::ranges::find: an array's iterator is a pointer on some standard libraries and not on
7251 // others, so `auto` there reads differently to each.
7252 for (auto const& [name, tone] : kTones) {
7253 if (name == *text) {
7254 return tone;
7255 }
7256 }
7257 }
7258 if (!noted) {
7259 noted = true;
7260 report(path, "rowTone is " + canonicalJson(value) + "; a tone is neutral, info, ok, warn or err");
7261 }
7262 return std::nullopt;
7263}
7264
7265inline void ScreenCore::ownQueriesOf(ExprPtr const& expr, RtScope const& scope, BoundaryRuntime& boundary,
7266 std::set<std::string, std::less<>>& seen) {
7267 if (!expr) {
7268 return;
7269 }
7270 bool const control = expr->kind == ExprKind::Query || expr->kind == ExprKind::Pending ||
7271 expr->kind == ExprKind::Error || expr->kind == ExprKind::ErrorKind;
7272 if (control || expr->kind == ExprKind::Ref) {
7273 NameRef const* const found = scope.find(expr->name);
7274 if (found != nullptr && found->category == NameCategory::Query && seen.insert(expr->name).second) {
7275 boundary.queries.push_back(found->query);
7276 }
7277 if (expr->kind == ExprKind::Ref && found != nullptr && found->category == NameCategory::Let &&
7278 seen.insert("let:" + expr->name).second) {
7279 ownQueriesOf(found->letExpr, scope, boundary, seen);
7280 }
7281 }
7282 for (ExprPtr const& arg : expr->args) {
7283 ownQueriesOf(arg, scope, boundary, seen);
7284 }
7285 for (auto const& [name, field] : expr->fields) {
7286 static_cast<void>(name);
7287 ownQueriesOf(field, scope, boundary, seen);
7288 }
7289}
7290
7291inline void ScreenCore::ownQueries(DocNode const& node, RtScope const& scope, BoundaryRuntime& boundary,
7292 std::set<std::string, std::less<>>& seen) {
7293 if (node.kind == "boundary") {
7294 return;
7295 }
7296 for (auto const& [key, expr] : node.props) {
7297 static_cast<void>(key);
7298 ownQueriesOf(expr, scope, boundary, seen);
7299 }
7300 for (auto const& [key, nodes] : node.children) {
7301 static_cast<void>(key);
7302 for (DocNodePtr const& child : nodes) {
7303 ownQueries(*child, scope, boundary, seen);
7304 }
7305 }
7306 for (auto const& [key, parts] : node.parts) {
7307 static_cast<void>(key);
7308 for (DocNodePtr const& part : parts) {
7309 ownQueries(*part, scope, boundary, seen);
7310 }
7311 }
7312}
7313
7314inline Node ScreenCore::lowerBoundary(DocNode const& node, LowerCtx const& ctx) {
7315 auto boundary = std::make_shared<BoundaryRuntime>();
7316 std::set<std::string, std::less<>> seen;
7317 for (DocNodePtr const& child : node.nodes("children")) {
7318 ownQueries(*child, *ctx.scope, *boundary, seen);
7319 }
7320 auto bindings = std::make_shared<Bindings>(*ctx.bindings);
7321 bindings->push_back(Binding{.name = "boundary", .read = [boundary] {
7322 for (QueryRuntime const* const query : boundary->queries) {
7323 Value const error = query->error();
7324 if (!error.isNull() && query->value().isNull()) {
7325 return Value{ValueRecord{{"error", error}}};
7326 }
7327 }
7328 return Value{ValueRecord{{"error", Value{}}}};
7329 }});
7330 LowerCtx const errorCtx{.scope = ctx.scope, .bindings = bindings, .boundary = boundary};
7331 LowerCtx const childCtx{.scope = ctx.scope, .bindings = ctx.bindings, .boundary = boundary};
7332 auto group = [this](std::span<DocNodePtr const> nodes, LowerCtx const& where) -> Node {
7333 return nodes.empty() ? ui::spacer() : lowerAll(nodes, where);
7334 };
7335 Switch spec;
7336 spec.cases.push_back(SwitchCase{.key = Key{std::int64_t{0}}, .node = group(node.nodes("loading"), childCtx)});
7337 spec.cases.push_back(SwitchCase{.key = Key{std::int64_t{1}}, .node = group(node.nodes("error"), errorCtx)});
7338 spec.cases.push_back(SwitchCase{.key = Key{std::int64_t{2}}, .node = group(node.nodes("children"), childCtx)});
7339 spec.selector = slotProp<Key>([boundary] {
7340 bool loading = false;
7341 for (QueryRuntime const* const query : boundary->queries) {
7342 if (!query->settled()) {
7343 loading = true;
7344 } else if (query->value().isNull() && !query->error().isNull()) {
7345 return Key{std::int64_t{1}};
7346 }
7347 }
7348 return Key{std::int64_t{loading ? 0 : 2}};
7349 });
7350 commonOf(node, ctx, nullptr, spec.common);
7351 return ui::switchOf(std::move(spec));
7352}
7353
7354inline Node ScreenCore::lowerCustom(DocNode const& node, LowerCtx const& ctx) {
7355 std::string const name{node.name("name")};
7356 // A loaded document's custom node names a declared component, and every component has a fallback.
7357 Component const& component = _doc->components.at(name);
7358 std::vector<std::pair<std::string, ExprPtr>> props;
7359 for (DocNodePtr const& record : node.partList("props")) {
7360 for (auto const& [key, expr] : record->props) {
7361 props.emplace_back(key, expr);
7362 }
7363 }
7364 auto bindings = std::make_shared<Bindings>();
7365 bindings->push_back(
7366 Binding{.name = "props", .read = [scope = ctx.scope, outer = ctx.bindings, props = std::move(props)] {
7367 ValueRecord record;
7368 for (auto const& [key, expr] : props) {
7369 record.insert_or_assign(key, evalIn(*scope, outer.get(), expr));
7370 }
7371 return Value{std::move(record)};
7372 }});
7373 auto scope = std::make_shared<RtScope>(*this, nullptr, nullptr, ctx.scope->nodes.depth() + 1);
7374 DocNode const& fallback = *component.fallback;
7375 declare(*scope, nullptr, {&fallback}, {});
7376 Node lowered = lower(fallback, LowerCtx{.scope = scope, .bindings = bindings, .boundary = nullptr});
7377 Custom spec{.name = name,
7378 .fallback = std::move(lowered),
7379 .props = {},
7380 .minVersion =
7381 static_cast<int>(std::clamp<std::int64_t>(component.version, 0, std::numeric_limits<int>::max())),
7382 .onEvent = {}};
7383 // Every declared prop is given, a missing one as its type's empty value: a component lacking one is refused.
7384 for (auto const& [prop, type] : component.props) {
7385 ExprPtr expr;
7386 for (DocNodePtr const& record : node.partList("props")) {
7387 if (ExprPtr const given = record->prop(prop)) {
7388 expr = given;
7389 }
7390 }
7391 if (type == "bool") {
7392 spec.props.push_back(CustomProp{.name = prop, .value = bindProp<bool>(ctx, expr, false, boolOf)});
7393 } else if (type == "number") {
7394 spec.props.push_back(CustomProp{
7395 .name = prop, .value = bindProp<double>(ctx, expr, 0.0, [](Value const& value, FormatContext const&) {
7396 return componentNumber(value);
7397 })});
7398 } else {
7399 spec.props.push_back(
7400 CustomProp{.name = prop,
7401 .value = bindProp<std::string>(ctx, expr, std::string{},
7402 [type](Value const& value, FormatContext const& format) {
7403 return componentText(value, type, format);
7404 })});
7405 }
7406 }
7407 spec.onEvent = [this, ctx, site = &node, declared = &component](std::string const& event,
7408 std::string const& payload) {
7409 Commands const* const commands = site->event("on." + event);
7410 if (commands == nullptr) {
7411 return;
7412 }
7413 auto const type = declared->events.find(event);
7414 runEvent(commands, ctx, componentPayload(payload, type == declared->events.end() ? nullptr : &type->second));
7415 };
7416 return ui::custom(std::move(spec));
7417}
7418
7419/// @brief One child of an arranged container, as the user's choice places it.
7421 /// @brief The child's id.
7422 std::string id;
7423 /// @brief Whether the choice hides it.
7424 bool hidden = false;
7425};
7426
7427/// @brief A container's children in the order a stored `arrange` choice gives, each marked shown or hidden.
7428///
7429/// A child the choice does not name keeps its declared place after the named ones, shown, and an entry that names no
7430/// child, names one twice or is not a `{"id", "hidden"}` record is skipped, so a new document version survives an old
7431/// choice (spec 5 §12b). A choice that is not a list leaves the declared order. Only `"hidden": true` hides.
7432/// @param stored The point's current choice.
7433/// @param declared The container's children, in document order.
7434/// @return Every child, in order.
7435[[nodiscard]] inline std::vector<ArrangedChild> arrangedChildren(Value const& stored,
7436 std::span<DocNode const* const> declared) {
7437 auto const isDeclared = [declared](std::string const& childId) {
7438 return std::ranges::find(declared, childId, &DocNode::id) != declared.end();
7439 };
7440 std::vector<ArrangedChild> out;
7441 std::set<std::string, std::less<>> placed;
7442 if (ValueList const* const entries = stored.asList()) {
7443 for (Value const& entry : *entries) {
7444 Value const* const named = entry.field("id");
7445 std::string const* const name = named == nullptr ? nullptr : named->asString();
7446 if (name == nullptr || !isDeclared(*name) || !placed.insert(*name).second) {
7447 continue;
7448 }
7449 Value const* const hidden = entry.field("hidden");
7450 bool const* const flag = hidden == nullptr ? nullptr : hidden->asBool();
7451 out.push_back(ArrangedChild{.id = *name, .hidden = flag != nullptr && *flag});
7452 }
7453 }
7454 for (DocNode const* const child : declared) {
7455 if (!placed.contains(child->id)) {
7456 out.push_back(ArrangedChild{.id = child->id});
7457 }
7458 }
7459 return out;
7460}
7461
7462/// @brief The ids of the children an `arrange` choice shows, in its order (`arrangedChildren`, without the hidden).
7463/// @param stored The point's current choice.
7464/// @param declared The container's children, in document order.
7465/// @return The ids to show, in order.
7466[[nodiscard]] inline std::vector<Value> arrangedOrder(Value const& stored, std::span<DocNode const* const> declared) {
7467 std::vector<Value> order;
7468 for (ArrangedChild const& child : arrangedChildren(stored, declared)) {
7469 if (!child.hidden) {
7470 order.emplace_back(child.id);
7471 }
7472 }
7473 return order;
7474}
7475
7476/// @brief An arrangement as the choice an `arrange` point stores.
7477/// @param children Every child, in order.
7478/// @return `[{"id", "hidden"}, …]`.
7479[[nodiscard]] inline Value arrangementValue(std::vector<ArrangedChild> const& children) {
7480 ValueList entries;
7481 entries.reserve(children.size());
7482 for (ArrangedChild const& child : children) {
7483 entries.emplace_back(ValueRecord{{"id", Value{child.id}}, {"hidden", Value{child.hidden}}});
7484 }
7485 return Value{std::move(entries)};
7486}
7487
7488/// @brief The element of a document tree with @p elementId.
7489/// @param node The subtree.
7490/// @param elementId The element's id.
7491/// @return The element, or `nullptr` when the subtree has none with that id.
7492// NOLINTNEXTLINE(misc-no-recursion): one level per level of the document tree.
7493[[nodiscard]] inline DocNode const* elementNode(DocNode const& node, std::string_view elementId) {
7494 if (node.id == elementId) {
7495 return &node;
7496 }
7497 for (auto const* const groups : {&node.children, &node.parts}) {
7498 for (auto const& [slot, nodes] : *groups) {
7499 static_cast<void>(slot);
7500 for (DocNodePtr const& child : nodes) {
7501 if (DocNode const* const found = elementNode(*child, elementId)) {
7502 return found;
7503 }
7504 }
7505 }
7506 }
7507 return nullptr;
7508}
7509
7510inline std::string ScreenCore::arrangedBy(std::string const& elementId) const {
7511 if (elementId.empty()) {
7512 return {};
7513 }
7514 for (auto const& [name, point] : _doc->customize) {
7515 if (point.kind == "arrange" && point.of == elementId) {
7516 return name;
7517 }
7518 }
7519 return {};
7520}
7521
7522// A keyed list over the children's ids rather than a stack, so a new choice moves the shown widgets, keeping their
7523// state and focus, and mounts or unmounts only what it shows or hides. A child is lowered in the container's own
7524// scope: arranging changes where it is, not what it reads.
7525inline Node ScreenCore::lowerArranged(DocNode const& node, LowerCtx const& ctx, ElementState* element,
7526 std::string const& point, Axis axis, int gap) {
7527 std::vector<DocNode const*> declared;
7528 for (DocNodePtr const& child : node.nodes("children")) {
7529 declared.push_back(child.get());
7530 }
7531 auto order = [this, point, declared] { return arrangedOrder(prefs.get().at(point), declared); };
7532 auto key = [](Value const& childId) { return keyOf(childId); };
7533 auto view = [this, ctx, declared](reactive::Signal<Value> const& row) -> Node {
7534 // The order lists only declared ids, so the lookup always finds the child.
7535 auto const found = std::ranges::find(declared, textOf(row.peek(), session.formatting()), &DocNode::id);
7536 return lower(**found, ctx);
7537 };
7538 ForEach spec{
7539 .model = std::make_shared<TypedForEach<Value> const>(std::move(order), std::move(key), std::move(view)),
7540 .axis = axis,
7541 .gap = gap};
7542 commonOf(node, ctx, element, spec.common);
7543 return makeNode(std::move(spec));
7544}
7545
7546// One row per child of the arranged container, hidden ones included: a checkbox shows or hides it, and two buttons
7547// move it a place. Each edit stores the whole arrangement, so the stored list always names every child.
7548inline Node ScreenCore::lowerArrangeEditor(DocNode const& node, LowerCtx const& ctx, std::string const& point,
7549 CustomizePoint const& spec, std::function<void(Value)> const& choose) {
7550 // The loader checked that `of` names a column or row of this screen.
7551 DocNode const* const container = elementNode(*_doc->view, spec.of);
7552 std::vector<DocNode const*> declared;
7553 for (DocNodePtr const& child : container->nodes("children")) {
7554 declared.push_back(child.get());
7555 }
7556 auto const current = [this, point, declared] { return arrangedChildren(prefs.get().at(point), declared); };
7557 auto const place = [current](std::string const& childId) {
7558 auto const children = current();
7559 return static_cast<std::size_t>(std::ranges::find(children, childId, &ArrangedChild::id) - children.begin());
7560 };
7561 // Applies an edit to the arrangement as it stands, read untracked: the edit is a transition, not a binding.
7562 auto const edit = [this, current, choose](std::string const& childId, auto const& change) {
7563 auto children = runtime().untracked(current);
7564 auto const found = std::ranges::find(children, childId, &ArrangedChild::id);
7565 change(children, static_cast<std::size_t>(found - children.begin()));
7566 choose(arrangementValue(children));
7567 };
7568 auto rows = [current] {
7569 std::vector<Value> ids;
7570 for (ArrangedChild const& child : current()) {
7571 ids.emplace_back(child.id);
7572 }
7573 return ids;
7574 };
7575 auto key = [](Value const& childId) { return keyOf(childId); };
7576 auto view = [this, current, place, edit](reactive::Signal<Value> const& row) -> Node {
7577 std::string const childId = textOf(row.peek(), session.formatting());
7578 Checkbox shown{.label = childId};
7579 shown.checked = slotProp<bool>([current, childId] {
7580 auto const children = current();
7581 return !std::ranges::find(children, childId, &ArrangedChild::id)->hidden;
7582 });
7583 shown.onToggle = [edit, childId](bool checked) {
7584 edit(childId, [checked](std::vector<ArrangedChild>& children, std::size_t index) {
7585 children.at(index).hidden = !checked;
7586 });
7587 };
7588 Button moveUp{.label = std::string{"\xE2\x86\x91"}};
7589 moveUp.onClick = [edit, childId] {
7590 // Clamped rather than refused: at the edge the child swaps with itself, which stores the same order.
7591 edit(childId, [](std::vector<ArrangedChild>& children, std::size_t index) {
7592 std::swap(children.at(index), children.at(std::max(index, std::size_t{1}) - 1));
7593 });
7594 };
7595 moveUp.common.enabled = slotProp<bool>([place, childId] { return place(childId) > 0; });
7596 moveUp.common.a11y.name = "Move " + childId + " up";
7597 moveUp.common.testId = "arrange-up-" + childId;
7598 Button moveDown{.label = std::string{"\xE2\x86\x93"}};
7599 moveDown.onClick = [edit, childId] {
7600 edit(childId, [](std::vector<ArrangedChild>& children, std::size_t index) {
7601 std::swap(children.at(index), children.at(std::min(index + 1, children.size() - 1)));
7602 });
7603 };
7604 moveDown.common.enabled =
7605 slotProp<bool>([current, place, childId] { return place(childId) + 1 < current().size(); });
7606 moveDown.common.a11y.name = "Move " + childId + " down";
7607 moveDown.common.testId = "arrange-down-" + childId;
7608 shown.common.testId = "arrange-shown-" + childId;
7609 return ui::row(Row{.children = {ui::checkbox(std::move(shown)), ui::button(std::move(moveUp)),
7610 ui::button(std::move(moveDown))},
7611 .gap = 1});
7612 };
7613 ForEach editor{.model =
7614 std::make_shared<TypedForEach<Value> const>(std::move(rows), std::move(key), std::move(view))};
7615 commonOf(node, ctx, nullptr, editor.common);
7616 return makeNode(std::move(editor));
7617}
7618
7619inline Node ScreenCore::lowerCustomize(DocNode const& node, LowerCtx const& ctx) {
7620 std::string const point{node.name("point")};
7621 // A loaded document's customize node names a declared point, and every point has a choice in `prefs`.
7622 CustomizePoint const& spec = _doc->customize.at(point);
7623 auto choose = [this, point](Value value) {
7624 session.asUser([&] {
7625 ValueRecord next = prefs.peek();
7626 next.insert_or_assign(point, value);
7627 prefs.set(std::move(next));
7628 });
7629 if (host != nullptr) {
7630 host->preferenceChanged(_doc->id, point, value);
7631 }
7632 };
7633 if (spec.kind == "arrange") {
7634 return lowerArrangeEditor(node, ctx, point, spec, choose);
7635 }
7636 if (spec.kind == "choice") {
7637 Select select;
7638 std::vector<SelectOption> options;
7639 options.reserve(spec.options.size());
7640 for (std::string const& option : spec.options) {
7641 options.push_back(SelectOption{.key = Key{option}, .label = option});
7642 }
7643 select.options = std::move(options);
7644 select.selected = slotProp<std::optional<Key>>([this, point]() -> std::optional<Key> {
7645 Value const& chosen = prefs.get().at(point);
7646 return chosen.isNull() ? std::nullopt : std::optional<Key>{keyOf(chosen)};
7647 });
7648 select.onSelect = [choose](Key const& key) { choose(valueOfKey(key)); };
7649 commonOf(node, ctx, nullptr, select.common);
7650 return ui::select(std::move(select));
7651 }
7652 if (spec.kind == "value" && (spec.type.kind == TypeKind::String || spec.type.isAny())) {
7653 TextInput input;
7654 input.value =
7655 slotProp<std::string>([this, point] { return textOf(prefs.get().at(point), session.formatting()); });
7656 input.onChange = [choose](std::string text) { choose(Value{std::move(text)}); };
7657 commonOf(node, ctx, nullptr, input.common);
7658 return ui::textInput(std::move(input));
7659 }
7660 Text placeholder{.text = point, .role = TextRole::Muted};
7661 commonOf(node, ctx, nullptr, placeholder.common);
7662 return ui::text(std::move(placeholder));
7663}
7664// NOLINTEND(misc-no-recursion)
7665
7666} // namespace detail
7667
7668inline BorrowedModel Screen::lend(std::string_view alias) const {
7669 detail::NameRef const* const found = _scope->find(alias);
7670 if (found == nullptr || found->category != detail::NameCategory::Model) {
7671 return {};
7672 }
7673 detail::ModelAlias& model = *found->model;
7674 return BorrowedModel{.channel = detail::channelOf(model, *_core),
7675 .token = _core->runtime().untracked([&] { return model.token->get(); })};
7676}
7677
7678inline std::expected<std::unique_ptr<Screen>, Refusal> Interpreter::mount(std::shared_ptr<Document const> doc,
7679 ValueRecord const& params,
7680 IViewBackend& backend,
7681 MountOptions const& options) {
7682 auto refuse = [&](std::string path, std::string reason) {
7683 return std::unexpected(Refusal{.document = doc->id, .path = std::move(path), .reason = std::move(reason)});
7684 };
7685 if (!doc->view) {
7686 return refuse("", "the document has no view");
7687 }
7688 ValueRecord given;
7689 for (auto const& [name, type] : doc->params) {
7690 auto const found = params.find(name);
7691 Value value = found == params.end() ? Value{} : found->second;
7692 if (!type.admits(value)) {
7693 return refuse(pointerAppend("/params", name), value.isNull()
7694 ? "param `" + name + "` is missing"
7695 : "param `" + name + "` is not a " + type.name());
7696 }
7697 given.insert_or_assign(name, std::move(value));
7698 }
7699 for (ModelDecl const& model : doc->scope.models) {
7700 if (model.instance == InstanceMode::Borrowed && !options.borrowed.contains(model.name)) {
7701 return refuse(model.path, "alias `" + model.name + "` borrows a model its opener did not lend");
7702 }
7703 }
7704 reactive::Runtime& runtime = _session->runtime();
7705 std::unique_ptr<Screen> screen{new Screen{}};
7706 screen->_core = std::make_unique<detail::ScreenCore>(doc, *_session, *_dispatcher, _options, options);
7707 detail::ScreenCore& core = *screen->_core;
7708 if (options.keepStateOf != nullptr) {
7709 core.keptStates = options.keepStateOf->ownState();
7710 }
7711 screen->_scope = std::make_shared<detail::RtScope>(core, nullptr, nullptr, options.depth);
7712 core.rootScope = screen->_scope;
7713 detail::RtScope& scope = *screen->_scope;
7714 scope.submitted = &core.submitted;
7715 scope.showErrors = doc->showErrors;
7716 for (auto const& [name, value] : given) {
7717 detail::NameRef ref;
7718 ref.category = detail::NameCategory::Param;
7719 ref.param = value;
7720 scope.names.insert_or_assign(name, std::move(ref));
7721 }
7722 std::vector<Commands const*> lists;
7723 for (auto const& [key, commands] : doc->lifecycle) {
7724 static_cast<void>(key);
7725 lists.push_back(&commands);
7726 }
7727 for (auto const& [chord, commands] : doc->keys) {
7728 static_cast<void>(chord);
7729 lists.push_back(&commands);
7730 }
7731 for (DocNodePtr const& action : doc->actions) {
7732 if (Commands const* const commands = action->event("onClick")) {
7733 lists.push_back(commands);
7734 }
7735 }
7736 Node root = runtime.untracked([&] {
7737 core.declare(scope, &doc->scope, {doc->view.get()}, lists);
7738 detail::LowerCtx const ctx{
7739 .scope = screen->_scope, .bindings = std::make_shared<detail::Bindings const>(), .boundary = nullptr};
7740 Node lowered = core.lower(*doc->view, ctx);
7741 screen->_headStatus = core.lowerBadgeParts(doc->status, ctx);
7742 screen->_headActions = core.lowerActionParts(doc->actions, ctx);
7743 if (!doc->keys.empty()) {
7744 std::vector<KeyBinding> keys;
7745 for (auto const& [chord, commands] : doc->keys) {
7746 keys.push_back(KeyBinding{
7747 .chord = chord, .onPress = [&core, ctx, list = &commands] { core.runEvent(list, ctx, Value{}); }});
7748 }
7749 Column column{.children = {std::move(lowered)}};
7750 column.common.keys = std::move(keys);
7751 lowered = ui::column(std::move(column));
7752 }
7753 return lowered;
7754 });
7755 screen->_mounted.emplace(runtime, backend, std::move(root), options.parent, options.depth + 1);
7756 screen->_core->focusElement = [mounted = &*screen->_mounted](std::string_view element) {
7757 return mounted->focus(element);
7758 };
7759 screen->runLifecycle("onMount");
7760 return screen;
7761}
7762
7763inline std::expected<std::unique_ptr<AppRoot>, Refusal> Interpreter::mountApp(std::shared_ptr<Document const> doc) {
7764 if (doc->id != "app") {
7765 return std::unexpected(
7766 Refusal{.document = doc->id, .path = "/id", .reason = "the app document's id is `app`"});
7767 }
7768 if (_session->appScope() != nullptr) {
7769 return std::unexpected(
7770 Refusal{.document = doc->id, .path = {}, .reason = "an app document is already mounted on the session"});
7771 }
7772 std::unique_ptr<AppRoot> root{new AppRoot{}};
7773 root->_core = std::make_unique<detail::ScreenCore>(doc, *_session, *_dispatcher, _options, MountOptions{});
7774 detail::ScreenCore& core = *root->_core;
7775 root->_scope = std::make_shared<detail::RtScope>(core, nullptr, nullptr, 0);
7776 core.rootScope = root->_scope;
7777 std::vector<Commands const*> lists;
7778 for (auto const& [key, commands] : doc->lifecycle) {
7779 static_cast<void>(key);
7780 lists.push_back(&commands);
7781 }
7782 _session->runtime().untracked([&] { core.declare(*root->_scope, &doc->scope, {}, lists); });
7783 _session->setAppScope(root->_scope.get());
7784 if (auto const found = doc->lifecycle.find("onMount"); found != doc->lifecycle.end()) {
7785 root->runCommands(found->second);
7786 }
7787 return root;
7788}
7789
7790} // namespace morph::ui
Justify
Where a stack places its spare length along its axis, while no shown child stretches.
Definition alignment.hpp:23
@ Center
Packed in the middle.
Definition alignment.hpp:25
@ Between
In equal gaps between the children, never less than the stack's gap.
Definition alignment.hpp:27
@ End
Packed at the end.
Definition alignment.hpp:26
@ Start
Packed at the start.
Definition alignment.hpp:24
Align
Where a child is placed across its container's axis: a column's horizontally, a row's vertically,...
Definition alignment.hpp:15
@ Center
Centred.
Definition alignment.hpp:18
@ End
At the end: right in a column, bottom in a row or grid.
Definition alignment.hpp:19
@ Start
At the start: left in a column, top in a row or grid.
Definition alignment.hpp:17
@ Stretch
A content child fills the cross extent; one its maximum stops sits at the start.
Definition alignment.hpp:16
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
Element element(std::string name)
An element of the view.
Definition author.hpp:1289
AnyHandle list(std::vector< AnyHandle > const &items)
{"list": [...]}: a list built from expressions.
Definition author.hpp:1123
AnyHandle env(std::string const &name)
A client environment value, {"env": name}: widthClass, locale, platform, online, backend.
Definition author.hpp:1209
Command command(std::string_view key, Json value)
A command with one key.
Definition author.hpp:2713
Node column(Nodes const &children, ContainerOptions const &options={})
A column node.
Definition author.hpp:3394
Handle< std::string > format(AnyHandle const &pattern, std::vector< AnyHandle > args)
format: a template with {0}, {1}, … placeholders.
Definition author.hpp:970
Handle< T > row()
The innermost row, {"row": ""}.
Definition author.hpp:1145
Command host(std::string const &name, AnyHandle const &args={})
host: a command the application registered with the client.
Definition author.hpp:2870
Json fieldOf(Json const &json, std::string_view field)
A field read below a named read.
Definition author.hpp:582
Command run(MutationHandle< Action > const &mutation, Body< Action > const &body={}, RunOptions const &options={})
run: runs a mutation.
Definition author.hpp:2755
AnyHandle prop(std::string const &name)
A prop of a component, inside the component's fallback.
Definition author.hpp:1228
AnyHandle session(std::string const &name)
A session attribute, {"session": name}.
Definition author.hpp:1214
AnyHandle content()
The content sizing, "content": as much as the content needs.
Definition author.hpp:1104
std::vector< TableColumn > cells(std::vector< Node > const &cells)
Columns from cells alone, without ids or labels.
Definition author.hpp:3686
Node collection(ModelHandle< Model > const &model, FormPresentation const &presentation={})
A collection node: the forms engine's list screen over an action.
Definition author.hpp:4307
Command open(Element const &dialog)
open: opens a dialog or drawer.
Definition author.hpp:2779
Node boundary(Nodes const &children, Nodes const &loading={}, Nodes const &error={})
A boundary node: loading until its queries have values, error when one failed without one.
Definition author.hpp:4142
Handle< std::int64_t > count(AnyHandle const &list)
count: a list's length.
Definition author.hpp:1000
AnyHandle event()
The payload of the event whose command list runs.
Definition author.hpp:1232
void actionPart(NodeWriter &node, std::optional< ActionPart > const &action)
Writes an action part.
Definition author.hpp:3973
constexpr Self self
The dialog or drawer a command list runs in.
Definition author.hpp:1295
Node form(ModelHandle< Model > const &model, FormOptions< Action > const &options={})
A form node: a schema form of the forms engine over an action (spec 2); each field takes its label fr...
Definition author.hpp:4271
@ Omit
Blank is "no value": the member is left out of the payload (the default).
Definition blank_as.hpp:19
ButtonEmphasis
How much a button stands out; each backend maps it to its theme.
@ Primary
The screen's main action.
@ Ghost
An action embedded in other content, with no chrome of its own.
@ Normal
An ordinary action.
@ Danger
An action that destroys or cannot be undone.
@ Item
An entry of a list: as wide as its line, its label at the start, unframed, and marked while selected.
Move-only handle representing the eventual result of an asynchronous operation.
static std::pair< Completion< T >, Promise > makeSettleable(::morph::exec::IExecutor *execPtr)
Constructs a Completion<T>/Promise<T> pair sharing one settleable state.
Central dispatcher that routes typed actions to an IBackend.
Definition bridge.hpp:747
static std::expected< CollectionModel, SchemaError > fromSchema(std::string_view viewJson)
Reads a view document.
The reactive state of one field: its draft and everything derived from it.
std::string const & draft() const
The draft, read-only. Tracked.
reactive::Computed< Encoding > const & encoded() const noexcept
The draft's encoding. Tracked.
reactive::Computed< bool > const & visible() const noexcept
Whether the field is shown: not x-hidden, and no visibleWhen naming it is False.
reactive::Computed< bool > const & required() const noexcept
Whether the field must be filled: required, or a requiredWhen naming it that is True.
reactive::Computed< bool > const & readonly() const noexcept
Whether the field is frozen: x-readonly, a frozen parent, or a readonlyWhen naming it that is True.
FieldSpec const & spec() const noexcept
The field's spec.
std::string const & label() const noexcept
The label: the explicit key's translation, else the derived key's (top level only),...
reactive::Computed< std::optional< FieldError > > const & error() const noexcept
Why the draft does not encode, if it does not. Tracked.
A field's spec and state, handed to an override: what it shows (the value, the draft,...
Definition overrides.hpp:28
static std::expected< FormModel, SchemaError > fromSchema(std::string_view actionType, std::string_view schemaJson, FieldOverlays const &overlays={})
Reads a schema document, with the overlays a document or an application states for its fields.
The reactive state of one form: a controller over a FormModel.
static std::expected< WizardModel, SchemaError > fromSchema(std::string_view wizardId, std::string_view wizardJson)
Reads a wizard document.
Definition wizard.hpp:59
A derived value: computed lazily on first read, cached, and recomputed only after a source it read re...
Definition signal.hpp:168
T const & get() const
Reads the value, bringing it up to date first, and subscribes the innermost tracked run.
Definition signal.hpp:198
The owner of a signal graph: every node takes one, and every operation runs on its owner.
Definition runtime.hpp:70
decltype(auto) untracked(F &&fn) const
Runs fn with tracking off: reads inside it subscribe nothing.
Definition runtime.hpp:136
Runs callbacks after a delay or on a period, on the runtime's owner executor.
virtual TimerHandle every(std::chrono::milliseconds period, std::function< void()> fn)=0
Runs fn every period, starting one period from now.
virtual TimerHandle after(std::chrono::milliseconds delay, std::function< void()> fn)=0
Runs fn once, delay from now.
Owns effects, computeds and adopted objects; destroys them in reverse creation order.
Definition scope.hpp:44
auto & computed(F &&fn)
Makes a Computed owned by this scope; its type is what fn returns.
Definition scope.hpp:127
Effect & effect(F &&fn)
Makes an Effect owned by this scope, at this scope's depth.
Definition scope.hpp:114
A source value. Reading it with get() inside a tracked run subscribes that run.
Definition signal.hpp:49
T const & get() const
Reads the value, subscribing the innermost tracked run.
Definition signal.hpp:76
void set(T value)
Replaces the value and notifies observers, unless it equals the current one and the policy is Equalit...
Definition signal.hpp:90
T const & peek() const noexcept
Reads the value without subscribing anything.
Definition signal.hpp:85
Owns one scheduled timer and cancels it when destroyed. Move-only.
Definition scheduler.hpp:19
RuntimeCore & core() const noexcept
The runtime core.
Definition graph.hpp:583
The app document mounted as the root scope every screen of the session shares (spec 6 §5).
~AppRoot()
Runs onUnmount, takes the scope off the session, and releases everything.
std::vector< Refusal > const & diagnostics() const noexcept
The diagnostics reported so far.
Value read(Json const &expression) const
Evaluates an expression in the app document's scope.
std::shared_ptr< ModelChannel > bind(std::string const &typeId, bridge::BindSharing sharing, std::string const &instanceKey) override
Binds through a RawHandler.
BridgeDispatcher(bridge::Bridge &bridge, exec::IExecutor *cbExec, Evaluator evaluator={})
Dispatches through bridge, and evaluates through evaluator.
A widget with children. A factory given this container appends the new widget as its last child.
Definition backend.hpp:103
The interpreter's view of the bridge: binds a model by its type id, privately or to a shared instance...
virtual std::shared_ptr< ModelChannel > bind(std::string const &typeId, bridge::BindSharing sharing, std::string const &instanceKey)=0
Binds a model.
Makes widgets. Each factory appends the new widget to parent, or makes a root when it is null,...
Definition backend.hpp:810
The development inspector over one mounted screen: its mounted nodes, its queries and mutations,...
Definition inspector.hpp:70
What the application answers for the commands a document cannot carry out alone (spec 6)....
virtual bool saveFile(InlineFile const &file, std::string const &option, std::function< void(std::expected< Value, std::string >)> done)
Saves a file a document holds the bytes of, for download from bytes.
virtual void setLocale(std::string const &locale)
Stores the active locale as a preference. The interpreter has already re-resolved every t.
virtual void signOut()
Ends the session. The interpreter has already cleared the session's query cache.
virtual void permissionsStale()
A call was refused with error kind denied: the permitted set is a snapshot, so the host asks the serv...
virtual bool hostCommand(std::string const &name, Value const &args)
Runs a command the application registered (host/1).
virtual bool uploadFor(UploadRequest const &request, std::function< void(Value)> progress, std::function< void(std::expected< Value, std::string >)> done)
Sends a file through the server's file side channel, knowing the mutation that will reference it....
virtual bool setAppValue(std::string const &name, Value const &value)
Writes app.name with set (spec 6 §5).
virtual bool navigate(NavigateRequest const &request, std::function< void(Value)> onResult, std::function< void()> onCancel)
Opens another screen.
virtual bool download(Value const &file, std::string const &option, Value const &name, std::function< void(std::expected< Value, std::string >)> done)
Fetches a file by reference to save it, or to a local temporary reference for a viewer.
virtual bool readFile(Value const &file, std::size_t maxBytes, std::function< void(std::expected< InlineFile, std::string >)> done)
Reads a file's bytes for upload into bytes. A host reads the file's size first and refuses one over m...
virtual void resetPrefs(std::string const &screen, std::string const &point)
The document reset a customization point, or all of them; the host forgets the stored choice.
virtual void preferenceChanged(std::string const &screen, std::string const &point, Value const &value)
The user chose at a customization point through a customize editor; the host stores the choice.
virtual bool upload(Value const &file, std::function< void(Value)> progress, std::function< void(std::expected< Value, std::string >)> done)
Sends a file through the server's file side channel (files/1).
virtual bool closeSelf(std::string const &screen, std::optional< Value > result)
Ends the screen that ran return or close: self.
virtual bool signIn(Value const &reply)
Installs the session the reply of a sign-in mutation carries, and reloads the catalog.
virtual bool notify(std::string const &text, std::string const &role)
Shows a transient message.
virtual bool switchBackend(std::string const &name, std::function< void(std::expected< void, std::string >)> done)
Switches to a backend the application's source lists (AppSource::switchBackend, spec 6 §9).
Mounts UI documents (spec 5): one per client process or window, over a session and a dispatcher.
std::expected< std::unique_ptr< AppRoot >, Refusal > mountApp(std::shared_ptr< Document const > doc)
Mounts the app document as the session's root scope (spec 6 §5).
Interpreter(Session &session, Dispatcher &dispatcher, InterpreterOptions options={})
An interpreter.
std::expected< std::unique_ptr< Screen >, Refusal > mount(std::shared_ptr< Document const > doc, ValueRecord const &params, IViewBackend &backend, MountOptions const &options={})
Mounts a screen.
One bound model alias: the instance a document's alias names, and the calls made through it.
virtual async::Completion< std::string > execute(std::string const &actionId, std::string const &bodyJson, CallOptions options)=0
Sends one action.
A node property: a constant, or a slot — a reactive read, with a stable id when it was given one.
Definition view.hpp:106
A mounted screen: its scopes, its view mounted through Mounted, and what its host asks of it.
bool dirty() const
Evaluates the document's dirty.
bool setPreference(std::string const &point, Value value)
Sets the user's choice at a customization point, as the preferences store loaded or the user made it....
~Screen()
Runs onUnmount, then unmounts.
std::vector< HeadAction > actions() const
The head's actions that are visible now, in order, each with whether it is enabled....
bool runAction(std::string_view actionId)
Runs a head action's onClick as a user event in the screen scope, in one batch.
Document const & document() const noexcept
The screen's document, whose customize block names the points a preferences store loads.
std::string const & id() const noexcept
The document's id.
Value read(Json const &expression) const
Evaluates an expression in the screen's scope: what a host reads (title, identity),...
BorrowedModel lend(std::string_view alias) const
What an alias of this screen lends to a screen it opens that borrows it.
bool requestClose()
Runs onClose, as the host does before it closes the screen.
Value identity() const
Evaluates the document's identity.
Value subtitle() const
Evaluates the document's subtitle, the head's line under the title.
Value title() const
Evaluates the document's title.
bool pending() const
Whether the screen has work a close waits for: an open dialog or drawer, a mutation run,...
std::vector< Refusal > const & diagnostics() const noexcept
The diagnostics reported so far, each path and reason once.
void runLifecycle(std::string_view key)
Runs a lifecycle command list: onSave, onBackendChange, or any other the document declares.
std::vector< HeadBadge > status() const
The head's status badges that are visible now, in order. A tracked read.
Widget & root() const
The root widget.
reactive::Runtime & runtime() const noexcept
The reactive runtime the screen runs on.
bool declares(std::string_view key) const
Whether the document declares a lifecycle command list.
std::shared_ptr< bool const > beginClose()
Runs onClose, and hands back the flag its cancelClose sets.
What screens of one client session share (spec 5 §5, §12b): the query cache, the mounted queries,...
std::function< HostCommandResult(std::string const &, Value const &)> const & hostCommandRunner() const noexcept
What runs host commands.
void fetch(std::shared_ptr< detail::CacheEntry > const &entry, std::shared_ptr< ModelChannel > const &channel)
Sends an entry's request under a new generation; a reply of an older generation is dropped.
static std::string cacheKey(std::string const &token, std::string const &typeId, std::string const &action, bool evaluate, std::string const &body)
The cache key of a query.
detail::RtScope const * appScope() const noexcept
The app document's scope, which {"ref": "app.name"} reads and {"set": "app.name"} writes; set by Inte...
void prefetch(std::shared_ptr< ModelChannel > const &channel, std::string const &token, std::string const &typeId, std::string const &action, Value const &body)
Starts a query's request before its screen mounts, so the screen mounts on a warm cache.
void setHostValues(ValueRecord values)
Sets the host values the application registered.
void forgetQuery(detail::QueryRuntime &query)
Unregisters a mounted query.
void showLayers(detail::CacheEntry &entry)
Recomputes what an entry shows from its confirmed value and its layers.
void setHostCommands(HostCommands commands)
Sets the client's host commands: the names {"can": "host:name"} answers for, and what runs them....
void attachQuery(detail::QueryRuntime &query)
Registers a mounted query, which a reconnect and a backend switch refetch.
void asUser(std::function< void()> const &body)
Runs body as a user event: what it writes counts as the user's.
void reconnected()
The connection to the server was made again (spec 8 §7, "Reconnect"): sends the whole watch set with ...
FormatContext const & formatting() const
The formatting facts. A tracked read.
void setAppScope(detail::RtScope const *scope) noexcept
Installs or clears the app document's scope. Called by AppRoot, nothing else.
static void markStale(detail::CacheEntry &entry)
Marks an entry stale. While its fetch is in flight, the entry also refetches once that fetch settles,...
void setPermitted(std::set< std::string, std::less<> > permitted)
Sets the action ids the principal may run, as the catalog reply lists them; {"can": ....
std::string classify(std::exception_ptr const &error) const
The error kind of a failure.
std::string privateToken()
A fresh instance token for a private alias: what the cache key holds for its instance.
void deliverEvent(wire::EventBody const &event)
Applies a server event to the cache (spec 8 §9).
std::shared_ptr< detail::CacheEntry > acquire(std::string const &token, std::string const &typeId, std::string const &action, bool evaluate, Value const &body)
Finds or makes an entry and counts one more user.
void noteWrite() noexcept
Records a write to a document source.
void release(std::shared_ptr< detail::CacheEntry > const &entry)
Counts one user less; an entry nobody uses is dropped after keepUnused.
wire::WatchBody watchSet() const
The topics the cache holds now: every record an entry holds, and the keyed shared instance of every e...
void actionSucceeded(std::string const &typeId, std::string const &actionId, Value const &reply, ReplyUse use, std::string const &instanceToken)
A call of actionId on a model of typeId succeeded: applies the action's effects to the cache (spec 8 ...
void setSessionValues(ValueRecord values)
Sets the session values (principal, userName, …).
SessionOptions const & options() const noexcept
The options.
void detachScreen(detail::ScreenCore &screen)
Forgets a screen or app document.
void attachScreen(detail::ScreenCore &screen)
Registers a mounted screen or app document, whose onBackendChange a backend switch runs.
void clear()
Drops every cache entry: signOut and a principal change (spec 5 §5, "Isolation").
void setLocale(std::string locale)
Changes the active locale, keeping the other formatting facts.
std::size_t cacheSize() const noexcept
The number of entries in the cache.
Session(reactive::Runtime &runtime, SessionOptions options={})
An empty session.
void setFormatting(FormatContext format)
Replaces the formatting facts; every t and display function re-evaluates.
bool lastWriteByUser() const noexcept
Whether the newest write to a document source was made by a user event (spec 5 §6,...
bool can(std::string_view action) const
Whether the principal may run an action, or the client can run a host command now....
void setEnvironment(ValueRecord values)
Sets the env values (widthClass, locale, platform, online, backend).
void applyChanges(wire::ChangesBody const &changes)
Applies a changes event's payload to the cache, in one reactive batch.
void confirm(detail::CacheEntry &entry, Value const &confirmed)
Writes an entry's confirmed value and re-applies its optimistic layers over it.
void setTranslations(render::TranslationProvider translations)
Replaces the message catalogue, keeping the other formatting facts; every t re-resolves.
reactive::Runtime & runtime() const noexcept
The runtime.
void backendChanged(std::string const &backend)
Carries out a committed backend switch (spec 6 §9), in one batch: every mounted screen's onBackendCha...
Value client(ExprKind form, std::string_view name) const
Reads env, session or host. A tracked read.
std::chrono::milliseconds now() const
The clock.
The document's typed value (spec 5 §3): what every expression evaluates to.
Definition value.hpp:157
Quantity const * asQuantity() const noexcept
The quantity, if held.
Definition value.hpp:232
Decimal const * asDecimal() const noexcept
The decimal, if held.
Definition value.hpp:228
bool isNull() const noexcept
Whether the value is null.
Definition value.hpp:212
Value const * field(std::string_view name) const
A field of a record.
Definition value.hpp:255
ValueRecord const * asRecord() const noexcept
The record, if held.
Definition value.hpp:247
time::DateTime const * asTimestamp() const noexcept
The timestamp, if held.
Definition value.hpp:236
bool const * asBool() const noexcept
The bool, if held.
Definition value.hpp:216
std::int64_t const * asInt() const noexcept
The int, if held.
Definition value.hpp:220
std::string const * asString() const noexcept
The string, if held.
Definition value.hpp:224
ValueList const * asList() const noexcept
The list, if held.
Definition value.hpp:240
A retained widget. A new widget is visible, enabled, content-sized, not draggable and not a drop targ...
Definition backend.hpp:34
AnyHandle get(std::string const &value) const
One of the element's values.
Definition author.hpp:1248
Handle< bool > open() const
Whether a dialog or drawer is open.
Definition author.hpp:1272
std::string const & name() const noexcept
The element's id.
Definition author.hpp:1243
AnyHandle draft() const
An input's text while it is edited.
Definition author.hpp:1256
AnyHandle selection() const
A table's selection.
Definition author.hpp:1268
Handle< Member > at(Member Class::*member) const
A field of the value, by member pointer: a renamed field is a compile error.
Definition author.hpp:703
Node visible(AnyHandle const &condition) const
Whether the node shows.
Definition author.hpp:1823
A mutation at run time (spec 5 §5): concurrency, optimistic writes and the reply's effects.
void run(Value body, std::shared_ptr< Bindings const > bindings, RunDone done)
Runs it, queues the run or refuses it, as its concurrency says.
Value result() const
The last applied result. A tracked read.
Value error() const
The last applied failure's text, or null. A tracked read.
bool pending() const
Whether a run is in flight or queued. A tracked read.
~MutationRuntime()
Drops the replies still to come.
MutationDecl const & decl() const noexcept
The declaration.
Value errorKind() const
The last applied failure's kind, or null. A tracked read.
std::string const & typeId() const noexcept
The model type.
MutationRuntime(MutationDecl const &decl, RtScope &scope, ModelAlias *alias)
A mutation of scope.
A query at run time (spec 5 §5): a key over the scope, an entry of the session's cache,...
std::string const & typeId() const noexcept
The model type.
bool settled() const
Whether the query has a value or a failure for its current key, or is idle. A tracked read,...
std::shared_ptr< ModelChannel > channel() const
The channel its alias is bound to now.
bool pending() const
Whether a request is in flight or waiting out its debounce. A tracked read.
QueryRuntime(QueryDecl const &decl, RtScope &scope, ModelAlias *alias)
A query of scope, not yet started.
void detach()
Forgets its entry without releasing it: the session dropped the cache.
Value value() const
The value: the entry's, kept from the previous key while the new one has none. A tracked read.
Value error() const
The failure's text, null when none. A tracked read.
void refetch()
Refetches the current key now, ending a debounce wait.
std::shared_ptr< CacheEntry > const & entry() const
The current entry. A tracked read.
void invalidate()
Marks its entry stale and refetches it.
void refetchCurrent()
Fetches the key its body has now, inside the batch that changed the body: switches to that key's entr...
QueryDecl const & decl() const noexcept
The declaration.
Value errorKind() const
The failure's kind, null when none. A tracked read.
~QueryRuntime()
Stops its timers and releases its entry.
void start()
Starts it: makes its key and the Effect that follows it, and its refresh timer.
One document scope at run time: the screen, a dialog's or drawer's content, a switch case or a row.
reactive::Signal< Value > const * innermostRow() const
The innermost row, from this scope outwards.
std::vector< std::unique_ptr< MutationRuntime > > mutations
Its mutations.
RtScope(ScreenCore &screen, std::shared_ptr< RtScope > enclosing, reactive::Signal< Value > const *current, std::size_t depth)
An empty scope.
reactive::Signal< Value > const * row
The row of a row scope.
ScreenCore & core
The screen.
RtScope const & policyScope() const
The innermost scope that decides when errors show and is submitted: a dialog's, or the screen's.
std::vector< std::unique_ptr< QueryRuntime > > queries
Its queries.
std::vector< std::unique_ptr< StateSlot > > states
Its state.
reactive::Signal< bool > * submitted
submitted of a scope that decides when errors show.
~RtScope()
Releases what it owns, and tells the dialog it belongs to.
ElementState * owner
The dialog or drawer whose content it is.
std::map< std::string, NameRef, std::less<> > names
Its names.
std::string showErrors
touched, submit or always, for a scope that decides it.
std::vector< std::unique_ptr< TransferState > > transfers
Its transfers.
NameRef const * find(std::string_view name) const
Resolves a name, innermost scope first.
reactive::Scope nodes
The reactive nodes it owns; declared last, so they go first.
std::vector< std::unique_ptr< CheckRuntime > > checks
Its checks.
bool ready() const
Whether every element of the scope is valid and no blocking check of it fails. A tracked read.
std::shared_ptr< RtScope > parent
The enclosing scope.
std::vector< std::unique_ptr< ElementState > > elements
Its elements, in document order.
std::vector< std::unique_ptr< ModelAlias > > models
Its model aliases.
The Environment the evaluator reads through: a scope chain and the names bound at one place.
void report(std::string_view path, std::string_view reason) const override
Reports to the screen's diagnostics.
ScopeEnv(RtScope const &scope, Bindings const *bindings, Value const *row=nullptr)
Reads scope.
Value pref(std::string_view point, std::span< std::string const > path) const override
Reads a customization choice.
Value ref(std::string_view head, std::span< std::string const > path) const override
Reads a name: a binding, then the scope chain.
Value control(ExprKind form, std::string_view name) const override
Reads a query or mutation form.
Value row(std::span< std::string const > path) const override
Reads the innermost row.
Value client(ExprKind form, std::string_view name) const override
Reads env, session or host.
FormatContext const & formatting() const override
The session's formatting facts.
One mounted screen's interpreter: its document, diagnostics, slots and preferences,...
std::vector< BadgeModel > lowerBadgeParts(std::span< DocNodePtr const > parts, LowerCtx const &ctx)
Lowers a screen head's headBadge parts, bound in ctx's scope.
std::function< bool(std::string_view)> focusElement
Moves focus to a mounted element by its id; empty until the screen is mounted, and again once it is u...
void runOne(Command const &command, CommandCtx const &ctx, Done done)
Runs one command.
void addPending(int delta)
Counts pending work in or out.
void runEvent(Commands const *commands, LowerCtx const &ctx, Value event)
Runs a command list for an event of the user's.
void declare(RtScope &scope, ScopeDecl const *decl, std::vector< DocNode const * > const &content, std::vector< Commands const * > const &commands)
Declares a scope's names and starts its reactive parts.
reactive::Signal< bool > submitted
The screen's submitted.
Session & session
The session.
InterpreterHost * host
The host; may be nullptr.
Value inputValue(ElementState const &element) const
The current value of an input, checkbox or select: what required tests.
ValueRecord prefDefaults
The points' defaults.
reactive::Runtime & runtime() const noexcept
The runtime.
reactive::Signal< ValueRecord > prefs
The customization choices, by point.
Value elementValue(ElementState const &element, std::string_view name) const
Reads an element value. A tracked read.
bool const recording
Whether this screen records what the inspector reads: its session's developmentTools,...
std::weak_ptr< RtScope > rootScope
The document's own scope, once mounted.
ActionModels lowerActionParts(std::span< DocNodePtr const > parts, LowerCtx const &ctx)
Lowers a list of actionButton parts into reactive models: label, icon, enabled and visible bound in c...
static Node actionBarOf(ActionModels actions)
An overlay's actions as the action bar its footer shows: a button per action, test id action_<id>,...
std::set< std::string, std::less<> > reported
The paths and reasons already reported.
Document const & doc() const noexcept
The document.
std::vector< LoweredNode > loweredNodes
Every node lowered, in lowering order; an entry whose scope is gone is pruned as more are added.
std::vector< Refusal > diagnostics
The diagnostics reported, in order.
void runList(Commands const &commands, CommandCtx const &ctx, Done done)
Runs a command list: each command in order in one batch; done when every command in it is.
std::size_t depth
The screen scope's reactive depth.
Dispatcher & dispatcher
The dispatcher.
Node lower(DocNode const &node, LowerCtx const &ctx)
Lowers a document node to a view node.
std::deque< CommandLogEntry > commandLog
The latest command lists run, oldest first, at most kCommandLogSize.
bool inspecting
Set while the inspector evaluates, so that an expression error it meets is not reported.
ScreenCore(std::shared_ptr< Document const > doc, Session &owner, Dispatcher &calls, InterpreterOptions const &options, MountOptions const &mount)
The core of a screen about to mount.
void report(std::string_view path, std::string_view reason)
Reports a diagnostic once per path.
std::map< std::string, BorrowedModel, std::less<> > borrowed
The models the opener lends.
void initState(RtScope const &scope, StateSlot &slot) const
Makes a state's signal from its init, once.
std::function< void(Refusal const &)> onDiagnostic
The diagnostic callback.
std::map< std::string, KeptState, std::less<> > keptStates
The states of the own scope of the screen this one replaces, by name (MountOptions::keepStateOf).
reactive::Signal< int > pendingWork
The open dialogs and drawers, the mutation runs and the transfers in flight, across every scope of th...
~ScreenCore()
Takes the screen off the session, so a later backend switch does not run its onBackendChange.
SlotId nextSlot() noexcept
The next slot id: the interpreter numbers every bound property of the document.
std::optional< Value > keptState(RtScope const &scope, StateDecl const &decl) const
The value a state of the screen's own scope keeps from the screen it replaces: that screen's value of...
void runLifecycleOf(std::string_view key)
Runs a lifecycle list in the document's own scope, as a screen's or app document's own command list.
std::vector< RtScope * > scopes
The screen's live scopes, in creation order.
Node lowerAll(std::span< DocNodePtr const > nodes, LowerCtx const &ctx)
Lowers a node list into one node: the node itself, or a column of them.
void take()
Counts the transfer, unless it has already settled.
TransferHold(ScreenCore &core, std::weak_ptr< bool > alive)
A hold that counts nothing yet.
void release()
Gives the count back, if one was taken and the screen is still there; the transfer counts no more.
Collections, headless: a v-* view document as a reactive list of rows, the table source the table eng...
collectionView: a CollectionSession rendered as a view tree (ui::Node): the title and collection acti...
morph::ui::DevelopmentTools: whether a client runs in development mode (spec 6 §21)....
DevelopmentTools
Whether a session's screens record what the development inspector reads, and whether a frontend opens...
Definition dev_tools.hpp:14
@ On
Each screen records its lowered nodes, its live scopes and its latest command lists.
Definition dev_tools.hpp:16
@ Off
Nothing is recorded, no inspector is handed out, and the inspector's shortcut does nothing.
Definition dev_tools.hpp:15
The UI document (spec 5 §3): its model — declarations, commands, view nodes, components and customiza...
@ Exclusive
The run is refused.
Definition document.hpp:218
@ Serial
Runs queue, one queue per serialKey.
Definition document.hpp:219
InstanceMode
How a model alias binds its instance (spec 5 §5).
Definition document.hpp:146
@ Borrowed
The opener's binding of an alias ({"from": alias}), for a screen opened as a dialog.
Definition document.hpp:151
@ Private
An instance of the scope's own, released when the scope unmounts.
Definition document.hpp:148
@ Keyed
The shared instance an expression's key selects.
Definition document.hpp:150
@ Default
None named: shared when the catalog marks the model stateless, private otherwise.
Definition document.hpp:147
@ Shared
A stateless model's instance, its queries cached with other screens'; refused for a stateful model.
Definition document.hpp:149
NameCategory
What a name in a scope declares.
@ Transfer
A transfer id of upload.
std::vector< Command > Commands
A command list, run as one batch.
Definition document.hpp:239
@ User
Only changes caused by the user's own edits.
Definition document.hpp:335
KindSpec const * findKind(std::string_view kind, bool part)
Looks up a node kind or a part kind.
Definition document.hpp:990
bool listed(std::span< std::string_view const > names, std::string_view name)
Whether a list of names holds one.
std::shared_ptr< DocNode const > DocNodePtr
A shared, immutable view node.
Definition document.hpp:387
CommandKind
The commands of ui/1 (spec 5 §8, spec 6).
Definition document.hpp:242
@ ResetPrefs
{"resetPrefs": "point" | "all"}
Definition document.hpp:254
@ SignIn
{"signIn": {"from": "m"}}
Definition document.hpp:262
@ Navigate
{"navigate": "screen" | {"route": e}, "params": e, "as": ..., "onResult": [...], ....
Definition document.hpp:249
@ Reset
{"reset": "name"}
Definition document.hpp:244
@ Set
{"set": "name.path", "value": e}
Definition document.hpp:243
@ Retry
{"retry": true}
Definition document.hpp:261
@ If
{"if": [cond, [...], [...]]}
Definition document.hpp:259
@ Return
{"return": e}
Definition document.hpp:250
@ Refetch
{"refetch": "q"}
Definition document.hpp:246
@ Notify
{"notify": e, "role": "success"}
Definition document.hpp:256
@ SignOut
{"signOut": true}
Definition document.hpp:263
@ All
{"all": [...]}
Definition document.hpp:258
@ Seq
{"seq": [...]}
Definition document.hpp:257
@ Host
{"host": "name", "args": e?}
Definition document.hpp:252
@ Open
{"open": "dialog"}
Definition document.hpp:247
@ Run
{"run": "m", "body": e?, "as": "x"?, "each": e?}
Definition document.hpp:245
@ CancelClose
{"cancelClose": true}
Definition document.hpp:260
@ SwitchBackend
{"switchBackend": "name"}
Definition document.hpp:253
@ Close
{"close": "dialog" | "self"}
Definition document.hpp:248
@ Focus
{"focus": "id"}
Definition document.hpp:251
@ SetLocale
{"setLocale": "de"}
Definition document.hpp:255
The expr/1 expression language of the UI document: parsing a JSON expression into an Expr tree,...
std::string unitDisplay(std::string const &unit, UnitTable const &units)
The display text of a unit.
std::expected< ExprPtr, Refusal > parseExpression(Json const &json, std::string const &path="")
Parses one expression.
std::expected< std::vector< std::string >, std::string > splitPath(std::string_view text)
Splits head.a.b into its segments.
Value evaluate(Expr const &expr, Environment const &env)
Evaluates an expression.
ExprKind
The forms of an expr/1 expression (spec 5 §4).
@ ErrorKind
{"errorKind": "m"}: the kind of a query's or mutation's error.
@ Pending
{"pending": "q"}: whether a query or mutation is in flight.
@ Ref
{"ref": "name.path"}.
@ Query
{"query": "q"}: a query's value.
@ Session
{"session": "name"}: a session attribute.
@ Result
{"result": "m"}: a mutation's last result.
@ Error
{"error": "q"}: a query's or mutation's error text.
@ Env
{"env": "name"}: the client environment.
@ Can
{"can": "m"}: whether the principal may run a mutation or query.
@ Literal
A JSON scalar, or {"lit": v}.
Value walk(Value const &value, std::span< std::string const > path)
Reads a path out of a value: a record's field by name, a list's element by index.
std::shared_ptr< Expr const > ExprPtr
A shared, immutable expression node.
FormModel: the immutable runtime model of one action's form, read from the schema JSON schemaJson<A>(...
@ Object
A nested object.
@ ObjectArray
A list of nested objects.
FormSession: the reactive state of one form over a FormModel — drafts, encoded values,...
std::function< async::Completion< std::string >(std::string_view actionType, std::string bodyJson)> Submitter
Executes a form's action: the action type and its JSON body in, the JSON reply out.
formView: a FormSession rendered as a view tree (ui::Node), so one form draws on every frontend.
void transfersOf(Commands const &commands, std::vector< std::string > &out)
The transfers of the upload commands in a list, nested lists included.
std::expected< std::string, std::string > inlineBytes(Value const &value)
A document's inline file content as bytes: base64 text, or a list of integers 0 to 255.
Key keyOf(Value const &value)
A value as a view key: an int or a string as itself, anything else as its JSON text.
LowerCtx attached(DetachedLower const &held)
Puts a detached context's scope back.
std::string sizeText(std::size_t bytes)
A byte count as a person reads it: 512 bytes, 2.4 KiB, 1 MiB.
Node holding(std::vector< Node > children, std::shared_ptr< RtScope > scope, std::vector< KeyBinding > keys={})
A column of content that holds its scope for as long as it is mounted: its visible is a binding that ...
Value componentPayload(std::string const &payload, Type const *type)
A custom component's event payload as a value: i:<int64> an int, s:<text> a string,...
ElementState * elementFor(RtScope const &scope, DocNode const &node)
The element a node of a scope keeps its state in.
Value mergeRow(Value const &row, Value const &with)
Writes with over a row: the fields of a record over a record's, otherwise the whole row.
std::vector< Value > arrangedOrder(Value const &stored, std::span< DocNode const *const > declared)
The ids of the children an arrange choice shows, in its order (arrangedChildren, without the hidden).
Axis axisOf(Value const &value)
An axis by name: horizontal, or vertical for anything else.
Value jsonTextValue(std::string_view text)
A JSON text as a document value; null when it is not JSON or holds what a value cannot (a fraction).
std::vector< std::string > stringsOf(Value const &value)
A list of strings out of a value: a string, a list of strings, or nothing.
std::optional< TextRole > roleNamed(std::string_view name)
A text role by name.
bool statefulKind(std::string_view kind)
Whether a node kind keeps per-element state even without an id: the inputs, dialogs,...
Value formFieldValue(forms::FieldState const &field)
One field of a form as a record: value (the encoded value, null while blank or invalid),...
Justify justifyOf(std::string_view name)
A justification by name, as the loader checked it: center, end, between, or start for none.
Value valueOfKey(Key const &key)
A view key as a value.
std::function< Value()> formFilled(std::string const &prop, forms::FieldState *state)
What a form fills of a field component's props: the field's value, errors, read-only and required sta...
Node lazy(std::function< Node()> build)
Content whose scope is made when it mounts and released when it unmounts: a one-row forEach whose row...
Value formValue(ElementState const &element, std::string_view name)
A form's element values (spec 5 §6): ready, body (the record the form would submit,...
std::string dateTextOf(time::DateTime const &instant, std::int32_t offsetMinutes)
A date input's date text for instant in a display zone offsetMinutes from UTC.
std::string instanceKeyText(Value const &key)
The text of an instance key, as the bridge's string-keyed attach takes it.
std::string textOf(Value const &value, FormatContext const &format)
A value as text for a widget: a string as itself, a number in the locale, a timestamp in ISO 8601,...
DateReading dateInstantOf(Value const &value, std::int32_t offsetMinutes)
Reads what a date input shows for value, in a display zone offsetMinutes from UTC.
std::string patchMissReason(std::string_view query, std::string_view listMember)
What a patch that found no list reports.
std::shared_ptr< ModelChannel > channelOf(ModelAlias &alias, ScreenCore &core)
The channel an alias is bound to for its current token, binding it when the token changed.
DetachedLower detachedLower(LowerCtx const &ctx)
Detaches a lowering context's scope.
std::optional< Value > patchKeyedRows(Value const &value, std::span< std::string const > path, std::string_view keyField, RowsByKey const &rows)
Merges new rows over the rows with the same key that a value holds at a path.
Value listOfStrings(std::vector< std::string > const &texts)
A list of strings as a value.
Value withoutFields(Value const &value, std::vector< std::string > const &fields)
Removes the named fields from a record, and from each record of a list.
KeySpec const * keySpec(KindSpec const &kind, std::string_view key)
A kind's key spec, from the kind table.
double componentNumber(Value const &value)
A value as a custom component's number prop: the one kind that crosses as a double.
std::vector< Value const * > keyedRowsOf(Value const &value, std::vector< std::string > const &paths, std::string_view keyField)
The rows of a keyed record a value holds, at each of the record's paths.
std::size_t base64Size(std::string_view text)
How many bytes base64 text decodes to, read from its length alone.
std::string commandKey(CommandKind kind)
The key a command is written with.
std::function< void(bool)> Done
Called once when a command finishes: whether it succeeded.
void keyedRowsAt(Value const &value, std::span< std::string const > path, std::string_view keyField, std::vector< Value const * > &out)
The rows of a keyed record that a value holds at a path.
std::optional< std::string > base64Decode(std::string_view text)
Base64 text as bytes (RFC 4648, with or without padding).
std::string base64Encode(std::string_view bytes)
Bytes as base64 (RFC 4648, with padding): how a document carries an inline file.
bool engaged(Value const &value)
Whether a value counts as given for required: not null, not an empty string or list.
constexpr std::size_t kCommandLogSize
How many command lists the inspector's log keeps.
constexpr std::size_t kPruneLoweredAt
How often, in lowered nodes, the inspector's node list drops the nodes whose scope is gone.
std::string failureText(std::exception_ptr const &error)
The text of a failure.
Value withPath(Value const &base, std::span< std::string const > path, Value value)
A value with the field at path replaced, creating records along the way.
std::vector< SelectOption > selectOptions(Value const &options, FormatContext const &format)
The options of a select, from a list of scalars or of records with a key and a label.
Value optionKey(Value const &option)
The key of a select option: a record's key, id or value field, or the option itself.
PatchMiss
Why a patch left a query's value as it was.
@ NoList
The value, or its at member, is not a list: the patch can never apply, and is reported.
@ NoRow
No row's id equals the key: the row is not loaded, which is not a fault.
std::vector< ActionModel > ActionModels
A list of lowered actionButton parts, in document order.
std::string canonicalJson(Value const &value)
A value's JSON text, with record fields in name order: a cache key's canonical body.
StepState stepStateOf(std::string_view name)
A step's state by name: active, done, failed, or pending for anything else.
std::string defaultErrorKind(std::exception_ptr const &error)
The default error kinds: the bridge's own failures, and error for the rest.
Align alignOf(std::string_view name)
An alignment by name, as the loader checked it: start, center, end, or stretch for none.
std::optional< Value > patchHeldRows(Value const &value, std::vector< std::string > const &paths, std::string_view keyField, RowsByKey const &rows)
Merges new rows into a value at each of the record's paths.
bool sameShown(std::string_view kind, Value const &lhs, Value const &rhs, FormatContext const &format)
Whether two values show the same in an input: by text for a text input, by instant for a date input,...
std::int64_t intOf(Value const &value, std::int64_t fallback)
A value as an integer: an int, or a decimal truncated toward zero.
std::function< async::Completion< std::string >(std::string const &kind, std::string const &bodyJson)> Watcher
Sends a watch or unwatch request for a session's cache (spec 8 §7): the kind, and the WatchBody as JS...
std::vector< std::string > pathMembers(std::string_view path)
The members of a catalog record path: "header.rows" is header, then rows; "" is none.
bool isSizing(Value const &value)
Whether a value is a sizing of one dimension: "content", {"fixed": n} or {"stretch": w}...
Value evalIn(RtScope const &scope, Bindings const *bindings, ExprPtr const &expr)
Evaluates an expression in a scope.
forms::FieldOverlay overlayOf(RtScope const &scope, DocNode const &part, FormatContext const &format)
A document field overlay's expressions, evaluated once, as the engine's overlay.
std::vector< Binding > Bindings
The names bound at one place, innermost last.
Sizing sizingOf(Value const &value)
A sizing by its document form: "content", {"fixed": n} or {"stretch": w}.
ReplyUse
How a mutation's reply was used.
@ Discarded
A newer run's reply was applied first, so its values are older and only mark.
@ Applied
The reply is the latest: its values may patch the cache.
bool boolOf(Value const &value, FormatContext const &format)
A value as a bool property: true only for true.
std::function< async::Completion< std::string >(std::string const &typeId, std::string const &actionId, std::string const &bodyJson)> Evaluator
Evaluates an action body without running it: the model's type id, the action id and the JSON body in,...
DocNode const * elementNode(DocNode const &node, std::string_view elementId)
The element of a document tree with elementId.
std::function< void(bool succeeded, Value const &reply)> RunDone
One run of a mutation, as a command sees it: whether it succeeded, and the reply.
std::string componentText(Value const &value, std::string_view type, FormatContext const &format)
A value as a custom component's text prop (spec 5 §10): an int or exact as plain decimal text,...
std::vector< ArrangedChild > arrangedChildren(Value const &stored, std::span< DocNode const *const > declared)
A container's children in the order a stored arrange choice gives, each marked shown or hidden.
std::string stringOf(Value const &value, FormatContext const &format)
A value as a text property.
std::expected< Value, PatchMiss > patchRows(Value const &value, std::string_view listMember, Value const &key, Value const &with)
Replaces the row whose id equals key in a query's list of rows.
constexpr std::size_t kMaxInlineFileBytes
The largest inline cap a session takes: base64 grows bytes by 4/3, and 64 KiB of an envelope are left...
std::expected< forms::detail::engine::Json, std::string > changeWire(std::string const &payload, std::string_view type)
A component's change payload as the wire value of the field it draws, read as the value prop's declar...
constexpr std::size_t kDefaultInlineFileBytes
The inline cap a session has unless it is given another: 1 MiB.
Value arrangementValue(std::vector< ArrangedChild > const &children)
An arrangement as the choice an arrange point stores.
Tone toneOf(Value const &value, FormatContext const &format)
A tone by name.
Value rowId(Value const &row)
A row's identity for patch and optimistic: its id field.
CommandCtx detached(CommandCtx const &ctx)
A command context without its scope: what a callback the host keeps may hold, so that the host never ...
std::map< std::string, Value const *, std::less<> > RowsByKey
New rows by the canonical text of their key.
TextRole roleOf(Value const &value, FormatContext const &format)
A text role by name, as roleNamed reads it.
The MessageFormat 2 subset ui/1 accepts for t(key, fallback, args), and the CLDR plural rules its ....
std::optional< std::string > formatNumber(Value const &value, render::NumericLocale const &numeric)
Formats a number for display in a locale: at its own places, rounded half to even.
Definition message.hpp:97
std::string decimalText(math::Rational const &value, std::uint32_t places)
The decimal text of an exact value at a number of places: rounded half to even, then written with exa...
Definition message.hpp:54
morph::ui::Mounted: builds a view tree's widgets through a backend once, and keeps them current throu...
void convert(const Quantity< From > &in, Quantity< To > &out)
Auto-generated within-dimension conversion (ratio + chaining).
Definition quantity.hpp:956
morph::reactive::Scheduler: timers a frontend runs on the runtime's owner executor.
std::function< std::optional< std::string >(std::string_view key, std::string_view bcp47Locale)> TranslationProvider
A host-supplied catalog lookup: key/bcp47Locale -> translated text, or std::nullopt on a miss.
Definition i18n.hpp:27
morph::reactive::Runtime: the owner, batching and flush scheduling of a signal graph.
morph::reactive::Scope: owns reactive nodes and objects, and destroys them in reverse.
Signal<T>, Computed<T> and Effect: the nodes of a reactive graph.
void request(Store< ViewState, Msg > &store, bridge::BridgeHandler< Model, Sharing > &handler, async::CallbackScope const &scope, Action action, ToMsg toMsg, ToFailMsg toFailMsg)
Executes action and sends its result, or its failure, to store as one message.
Definition store.hpp:202
Thrown to in-flight Completions when Bridge::switchBackend() runs.
Definition backend.hpp:600
Thrown to a pending Completion when Bridge::setExecuteDeadline's duration elapses before any reply ar...
Definition backend.hpp:652
Thrown to in-flight Completions when a transport drops mid-call (e.g. a Qt WebSocket disconnect)....
Definition backend.hpp:621
Thrown to a pending Completion when the server-side morph::backend::LimitPolicy::executeTimeout elaps...
Definition backend.hpp:636
A change to the rows of one record type.
std::string record
The record's wire name.
std::optional< std::vector< glz::raw_json > > keys
The keys of the rows that changed, each as raw JSON; absent when the whole type changed.
Abstract executor interface.
Definition executor.hpp:70
What a document (or an application) changes about one top-level field before the session is built: a ...
std::optional< BlankAs > blankAs
Replaces x-blankAs.
std::optional< std::string > label
Replaces the label (title).
std::optional< std::uint32_t > decimals
The places a Decimal or Quantity is entered with in its entry unit, or a Number's display decimals....
std::optional< std::string > unit
The unit a Quantity is entered in: the id of one of its units (its canonical unit or an alternative)....
std::optional< std::string > widget
Replaces x-widget, so the kind is read again (textarea, radio, slider, epochDays, …).
Everything the engine knows about one field, from its schema property.
FieldKind kind
The control and encoder.
How a session reads numbers, dates and text.
How formView lays a form out and labels its chrome.
Definition form_view.hpp:38
A UTC instant with millisecond precision.
Definition datetime.hpp:71
static std::optional< DateTime > fromIso8601(std::string_view text) noexcept
Strictly parses ISO-8601 UTC: YYYY-MM-DDTHH:MM:SS, optionally followed by .m/.mm/....
Definition datetime.hpp:148
std::chrono::sys_time< std::chrono::milliseconds > value
The underlying UTC instant (Unix time, leap seconds ignored).
Definition datetime.hpp:73
A row of buttons in the footer of the dialog or drawer whose content holds it: what a document's over...
Definition view.hpp:1089
std::vector< Node > children
The buttons, in order; a null child is skipped.
Definition view.hpp:1091
A button inside another kind, such as a banner's action: its caption beside its handler.
Definition view.hpp:970
One effect an action declares, as the catalog states it.
Definition document.hpp:48
What the catalog says about an action.
Definition document.hpp:78
A record an action's result holds, as the catalog states it.
Definition document.hpp:62
std::string record
The record's wire name.
Definition document.hpp:64
A short label marking a state, such as a count or a status.
Definition view.hpp:994
std::string icon
The name of an icon in the theme's icon map; empty shows none. Set once.
Definition view.hpp:1000
A message across the top of a region, with an optional action and an optional dismissal.
Definition view.hpp:978
ActionButton action
The button beside the message; an empty label shows none.
Definition view.hpp:984
A model the opener lends to a screen whose alias says {"from": alias} (spec 6 §16).
std::string token
The opener's instance token, so the two share cache entries.
std::shared_ptr< ModelChannel > channel
The opener's channel.
A progress indicator.
Definition view.hpp:708
A push button.
Definition view.hpp:418
std::string icon
The name of an icon in the theme's icon set, drawn beside the caption; empty shows none....
Definition view.hpp:428
Action onClick
Called on activation.
Definition view.hpp:424
How one model call is sent.
bool evaluate
Ask the server to evaluate the action's body instead of executing it (spec 5 §7, ui-evaluate).
bool queueOffline
The mutation declares "offline": "queue": a dispatcher with an offline queue holds a call that cannot...
The facts load-time validation needs from the application's catalog. Local mode answers them from the...
Definition document.hpp:105
A validation across elements (spec 5 §6).
Definition document.hpp:351
std::vector< std::string > targets
The elements the message attaches to.
Definition document.hpp:361
ExprPtr message
The message added to each target's errors.
Definition document.hpp:359
ExprPtr assertion
The condition; false fails the check, null does not.
Definition document.hpp:357
A labelled check box.
Definition view.hpp:473
std::function< void(bool)> onToggle
Called with the new state when the user toggles it.
Definition view.hpp:479
A titled section the user opens and closes. Its header stays usable while it is closed.
Definition view.hpp:1112
Node header
Shown beside the title, open or closed; may be null.
Definition view.hpp:1120
Children stacked top to bottom.
Definition view.hpp:564
Common common
Visibility, enablement, layout, drag-and-drop.
Definition view.hpp:570
One command list as it ran: the event that ran it, its commands, and the reactive batch they ran in.
std::vector< LoggedCommand > commands
Its commands, in order.
std::string event
The JSON Pointer of the list: the event, lifecycle key or step that holds it.
std::uint64_t batch
The number of the reactive batch it ran in (reactive::detail::RuntimeCore::currentBatch); 0 outside e...
One command. Which members a kind uses is listed with each member.
Definition document.hpp:271
std::string option
A closed option: navigate's as, notify's role, download's save or to.
Definition document.hpp:289
std::string transfer
The transfer id of upload, whose progress is an element value.
Definition document.hpp:291
Commands commands
Nested commands: seq and all; if's then branch; navigate's onResult.
Definition document.hpp:301
ExprPtr value
The main expression: set's value, run's body, navigate's params, return's value, host's args,...
Definition document.hpp:283
std::string nameAs
upload's body members for an inline file's name and media type; empty for none.
Definition document.hpp:297
bool bytes
Whether the file moves inline: download's "from": "bytes", upload's "into": "bytes".
Definition document.hpp:293
std::string typeAs
See nameAs.
Definition document.hpp:299
CommandKind kind
The kind.
Definition document.hpp:273
ExprPtr extra
The second expression: run's each, navigate's route, download's suggested name.
Definition document.hpp:285
std::string path
The command's JSON Pointer.
Definition document.hpp:275
std::string target
The name it acts on: set's name.path; the state of reset; the mutation of run, signIn and upload; the...
Definition document.hpp:280
std::string binding
A binding name: run's and upload's as.
Definition document.hpp:287
ExprPtr mediaType
download's media type of inline bytes.
Definition document.hpp:295
Commands alternative
The alternative: if's else branch; navigate's onCancel.
Definition document.hpp:303
The part every node carries: visibility, enablement, layout, drag-and-drop, accessibility,...
Definition view.hpp:348
Prop< bool > visible
Whether the widget is shown.
Definition view.hpp:350
A custom component declaration (spec 5 §10).
Definition document.hpp:470
One prop a loaded custom component is given.
Definition view.hpp:1144
A component the backend may load (spec 5 §10), with the built-in nodes shown when it does not.
Definition view.hpp:1156
std::function< void(std::string event, std::string payload)> onEvent
Called with an event a loaded component raises: its name and its payload as the backend reports it.
Definition view.hpp:1166
A customization point (spec 5 §12b).
Definition document.hpp:490
A date, or a date and time, edited in a display zone and reported as an instant.
Definition view.hpp:914
An exact decimal: a math::Rational whose DecimalPlaces are the value's declared places.
Definition value.hpp:95
A modal overlay; its content is mounted only while it is open.
Definition view.hpp:694
A view node of the document (spec 5 §9), or a structured part of one (a table column,...
Definition document.hpp:394
std::map< std::string, Commands, std::less<> > keys
Key bindings: chord to commands.
Definition document.hpp:413
std::map< std::string, std::vector< DocNodePtr >, std::less<> > children
Child node lists, by key (children, header, loading, error, default, cell, fallback).
Definition document.hpp:406
std::string path
The node's JSON Pointer.
Definition document.hpp:400
std::optional< ScopeDecl > scope
The declarations of the scope the node creates (dialog, drawer, switch case, forEach or table row); e...
Definition document.hpp:418
std::string_view name(std::string_view key) const
A plain string property.
Definition document.hpp:455
Commands const * event(std::string_view key) const
A command list.
Definition document.hpp:431
std::map< std::string, std::vector< DocNodePtr >, std::less<> > parts
Structured parts, by key (columns, tabs, cases, items, a11y, action, fields, …).
Definition document.hpp:408
std::string kind
The node kind (text, table, …), or for a part its part kind (column, tab, case, …).
Definition document.hpp:396
std::map< std::string, Commands, std::less<> > events
Command lists, by key (onClick, …), including a custom node's on events as on.<event>.
Definition document.hpp:404
bool flag(std::string_view key) const
A literal flag.
Definition document.hpp:463
std::span< DocNodePtr const > partList(std::string_view key) const
A list of parts.
Definition document.hpp:447
ExprPtr prop(std::string_view key) const
An expression property.
Definition document.hpp:423
std::string id
The element id; empty when none is given.
Definition document.hpp:398
std::span< DocNodePtr const > nodes(std::string_view key) const
A child node list.
Definition document.hpp:439
A loaded and validated UI document: a screen, or the app document (id app, spec 6 §5).
Definition document.hpp:536
A panel that slides in from one edge over the content: a Dialog with a side, and the same rules.
Definition view.hpp:1072
A region that takes files dropped from the operating system.
Definition view.hpp:1128
std::vector< std::string > accept
The extensions it takes, such as ".csv", compared without regard to case; empty takes every file....
Definition view.hpp:1131
What a region shows when it has nothing to show: a title, an explanation and an optional action.
Definition view.hpp:1058
std::string icon
The name of an icon in the theme's icon map; empty shows none. Set once.
Definition view.hpp:1064
The state of a value a user edits or reads: whether it may be changed, must be given,...
Definition view.hpp:386
A file path, typed or chosen in the platform's file dialog.
Definition view.hpp:956
Prop< std::string > path
The path shown.
Definition view.hpp:958
One widget per row of a keyed collection, updated in place when a row with the same key changes.
Definition view.hpp:864
What the display functions and t format with: the client's locale and its formatting facts.
render::TranslationProvider translations
The message catalogue; empty shows every fallback.
std::string locale
The active BCP 47 locale.
One cell of a Grid.
Definition view.hpp:595
Cells laid out row-major in a fixed number of columns.
Definition view.hpp:603
An action of a screen's head, as its handle answers it.
ButtonEmphasis emphasis
How much it stands out.
bool enabled
Whether it can be run now.
bool operator==(HeadAction const &) const =default
Memberwise equality.
std::string id
The action's id, what ScreenHead::run takes.
std::string label
The label.
std::string icon
The name of an icon; empty for none.
A status badge of a screen's head, as its handle answers it.
bool operator==(HeadBadge const &) const =default
Memberwise equality.
std::string icon
The name of an icon; empty for none.
std::string text
The badge's text.
What running a host command (host/1) did.
@ Missing
The client has no such command; nothing ran.
@ Refused
The command exists and did not run: its arguments, or a connection that is not verified.
std::string reason
Why it was refused; empty otherwise.
The client's host commands, as a session runs them (spec 6 §14).
std::function< HostCommandResult(std::string const &name, Value const &args)> run
Runs a command with its evaluated args (null when the document gives none).
std::set< std::string, std::less<> > available
The commands the client can run now; {"can": "host:name"} is true for these.
A file whose bytes a document moves inline: download from bytes, upload into bytes.
std::string name
The file's name.
std::string bytes
Its bytes.
std::string mediaType
Its media type; empty when unknown.
What an Interpreter is built with.
std::function< void(Refusal const &)> diagnostics
Called with each diagnostic as it is reported: a runtime expression error, a command the host does no...
InterpreterHost * host
The application's side of the commands; nullptr answers every one "not supported"....
A keyboard chord a node handles while focus is inside it.
Definition view.hpp:339
One row of a KeyValue list.
Definition view.hpp:1038
Captions beside their values, one pair per row.
Definition view.hpp:1050
Prop< std::vector< KeyValueItem > > items
The pairs, in order.
Definition view.hpp:1052
A widget's size request in both dimensions, and the bounds its extent is clamped into.
Definition view.hpp:308
std::optional< int > minHeight
The least height, in backend units; nullopt for none.
Definition view.hpp:318
std::optional< int > minWidth
The least width, in backend units; nullopt for none.
Definition view.hpp:314
std::optional< int > maxWidth
The most width, in backend units; nullopt for none.
Definition view.hpp:316
std::optional< int > maxHeight
The most height, in backend units; nullopt for none.
Definition view.hpp:320
A derived value (let).
Definition document.hpp:183
ExprPtr value
The expression.
Definition document.hpp:189
std::string name
The name.
Definition document.hpp:185
One command of a command list the inspector's log shows (spec 6 §21).
std::string path
The command's JSON Pointer in the document.
std::string target
The name it acts on (Command::target); empty for a command that names none.
std::string kind
The command's key: set, run, refetch, …
One entry of a Menu: its label and its handler in one place, so there are no parallel lists.
Definition view.hpp:526
Prop< std::string > label
The caption.
Definition view.hpp:528
A vertical list of commands.
Definition view.hpp:546
A model alias.
Definition document.hpp:155
std::string name
The alias.
Definition document.hpp:157
std::string type
The model's registered type id.
Definition document.hpp:161
ExprPtr key
The key expression of a Keyed instance.
Definition document.hpp:165
What Interpreter::mount is given beside the document.
std::map< std::string, BorrowedModel, std::less<> > borrowed
The models the opener lends, by the alias of this screen that borrows them.
std::size_t depth
The reactive scope depth of the screen's own scope.
ContainerWidget * parent
The container the screen's root widget is appended to; nullptr makes a backend root.
Screen const * keepStateOf
The screen this mount replaces, such as one whose document changed; nullptr for a fresh mount....
A mutation (spec 5 §5).
Definition document.hpp:307
std::string model
The model alias it goes through.
Definition document.hpp:313
std::string action
The action id it runs.
Definition document.hpp:315
std::string name
The name.
Definition document.hpp:309
What navigate asks the host to open (spec 6 §16, §20).
std::string screen
The screen id; empty when route is given.
Value route
The route, when the command named one instead of a screen.
std::string as
tab, dialog, drawer or window; empty for the shell's choice.
A titled frame around one child; a collapsible one is an accordion section.
Definition view.hpp:624
Node header
Shown beside the title, collapsed or not; may be null.
Definition view.hpp:628
A progress bar.
Definition view.hpp:1006
Prop< std::optional< double > > value
How far along, from 0 to 1; a value outside is shown at the nearer end. nullopt: indeterminate.
Definition view.hpp:1008
An exact decimal with a unit: the id of a unit of the server's unit systems (x-unitAlternatives).
Definition value.hpp:109
A query (spec 5 §5).
Definition document.hpp:193
std::string name
The name.
Definition document.hpp:195
std::string model
The model alias it goes through.
Definition document.hpp:199
std::string action
The action id it runs, or evaluates.
Definition document.hpp:201
Why a document, an expression or a value was refused: where, and the reason.
Definition value.hpp:38
Children laid out left to right.
Definition view.hpp:578
The declarations of one scope, in declaration order.
Definition document.hpp:367
std::vector< ModelDecl > models
Model aliases.
Definition document.hpp:369
std::vector< StateDecl > state
State.
Definition document.hpp:371
std::vector< LetDecl > lets
Derived values.
Definition document.hpp:373
std::vector< MutationDecl > mutations
Mutations.
Definition document.hpp:377
std::vector< QueryDecl > queries
Queries.
Definition document.hpp:375
std::vector< CheckDecl > checks
Checks.
Definition document.hpp:381
std::vector< WatchDecl > watches
Watches.
Definition document.hpp:379
A scrollable viewport around one child.
Definition view.hpp:644
One option of a Select.
Definition view.hpp:487
A choice of one option.
Definition view.hpp:499
Prop< std::vector< SelectOption > > options
The options, in order.
Definition view.hpp:501
What a Session is built with.
std::function< std::chrono::milliseconds()> clock
The clock staleTime is measured on; empty uses std::chrono::steady_clock.
std::function< std::string(std::exception_ptr const &)> classify
Maps a failed call to its error kind (denied, offline, timeout, …); empty uses the default,...
std::size_t maxInlineFileBytes
The most bytes a document moves inline in one file (download from bytes, upload into bytes,...
reactive::Scheduler * scheduler
Timers for debounce, refreshEvery and the cache's eviction. Without one, debounce fetches at once,...
Catalog catalog
The catalog: stateless decides an alias's default instance; an action's secretFields are never cached...
Watcher watcher
Sends the cache's watch changes to the server. Empty: the session still counts what its cache holds,...
std::chrono::milliseconds keepUnused
How long an entry no mounted query uses is kept (spec 5 §5: five minutes).
DevelopmentTools developmentTools
Development mode: whether this session's screens record what the inspector reads. Off by default.
How much space a widget asks for along one dimension, in backend units (a character cell on the TUI).
Definition view.hpp:270
static constexpr Sizing content() noexcept
Size to the content.
Definition view.hpp:284
static constexpr Sizing stretch(int weight=1) noexcept
A share of the leftover space.
Definition view.hpp:294
static constexpr Sizing fixed(int units) noexcept
A fixed size.
Definition view.hpp:289
An integer chosen on a range.
Definition view.hpp:936
Prop< std::int64_t > value
The value shown.
Definition view.hpp:938
Empty space that takes what its layout hints ask for.
Definition view.hpp:618
Panes side by side, or stacked, with handles the user drags to share the space between them.
Definition view.hpp:1097
A state declaration.
Definition document.hpp:171
std::string name
The name.
Definition document.hpp:173
Type type
The declared type; any when the document gives none.
Definition document.hpp:177
One step of a Steps indicator.
Definition view.hpp:1016
The steps of a process in order, each with its state.
Definition view.hpp:1028
Prop< std::vector< Step > > items
The steps.
Definition view.hpp:1030
One case of a Switch.
Definition view.hpp:654
Shows the case whose key the selector yields; remounts only when that key changes.
Definition view.hpp:662
std::vector< SwitchCase > cases
The cases; the first one with the selected key wins.
Definition view.hpp:666
Prop< Key > selector
Which case to show.
Definition view.hpp:664
One page of Tabs.
Definition view.hpp:674
One column of a Table.
Definition view.hpp:876
Everything about a table beyond its columns and rows.
Definition view.hpp:1424
bool showHeader
Whether the header row is drawn.
Definition view.hpp:1426
What a table's row view builds for one row: its cells, one per column, and its tone.
Definition view.hpp:1442
A tab bar over pages.
Definition view.hpp:682
An editable text field. The backend never reports its own setText back through onChange.
Definition view.hpp:437
Prop< std::string > value
The text the field shows.
Definition view.hpp:439
Static or bound text.
Definition view.hpp:406
Prop< TextRole > role
The style.
Definition view.hpp:410
A document type (spec 5 §3): a kind, whether null is allowed, and a list's element type.
Definition value.hpp:311
std::string name() const
The type as a document spells it.
Definition value.hpp:347
bool admits(Value const &value) const
Whether a value conforms to the type.
Definition value.hpp:367
What an upload command asks the host to send, and the mutation that will reference the file,...
std::string modelType
The model type of the mutation that will reference the file.
std::string action
That mutation's action.
Value file
What the user picked or dropped.
A cascade: commands that run when a value changes (spec 5 §6).
Definition document.hpp:339
ExprPtr on
The value watched.
Definition document.hpp:343
An actionButton part, lowered in the scope of the list it belongs to: what a screen's head shows and ...
std::string id
The action's id, unique in its list.
Prop< std::string > icon
The name of an icon; empty for none.
Prop< std::string > label
The label.
ButtonEmphasis emphasis
How much it stands out.
std::string path
The part's JSON Pointer, where a diagnostic about it is reported.
Prop< bool > visible
Whether it is shown.
Prop< bool > enabled
Whether it can be run.
Action run
Runs its onClick as a user event, in one batch; empty when it declares none.
One child of an arranged container, as the user's choice places it.
std::string id
The child's id.
bool hidden
Whether the choice hides it.
A headBadge part, lowered in the screen scope.
Prop< std::string > icon
The name of an icon; empty for none.
Prop< std::string > text
The badge's text.
Prop< bool > visible
Whether it is shown.
Prop< Tone > tone
Its tone.
A name bound for the expressions of one place: a command's event, a step's as, result in a patch,...
std::function< Value()> read
Reads its value; a tracked read for a reactive one.
std::string name
The name.
A boundary at run time: the queries it owns (spec 5 §9).
std::vector< QueryRuntime * > queries
The queries read inside its children.
One entry of the session's query cache (spec 5 §5): one request in flight, one value,...
bool evaluate
Whether it is an evaluate query.
reactive::Signal< bool > hasValue
Whether a reply has been applied.
std::string instanceToken
The alias's instance token the entry was read through.
std::uint64_t generation
The generation of the request in flight; a reply of an older one is dropped.
reactive::Signal< bool > pending
Whether a request is in flight.
ActionRecordInfo const * holding(std::string_view record) const
The catalog entry of a record the entry holds.
Value confirmed
The value the server last confirmed.
bool refetchAfter
Marked stale while a fetch was in flight: that fetch may have read before the change,...
std::vector< std::string > secretFields
The fields never cached.
std::optional< std::chrono::milliseconds > fetchedAt
When the last reply was applied.
std::string body
The body's JSON text.
std::string action
The action.
std::string typeId
The model type.
reactive::TimerHandle evict
The eviction timer, while no query uses it.
reactive::Signal< Value > value
The value shown: the confirmed value with the optimistic layers over it.
reactive::Signal< std::string > error
The last failure's text; empty when none.
reactive::Signal< std::string > errorKind
The last failure's kind; empty when none.
std::string key
The cache key: model type, instance, action and canonical body.
std::vector< ActionRecordInfo > records
The records the entry's action returns, with the key field and paths of each; empty for an evaluate e...
bool stale
Marked stale by a refetch, a backend switch or a change.
int users
The mounted queries using it.
std::vector< OptimisticLayer > layers
The optimistic writes still waiting, oldest first.
CacheEntry(reactive::Runtime &runtime)
An empty entry.
~CheckRuntime()
Leaves its targets.
std::vector< ElementState * > targets
The elements it targets.
reactive::Computed< std::string > * message
The message, as text.
CheckDecl const * decl
The declaration.
reactive::Computed< bool > * failing
Whether assert is false.
Where a command list runs: its scope, the names bound there, and what it may answer.
std::shared_ptr< RtScope > scope
The scope.
std::shared_ptr< BoundaryRuntime > boundary
The innermost boundary, for retry.
std::shared_ptr< bool > cancelClose
Set by cancelClose.
std::shared_ptr< Bindings > bindings
The names bound here; a seq step's as adds to them.
What a date input reads from a value: the instant it shows, and whether the value names one at all.
bool readable
Whether the value names a date, an instant or nothing; false for text that names no date.
std::optional< time::DateTime > instant
The instant shown; empty for an empty field.
A lowering context whose scope is held weakly: what an Effect the scope itself owns may keep,...
std::shared_ptr< Bindings const > bindings
The names bound there.
std::weak_ptr< RtScope > scope
The scope, weakly.
std::shared_ptr< BoundaryRuntime > boundary
The innermost boundary.
An element's live values (spec 5 §6).
reactive::Signal< Value > draft
What the user is editing: a text input's text, a checkbox's state, a select's key,...
std::vector< CheckRuntime * > checks
The checks targeting it.
reactive::Computed< Value > * rows
A table's rows or a select's options, for its counts and selectedOption.
std::unique_ptr< forms::CollectionSession > collection
A collection's session (spec 2), made when the element is wired; null when its view document is unkno...
reactive::Signal< Value > activeKey
A table's last activated key.
ElementState(reactive::Runtime &runtime)
The element's state before any user action.
reactive::Signal< bool > edited
Whether the user changed the draft since it last followed the bound value.
DocNode const * node
The node.
reactive::Signal< int > checksVersion
Bumped when a check targeting it comes or goes.
std::unique_ptr< forms::WizardSession > wizard
A wizard's session (spec 2), made when the element is wired; null when its wizard document or a step'...
std::unique_ptr< forms::FormSession > form
A form's session (spec 2), made when the element is wired; null when its schema is unknown.
reactive::Signal< RtScope * > inner
A dialog's or drawer's scope while its content is mounted.
reactive::Computed< Value > * bound
The value an expression binds (value, checked, selected); nullptr when the element is uncontrolled.
reactive::Computed< Value > * errors
What is wrong now, as a list of strings: required unmet, the node's errors, the reply's field errors ...
reactive::Signal< Value > committed
The committed value of an input whose value no expression binds.
reactive::Signal< bool > submitted
A dialog's or drawer's submitted.
RtScope * scope
The scope that declares it.
reactive::Signal< bool > focused
Whether it has keyboard focus. The view tree reports no focus, so it stays false.
reactive::Signal< bool > open
A dialog's or drawer's open, set by open and close.
reactive::Signal< bool > touched
Whether it has been committed at least once (Enter or focus leaving after an edit).
reactive::Signal< Value > selection
A table's selected keys.
One key of a kind.
Definition document.hpp:628
std::string_view key
The key.
Definition document.hpp:630
A node kind or part kind: its keys and its element values.
Definition document.hpp:653
std::span< KeySpec const > keys
Its own keys.
Definition document.hpp:657
Where a node is lowered: its scope, the names bound there and the boundary that owns its queries.
std::shared_ptr< BoundaryRuntime > boundary
The innermost boundary.
std::shared_ptr< Bindings const > bindings
The names bound here.
std::shared_ptr< RtScope > scope
The scope.
The entries one change marks stale, each once, in the order they were found.
std::vector< CacheEntry * > entries
The entries.
void add(CacheEntry &entry)
Adds an entry, unless it is already there.
One model alias at run time: the instance it names, and the channel bound to it.
std::string boundToken
That token.
reactive::Computed< std::string > * token
The instance token: what the cache key holds for the instance; empty while unbound (a null key).
std::shared_ptr< ModelChannel > channel
The channel of the token it was last bound for.
InstanceMode mode
Private, Shared, Keyed or Borrowed, the default resolved through the catalog.
ModelDecl const * decl
The declaration.
One declared name of a run-time scope.
ExprPtr letExpr
The let's expression, for the boundary's ownership walk.
QueryRuntime * query
The query.
reactive::Computed< Value > * let
The let.
MutationRuntime * mutation
The mutation.
TransferState * transfer
The transfer.
NameCategory category
What it declares.
Value param
A param's value.
ModelAlias * model
The model alias.
ElementState * element
The element.
StateSlot * state
The state.
One optimistic write still waiting for its reply.
std::string at
The record member holding the list; empty when the value is the list.
std::uint64_t run
The run that made it.
A state of the screen this one replaces: its declared type's name and its value.
std::string type
The declared type's name.
A node as it was lowered, which the inspector lists while its scope lives.
std::weak_ptr< Bindings const > bindings
The names bound where it was lowered.
std::weak_ptr< RtScope > scope
The scope it was lowered in.
One state declaration at run time.
RtScope const * scope
The scope that declares it, where its init is evaluated.
bool initializing
Set while the init is evaluated, so a cyclic read gives null instead of recursing.
StateDecl const * decl
The declaration.
std::unique_ptr< reactive::Signal< Value > > value
The value, made when the init is evaluated.
The values of an upload transfer id (spec 6 §18).
reactive::Signal< bool > pending
Whether it is running.
TransferState(reactive::Runtime &runtime)
An idle transfer.
reactive::Signal< Value > progress
0 to 1, or null.
reactive::Signal< Value > error
The failure's text, or null.
The payload of a changes event.
Definition wire.hpp:699
std::vector< effects::RecordChange > records
Record changes; a record with no keys changed as a whole.
Definition wire.hpp:701
std::vector< InstanceTopic > instances
Instance changes.
Definition wire.hpp:703
The decoded body of an event envelope.
Definition wire.hpp:621
One instance topic: a model type, its directory key text and its instance id.
Definition wire.hpp:671
The body of a watch or unwatch request.
Definition wire.hpp:681
std::vector< InstanceTopic > instances
Instance topics to watch (or stop watching).
Definition wire.hpp:685
bool replace
For watch: replace the connection's whole watch set instead of adding to it.
Definition wire.hpp:687
std::vector< std::string > records
Record wire names to watch (or stop watching).
Definition wire.hpp:683
The backend contract: the retained widgets a mount drives, and the factory that makes them.
std::optional< std::string_view > aliasOf(std::string_view text)
Whether a string is an alias, {a.b}.
Definition theme.hpp:254
The UI document's typed values (ui::Value), its types (ui::Type), their JSON form,...
std::expected< Json, std::string > parseJson(std::string_view text)
Parses JSON text into the document layer's JSON tree.
Definition value.hpp:752
glz::generic_u64 Json
The JSON tree the document layer reads and writes: glaze's generic value in its integer-preserving mo...
Definition value.hpp:35
Json valueToJson(Value const &value)
Writes a value in its JSON form: the inverse of valueFromJson, with a timestamp as ISO-8601 text.
Definition value.hpp:668
std::vector< Value > ValueList
A list value.
Definition value.hpp:126
std::expected< Value, std::string > valueFromJson(Json const &json)
Reads a value from its JSON form, structurally.
Definition value.hpp:570
std::map< std::string, Value, std::less<> > ValueRecord
A record value: field name to value, ordered by name.
Definition value.hpp:129
std::string pointerAppend(std::string_view base, std::string_view token)
Appends one reference token to a JSON Pointer, escaping ~ and / as RFC 6901 requires.
Definition value.hpp:68
std::optional< std::expected< WireValue, FieldError > > Encoding
A draft's encoding: std::nullopt when blank, else the value or why it does not encode.
Definition values.hpp:65
The toolkit-agnostic view tree: props, node kinds and their builders.
Tone
What a message or a mark conveys, mapped to the theme's colours.
Definition view.hpp:240
@ Info
Information.
Definition view.hpp:242
@ Warn
Something to check.
Definition view.hpp:244
@ Ok
Success.
Definition view.hpp:243
@ Err
A failure.
Definition view.hpp:245
@ Neutral
Nothing in particular.
Definition view.hpp:241
Node actionBar(ActionBar spec)
An action bar node.
Definition view.hpp:1324
@ SingleLine
One line; Enter submits.
Definition view.hpp:210
@ Multiline
Several lines.
Definition view.hpp:211
@ Password
One line, masked.
Definition view.hpp:212
TextRole
How a Text is styled; each backend maps a role to its theme.
Definition view.hpp:199
@ Heading
A section heading.
Definition view.hpp:202
@ Success
A confirmation.
Definition view.hpp:204
@ Muted
Secondary text.
Definition view.hpp:201
@ Error
An error message.
Definition view.hpp:203
@ Normal
Body text.
Definition view.hpp:200
@ Section
The heading of a group within a list: body size, bold and muted, below a Heading.
Definition view.hpp:205
DateMode
What a DateTimeInput edits.
Definition view.hpp:228
@ Date
A calendar date; the time of day is midnight in the display zone.
Definition view.hpp:229
@ DateTime
A date and a time of day.
Definition view.hpp:230
Node forEach(std::function< std::vector< RowT >()> rows, std::function< Key(RowT const &)> keyOf, std::function< Node(reactive::Signal< RowT > const &)> rowView, Axis axis=Axis::Vertical, int gap=0)
One widget per row of a keyed collection read through a binding (a Query's value() included).
Definition view.hpp:1396
Node badge(Badge spec)
A short label marking a state.
Definition view.hpp:1294
std::function< void()> Action
A widget event handler taking no arguments.
Definition view.hpp:33
Node column(Column spec)
Children stacked top to bottom.
Definition view.hpp:1224
@ Single
At most one row.
Definition view.hpp:265
@ None
Rows can be activated, not selected.
Definition view.hpp:264
@ Multiple
Any number of rows.
Definition view.hpp:266
@ End
The right edge in a left-to-right layout.
Definition view.hpp:251
@ Start
The left edge in a left-to-right layout.
Definition view.hpp:250
@ Open
An existing file.
Definition view.hpp:235
@ Save
A file to create or overwrite.
Definition view.hpp:236
Node text(Text spec)
Static or bound text.
Definition view.hpp:1194
Node select(Select spec)
A choice of one option.
Definition view.hpp:1214
@ Radio
Every option visible, one marked.
Definition view.hpp:218
@ Dropdown
A closed field that opens a list.
Definition view.hpp:217
Node progress(Progress spec)
A progress bar.
Definition view.hpp:1299
std::shared_ptr< NodeData const > Node
A view-tree node: immutable, shared data.
Definition view.hpp:403
Node row(Row spec)
Children laid out left to right.
Definition view.hpp:1229
std::variant< std::int64_t, std::string > Key
The identity of a Switch case, a Select option, a ForEach or Table row and a drag payload.
Definition view.hpp:38
Node steps(Steps spec)
The steps of a process.
Definition view.hpp:1304
Axis
The direction a stack arranges, or a scroll area scrolls, its content.
Definition view.hpp:222
@ Vertical
Top to bottom.
Definition view.hpp:223
@ Horizontal
Left to right.
Definition view.hpp:224
std::uint32_t SlotId
The stable identity of a slot: a bound property a renderer can refer to by number.
Definition view.hpp:45
StepState
Where one step of a Steps indicator stands.
Definition view.hpp:255
@ Pending
Not started.
Definition view.hpp:256
@ Active
In progress.
Definition view.hpp:257
@ Failed
Stopped by a failure.
Definition view.hpp:259
@ Done
Completed.
Definition view.hpp:258
Node makeNode(Kind spec)
Wraps one node aggregate into a shared node.
Definition view.hpp:1185
Node button(Button spec)
A push button.
Definition view.hpp:1199
SlotBinding< std::decay_t< F > > slot(SlotId slotId, F &&read)
A slot with an id.
Definition view.hpp:63
Wizards, headless: a w-* document as one FormSession per step, sequenced by successful submissions,...
wizardView: a WizardSession rendered as a view tree (ui::Node): the title and step indicator,...