morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
mount.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <cstddef>
6#include <cstdint>
7#include <exception>
8#include <functional>
9#include <limits>
10#include <map>
11#include <memory>
12#include <numeric>
13#include <optional>
14#include <ranges>
15#include <stdexcept>
16#include <string>
17#include <string_view>
18#include <type_traits>
19#include <unordered_map>
20#include <unordered_set>
21#include <utility>
22#include <variant>
23#include <vector>
24
25#include "../attributes.hpp"
27#include "../reactive/scope.hpp"
29#include "../ui/backend.hpp"
30#include "view.hpp"
31
32/// @file
33/// @brief `morph::ui::Mounted`: builds a view tree's widgets through a backend once, and keeps them current through
34/// reactive bindings.
35///
36/// Specified in `docs/spec/ui/view_tree.md`, "Mount".
37
38namespace morph::ui {
39
40namespace detail {
41
42/// @brief The site names a misuse in this layer is reported under, through the runtime's owner probe.
43namespace site {
44/// @brief An exception escaped a widget callback; the mount caught it, so it never reaches the backend.
45inline constexpr char const* kCallbackThrew = "morph::ui: an exception escaped a widget callback";
46/// @brief A ForEach or Table snapshot repeated a key; the later row is refused and the first one kept.
47inline constexpr char const* kDuplicateKey = "morph::ui: duplicate ForEach key";
48} // namespace site
49
50/// @brief Called for each newly mounted row with its widget and key; a Table tells its widget the row's key.
51using RowHook = std::function<void(reactive::Scope&, Node const&, Widget&, Key const&)>;
52
53/// @brief Whether a row's widget is realized, and if not, why -- so the windowing pass of `reconcile` never
54/// confuses a row that has no widget by design (its view was a null node) with one whose widget is simply
55/// not realized right now, which it should realize the next time the row enters the visible range.
56enum class RowWidgetState : std::uint8_t {
57 Realized, ///< `widgetScope`/`widget` are set.
58 Dewindowed, ///< The row's view is not null, but its widget is not realized: out of the visible range.
59 NullView, ///< The row's view is a null node; it never had a widget and never will while that stays true.
60};
61
62/// @brief One mounted row of a ForEach or Table.
63///
64/// A row's state (its slot, and the signal it reads) and its widget are owned by two separate scopes so a
65/// virtualized table can release a row's widget -- the expensive part of mounting a row -- while its key is still
66/// in the data, without losing the row's place: `stateScope` is made once, when the key first appears, and lives
67/// until the key is gone; `widgetScope` comes and goes with whether the row is realized. A non-virtualized
68/// `ForEach`/`Table` (below its `Virtualization::threshold`, or on a backend that never dewindows a row) always
69/// has `widgetScope` set alongside `stateScope`, so this costs nothing when windowing never applies.
70struct KeyedRow {
71 /// @brief The row's key.
73 /// @brief Owns the row's slot; made once, for as long as the key is in the data.
74 std::unique_ptr<reactive::Scope> stateScope;
75 /// @brief The row's slot, owned by `stateScope`.
76 RowSlot* slot = nullptr;
77 /// @brief Owns the row's widget and its bindings; null while the row is dewindowed or has no view.
78 std::unique_ptr<reactive::Scope> widgetScope;
79 /// @brief The row's widget, owned by `widgetScope`; null exactly when `widgetScope` is null.
80 Widget* widget = nullptr;
81 /// @brief Which of the three states above `widget` is in.
83 /// @brief This row's position in view order, as of the most recent `reconcile` pass -- not its position in
84 /// `KeyedRows::list`, which `reorder` groups realized rows ahead of dewindowed ones within (so the
85 /// container only ever holds the widgets that exist), and so no longer matches view order once any row
86 /// is dewindowed. `applyWindow` compares this against the visible range; `reconcile` is the only writer.
87 std::size_t viewPosition = 0;
88};
89
90/// @brief What the backend's own scroll position asks a virtualized table to realize next.
92 /// @brief The view index of the first row the backend wants realized.
93 std::size_t first = 0;
94 /// @brief How many rows from `first` the backend wants realized; 0 (the default) asks for nothing, which a
95 /// table below its `Virtualization::threshold` never overrides, since its windowing Effect only runs on
96 /// a backend that calls `setOnVisibleRangeChanged` in the first place.
97 std::size_t count = 0;
98
99 /// @brief Memberwise equality, so an unchanged range notifies nobody.
100 /// @return Whether both `first` and `count` match.
101 bool operator==(VisibleRange const&) const = default;
102};
103
104/// @brief The mounted rows of one ForEach or Table, in their container's order.
105struct KeyedRows {
106 KeyedRows() = default;
107 /// @brief Unmounts the rows last first, as a container's children are; a vector's own destruction order differs
108 /// between standard libraries.
110 while (!list.empty()) {
111 list.pop_back();
112 }
113 }
114 KeyedRows(KeyedRows const&) = delete;
115 KeyedRows& operator=(KeyedRows const&) = delete;
116 KeyedRows(KeyedRows&&) = delete;
117 KeyedRows& operator=(KeyedRows&&) = delete;
118
119 /// @brief The rows; those with a widget are in the container's order.
120 std::vector<KeyedRow> list;
121};
122
123/// @brief Which entries of a permutation lie on one longest increasing run.
124///
125/// @p sequence holds, in current order, each widget's target position; the widgets on the run are already in
126/// order relative to each other and stay where they are, and every other one is moved once.
127/// @param sequence A permutation of `0 .. n-1`.
128/// @return Indexed by target position: whether that widget stays.
129[[nodiscard]] inline std::vector<bool> stableTargets(std::vector<std::size_t> const& sequence) {
130 constexpr auto kNone = static_cast<std::size_t>(-1);
131 std::vector<std::size_t> tails; // tails.at(n - 1): where the smallest tail of a run of length n sits
132 std::vector<std::size_t> previous(sequence.size(), kNone);
133 for (std::size_t i = 0; i < sequence.size(); ++i) {
134 auto const place = std::ranges::lower_bound(tails, sequence.at(i), {},
135 [&sequence](std::size_t entry) { return sequence.at(entry); });
136 auto const length = static_cast<std::size_t>(place - tails.begin());
137 if (length > 0) {
138 previous.at(i) = tails.at(length - 1);
139 }
140 if (length == tails.size()) {
141 tails.push_back(i);
142 } else {
143 tails.at(length) = i;
144 }
145 }
146 std::vector<bool> stable(sequence.size(), false);
147 for (std::size_t cursor = tails.empty() ? kNone : tails.back(); cursor != kNone; cursor = previous.at(cursor)) {
148 stable.at(sequence.at(cursor)) = true;
149 }
150 return stable;
151}
152
153/// @brief Brings a container's children from their current order into the target order with `moveChild`,
154/// moving only the children outside a longest run already in order.
155/// @param container The container.
156/// @param current Its children, in their current order. Kept equal to the container's order after every move, so
157/// it is still that order when a move throws.
158/// @param target The same children, in the order wanted.
159inline void reorderChildren(ContainerWidget& container, std::vector<Widget*>& current,
160 std::vector<Widget*> const& target) {
161 std::unordered_map<Widget const*, std::size_t> targetOf;
162 for (std::size_t i = 0; i < target.size(); ++i) {
163 targetOf.emplace(target.at(i), i);
164 }
165 std::vector<std::size_t> sequence;
166 sequence.reserve(current.size());
167 for (Widget const* const child : current) {
168 sequence.push_back(targetOf.at(child));
169 }
170 std::vector<bool> const stable = stableTargets(sequence);
171 auto const indexOf = [&current](Widget const* child) {
172 return static_cast<std::size_t>(std::ranges::find(current, child) - current.begin());
173 };
174 // From the back: each moved child goes directly before its successor in the target order, which is already
175 // in place, so one move per unstable child suffices.
176 for (std::size_t position = target.size(); position-- > 0;) {
177 if (stable.at(position)) {
178 continue;
179 }
180 Widget* const moving = target.at(position);
181 std::size_t const from = indexOf(moving);
182 std::size_t destination = current.size() - 1;
183 if (position + 1 < target.size()) {
184 std::size_t const before = indexOf(target.at(position + 1));
185 destination = from < before ? before - 1 : before;
186 }
187 if (destination == from) {
188 continue;
189 }
190 container.moveChild(*moving, destination);
191 current.erase(current.begin() + static_cast<std::ptrdiff_t>(from));
192 current.insert(current.begin() + static_cast<std::ptrdiff_t>(destination), moving);
193 }
194}
195
196/// @brief Where one Tabs keeps its pages: a slot per tab, made the first time the tab is selected and kept, hidden,
197/// after.
198struct Pages {
199 /// @brief Each page's slot; null until the tab is first selected.
200 std::vector<SlotWidget*> pageSlots;
201 /// @brief The page shown now, if any; may be past the last page, and then none is shown.
202 std::optional<std::size_t> shown;
203 /// @brief The tab the bar highlights, as far as the mount knows: the last one it set, or the user's pick since.
204 std::optional<std::size_t> highlighted;
205 /// @brief The Tabs' `onSelect`, run as a widget callback; empty when the Tabs has none.
206 std::function<void(std::size_t)> onSelect;
207};
208
209/// @brief Builds widgets for nodes and wires their props and callbacks.
210///
211/// One per `Mounted`. It must outlive every scope it mounted into: a Switch, Tabs, Dialog, ForEach or Table binding
212/// calls back into it to mount content later.
213class Mounter {
214public:
215 /// @param runtime The runtime bindings are made in. Borrowed.
216 /// @param backend Makes the widgets. Borrowed.
217 Mounter(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, IViewBackend& backend MORPH_LIFETIMEBOUND) noexcept
218 : _rt{&runtime}, _backend{&backend} {}
219
220 /// @brief Mounts @p node under @p parent; @p scope owns its widgets and bindings.
221 ///
222 /// A node's widget is adopted first, then its bindings, then its children, so destroying the scope destroys
223 /// bindings before their widget and children before their parent.
224 /// @param scope Owns everything mounted.
225 /// @param node The node; null mounts nothing.
226 /// @param parent The container to append to, or null for a root.
227 /// @return The node's widget, or null for a null node.
228 Widget* mount(reactive::Scope& scope, Node const& node, ContainerWidget* parent) {
229 if (!node) {
230 return nullptr;
231 }
232 return std::visit(
233 [&](auto const& spec) -> Widget* {
234 if constexpr (requires { spec.common.layout.alignSelf; }) {
235 checkPlacement(spec.common, parent);
236 }
237 return &this->mountKind(scope, spec, parent);
238 },
239 node->kind);
240 }
241
242 /// @brief Mounts @p node as `mount` does, as a mount of its own: the first `autofocus` widget it mounts, in
243 /// document order, takes keyboard focus once the whole of it has mounted.
244 /// @param scope Owns everything mounted.
245 /// @param node The node; null mounts nothing.
246 /// @param parent The container to append to, or null for a root.
247 /// @return The node's widget, or null for a null node.
248 Widget* mountRoot(reactive::Scope& scope, Node const& node, ContainerWidget* parent) {
249 return mountPass([&] { return mount(scope, node, parent); });
250 }
251
252 /// @brief Moves keyboard focus to the mounted widget whose `focusId` is @p focusId.
253 ///
254 /// Of several such widgets (rows of a list), the one mounted first is taken. A backend that cannot move focus
255 /// leaves it where it was; the request was still made.
256 /// @param focusId The name.
257 /// @return Whether a mounted widget has that name.
258 bool focus(std::string_view focusId) {
259 // lower_bound, not find: of equal names, find may answer any; lower_bound answers the first inserted.
260 auto const found = _focusable.lower_bound(focusId);
261 if (found == _focusable.end() || found->first != focusId) {
262 return false;
263 }
264 try {
265 found->second->focus();
266 } catch (...) { // NOLINT(bugprone-empty-catch)
267 // Focus is a request: a backend that cannot move it leaves it where it was.
268 }
269 return true;
270 }
271
272private:
273 // Runs @p body with @p overlay as the overlay an ActionBar mounts into the footer of, and puts the previous one
274 // back after it.
275 template <typename F>
276 auto underOverlay(ContainerWidget* overlay, F const& body) -> std::invoke_result_t<F const&> {
277 class Restore {
278 public:
279 Restore(Mounter& self, ContainerWidget* next) : _self{&self}, _saved{std::exchange(self._overlay, next)} {}
280 Restore(Restore const&) = delete;
281 Restore& operator=(Restore const&) = delete;
282 Restore(Restore&&) = delete;
283 Restore& operator=(Restore&&) = delete;
284 ~Restore() { _self->_overlay = _saved; }
285
286 private:
287 Mounter* _self;
288 ContainerWidget* _saved;
289 };
290 Restore const restore{*this, overlay};
291 return body();
292 }
293
294 // The footer of the overlay whose content is being mounted, or null outside any.
295 [[nodiscard]] ContainerWidget* overlayFooter() const {
296 if (auto* const dialog = dynamic_cast<DialogWidget*>(_overlay)) {
297 return &dialog->footer();
298 }
299 if (auto* const drawer = dynamic_cast<DrawerWidget*>(_overlay)) {
300 return &drawer->footer();
301 }
302 return nullptr;
303 }
304
305 // One mount: the root, or content, a page or a row a binding mounts later. The outermost pass focuses the first
306 // autofocus widget mounted during it, in document order, after everything in it has mounted; a pass nested in
307 // another (content a binding mounts while the root mounts) leaves that to the outer one. A pass that throws gives
308 // back the candidate it found, because its caller destroys what it mounted.
309 template <typename F>
310 auto mountPass(F const& body) -> std::invoke_result_t<F const&> {
311 Widget* const before = _autofocus;
312 ++_passDepth;
313 try {
314 auto result = body();
315 --_passDepth;
316 if (_passDepth == 0) {
317 if (Widget* const target = std::exchange(_autofocus, nullptr); target != nullptr) {
318 try {
319 target->focus();
320 } catch (...) { // NOLINT(bugprone-empty-catch)
321 // Focus is a request: a backend that cannot move it leaves it where it was.
322 }
323 }
324 }
325 return result;
326 } catch (...) {
327 _autofocus = before;
328 --_passDepth;
329 throw;
330 }
331 }
332
333 // A constant is applied once; a binding becomes an equality-gated Computed plus an Effect, made in that order
334 // after the widget, so the Effect dies first. Returns the Computed, or null for a constant.
335 template <typename T, typename Apply>
336 reactive::Computed<T>* bind(reactive::Scope& scope, Prop<T> const& prop, Apply apply) {
337 if (!prop.isBound()) {
338 apply(prop.constant());
339 return nullptr;
340 }
341 auto& value = scope.make<reactive::Computed<T>>(*_rt, prop.binding());
342 scope.effect([&value, apply = std::move(apply)] { apply(value.get()); });
343 return &value;
344 }
345
346 // Runs a widget callback, and returns whether it completed.
347 // - Inside widgetEvent: one batch, and a flush that would start meanwhile is re-posted, so a remount never
348 // destroys a widget whose native handler is on the stack.
349 // - Untracked: a backend may call a handler from inside a setter, and so from inside the binding Effect that
350 // called it; that Effect must not subscribe to what the handler reads.
351 // - An exception is reported and stops here: the backend that called the handler need not be exception-safe.
352 // Writes made before it still flush when the batch closes.
353 template <typename F>
354 static bool runCallback(reactive::Runtime& runtime, F const& body) {
355 return runtime.widgetEvent([&] {
356 return runtime.untracked([&] {
357 try {
358 body();
359 return true;
360 } catch (...) {
361 runtime.core()->report(site::kCallbackThrew);
362 return false;
363 }
364 });
365 });
366 }
367
368 template <typename... Args>
369 [[nodiscard]] std::function<void(Args...)> event(std::function<void(Args...)> const& handler) const {
370 if (!handler) {
371 return {};
372 }
373 // The closure owns a copy of the handler and frees it when it dies; the analyzer loses the copy's heap block
374 // in libc++'s std::function and reports it as leaked.
375 // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
376 return [runtime = _rt, handler](Args... args) {
377 static_cast<void>(runCallback(*runtime, [&] { handler(std::move(args)...); }));
378 };
379 }
380
381 /// @brief A controlled input's re-assertion: what the widget shows as far as the mount knows, what its slot
382 /// says now, and how to show a value.
383 /// @tparam T The value type.
384 template <typename T>
385 struct Reassertion {
386 /// @brief What the widget shows: the last value the mount set, or the last value the user asked for since.
387 /// Owned by the scope, made before this, so it outlives it.
388 std::optional<T>* shown;
389 /// @brief What the slot says now.
390 std::function<T()> read;
391 /// @brief Shows a value, and records it in `*shown`.
392 std::function<void(T const&)> apply;
393 };
394
395 // What a controlled input's event calls after the application's handler, with the value the user asked for.
396 //
397 // The widget shows the request already: it is recorded as shown. Then one turn is posted to the owner; it reads
398 // the slot untracked and shows it when the widget shows anything else. The turn is posted after the event's
399 // widgetEvent closed, so it runs after the flush the event caused, whose bindings record what they set: an
400 // accepted request costs no setter call, a refused one snaps back, and a transformed one, which the binding has
401 // shown already, costs nothing more. The check is owned by @p scope and held weakly by the event and the turn,
402 // so neither does anything once the content is gone.
403 template <typename T, typename Read, typename Apply>
404 [[nodiscard]] std::function<void(T const&)> reasserter(reactive::Scope& scope, std::optional<T>& shown, Read read,
405 Apply apply) {
406 auto const check = std::make_shared<Reassertion<T>>(
407 Reassertion<T>{.shown = &shown, .read = std::move(read), .apply = std::move(apply)});
408 scope.make<std::shared_ptr<Reassertion<T>>>(check);
409 return [runtime = _rt, weak = std::weak_ptr{check}](T const& requested) {
410 if (auto const live = weak.lock()) {
411 *live->shown = requested;
412 } else {
413 return;
414 }
415 // Called from a widget handler, which must not throw: a post that fails leaves the widget showing the
416 // user's value until the slot next changes.
417 try {
418 runtime->owner().post([runtime, weak] {
419 if (auto const live = weak.lock()) {
420 runtime->untracked([&live] { showSlot(*live); });
421 }
422 });
423 } catch (...) { // NOLINT(bugprone-empty-catch): see above
424 }
425 };
426 }
427
428 // Shows the slot's value if the widget shows anything else. A slot that throws is its binding's failure, which
429 // the binding's Effect reports; the widget keeps what the user did.
430 template <typename T>
431 static void showSlot(Reassertion<T> const& check) noexcept {
432 try {
433 T const current = check.read();
434 if (!(check.shown->has_value() && **check.shown == current)) {
435 check.apply(current);
436 }
437 } catch (...) { // NOLINT(bugprone-empty-catch): reported by the binding's Effect, see above
438 }
439 }
440
441 // A controlled input's value: bound like any property, recording what it shows, and not sending what it already
442 // shows; a slot is also re-asserted after each user event (see `reasserter`). Returns what the event calls, or
443 // nothing for a constant, which is not a slot: the widget keeps what the user did to it.
444 template <typename T, typename Apply>
445 [[nodiscard]] std::function<void(T const&)> controlled(reactive::Scope& scope, Prop<T> const& prop, Apply apply) {
446 auto& shown = scope.make<std::optional<T>>();
447 // A value the widget already shows, the user's accepted edit above all, is not sent again.
448 auto show = [&shown, apply = std::move(apply)](T const& value) {
449 if (shown.has_value() && *shown == value) {
450 return;
451 }
452 apply(value);
453 shown = value;
454 };
455 reactive::Computed<T> const* const value = bind(scope, prop, show);
456 if (value == nullptr) {
457 return {};
458 }
459 return reasserter<T>(scope, shown, [value] { return value->get(); }, std::move(show));
460 }
461
462 // An input's event: the application's handler, run as `event` runs it, then the re-assertion of the request.
463 // Installed whenever either exists, so a slot without a handler is read-only rather than editable.
464 template <typename T, typename Arg>
465 [[nodiscard]] std::function<void(Arg)> inputEvent(std::function<void(Arg)> const& handler,
466 std::function<void(T const&)> reassert) const {
467 if (!reassert) {
468 return event(handler);
469 }
470 return [inner = event(handler), reassert = std::move(reassert)](Arg arg) {
471 T const requested(arg);
472 if (inner) {
473 inner(std::move(arg));
474 }
475 reassert(requested);
476 };
477 }
478
479 // The loader's rules for bounds, for a view tree built in C++: no negative bound, no minimum above its maximum,
480 // and no fixed amount outside its bounds, which would leave it no room.
481 static void checkLayout(Common const& common) {
482 LayoutHints const& hints = common.layout;
483 auto const refuse = [&common](std::string const& what) {
484 throw std::logic_error{"morph::ui::mount: " + what + " on the node" +
485 (common.testId.empty() ? std::string{} : " `" + common.testId + "`")};
486 };
487 auto const check = [&refuse](Sizing sizing, std::optional<int> minimum, std::optional<int> maximum,
488 std::string const& axis) {
489 if (minimum.value_or(0) < 0 || maximum.value_or(0) < 0) {
490 refuse("a negative " + axis + " bound");
491 }
492 if (minimum && maximum && *minimum > *maximum) {
493 refuse("a minimum " + axis + " above its maximum");
494 }
495 if (sizing.kind == Sizing::Kind::Fixed &&
496 (sizing.amount < minimum.value_or(sizing.amount) || sizing.amount > maximum.value_or(sizing.amount))) {
497 refuse("a fixed " + axis + " outside its bounds");
498 }
499 };
500 check(hints.width, hints.minWidth, hints.maxWidth, "width");
501 check(hints.height, hints.minHeight, hints.maxHeight, "height");
502 }
503
504 // A dialog or a drawer is sized by the frontend, not by its container: Stretch sizing on one would silently
505 // change how every *other* sibling in its own parent layout is justified. Covers both the document path's own
506 // computed (non-literal) `sizing` -- resolved to a Sizing only here, at mount time, since the document-level
507 // checkSizing validates only a literal one -- and a C++ author calling ui::dialog()/ui::drawer() directly, both
508 // of which reach this same mountKind overload before either backend ever sees the result.
509 static void checkOverlayLayout(Common const& common, std::string_view kind) {
510 LayoutHints const& hints = common.layout;
511 auto const refuse = [&common, kind](std::string const& axis) {
512 throw std::logic_error{"morph::ui::mount: a " + std::string{kind} +
513 " is sized by the frontend, not by its container; its " + axis + " cannot stretch" +
514 (common.testId.empty() ? std::string{} : " (node `" + common.testId + "`)")};
515 };
516 if (hints.width.kind == Sizing::Kind::Stretch) {
517 refuse("width");
518 }
519 if (hints.height.kind == Sizing::Kind::Stretch) {
520 refuse("height");
521 }
522 }
523
524 // The loader's rule for `alignSelf`, for a view tree built in C++: only a stack's or a grid's child has a cross
525 // axis to be placed on.
526 static void checkPlacement(Common const& common, ContainerWidget const* parent) {
527 if (common.layout.alignSelf && dynamic_cast<StackWidget const*>(parent) == nullptr &&
528 dynamic_cast<GridWidget const*>(parent) == nullptr) {
529 std::string const named = common.testId.empty() ? std::string{} : " `" + common.testId + "`";
530 throw std::logic_error{
531 "morph::ui::mount: `alignSelf` places a node in its row, column or grid, and the node" + named +
532 " is not in one"};
533 }
534 }
535
536 void applyCommon(reactive::Scope& scope, Widget& widget, Common const& common) {
537 if (common.visible.isBound() || !common.visible.constant()) {
538 bind(scope, common.visible, [&widget](bool shown) { widget.setVisible(shown); });
539 }
540 if (common.enabled.isBound() || !common.enabled.constant()) {
541 bind(scope, common.enabled, [&widget](bool enabled) { widget.setEnabled(enabled); });
542 }
543 if (common.layout != LayoutHints{}) {
544 checkLayout(common);
545 widget.setLayout(common.layout);
546 }
547 if (common.dragKey.isBound() || common.dragKey.constant().has_value()) {
548 bind(scope, common.dragKey, [&widget](std::optional<Key> const& key) { widget.setDragKey(key); });
549 }
550 if (common.onDrop) {
551 applyDrop(widget, common);
552 }
553 if (common.a11y.name.isBound() || !common.a11y.name.constant().empty()) {
554 bind(scope, common.a11y.name, [&widget](std::string const& name) { widget.setAccessibleName(name); });
555 }
556 if (!common.a11y.role.empty()) {
557 widget.setAccessibleRole(common.a11y.role);
558 }
559 if (!common.testId.empty()) {
560 widget.setTestId(common.testId);
561 }
562 if (common.tooltip.isBound() || !common.tooltip.constant().empty()) {
563 bind(scope, common.tooltip, [&widget](std::string const& text) { widget.setTooltip(text); });
564 }
565 if (!common.surface.empty()) {
566 widget.setSurface(common.surface);
567 }
568 if (!common.keys.empty()) {
569 applyKeys(widget, common.keys);
570 }
571 if (common.autofocus && _autofocus == nullptr) {
572 _autofocus = &widget;
573 }
574 if (!common.focusId.empty()) {
575 scope.make<Focusable>(_focusable, _focusable.emplace(common.focusId, &widget));
576 }
577 }
578
579 using FocusMap = std::multimap<std::string, Widget*, std::less<>>;
580
581 // A widget's entry in the focus map, owned by the widget's scope after the widget, so it leaves the map before
582 // the widget is destroyed.
583 struct Focusable {
584 Focusable(FocusMap& owner, FocusMap::iterator added) noexcept : map{&owner}, entry{added} {}
585 Focusable(Focusable const&) = delete;
586 Focusable& operator=(Focusable const&) = delete;
587 Focusable(Focusable&&) = delete;
588 Focusable& operator=(Focusable&&) = delete;
589 ~Focusable() { map->erase(entry); }
590
591 FocusMap* map;
592 FocusMap::iterator entry;
593 };
594
595 // No predicate accepts every key; a predicate that throws refuses the drop.
596 void applyDrop(Widget& widget, Common const& common) {
597 std::function<bool(Key const&)> accepts = [](Key const&) { return true; };
598 if (common.accepts) {
599 accepts = [runtime = _rt, predicate = common.accepts](Key const& key) {
600 bool accepted = false;
601 static_cast<void>(runCallback(*runtime, [&] { accepted = predicate(key); }));
602 return accepted;
603 };
604 }
605 widget.setDropHandler(std::move(accepts), event(common.onDrop));
606 }
607
608 // One setKeys for all the chords; a chord listed twice runs its first binding, as a widget callback.
609 void applyKeys(Widget& widget, std::vector<KeyBinding> const& keys) {
610 std::vector<std::string> chords;
611 chords.reserve(keys.size());
612 for (KeyBinding const& binding : keys) {
613 chords.push_back(binding.chord);
614 }
615 widget.setKeys(chords, [runtime = _rt, keys](std::string const& chord) {
616 auto const bound = std::ranges::find(keys, chord, &KeyBinding::chord);
617 if (bound != keys.end() && bound->onPress) {
618 static_cast<void>(runCallback(*runtime, bound->onPress));
619 }
620 });
621 }
622
623 void mountChildren(reactive::Scope& scope, std::vector<Node> const& children, ContainerWidget& container) {
624 for (Node const& child : children) {
625 static_cast<void>(mount(scope, child, &container));
626 }
627 }
628
629 // The alignment only when it is not the default, as for a button's emphasis, so a stack that says nothing more
630 // costs no call.
631 Widget& mountStack(reactive::Scope& scope, Axis axis, std::vector<Node> const& children, int gap,
632 Common const& common, ContainerWidget* parent, Align align = Align::Stretch,
633 Justify justify = Justify::Start) {
634 StackWidget& widget = scope.adopt(_backend->createStack(parent, axis));
635 applyCommon(scope, widget, common);
636 widget.setGap(spacingStep(gap));
637 if (align != Align::Stretch) {
638 widget.setAlign(align);
639 }
640 if (justify != Justify::Start) {
641 widget.setJustify(justify);
642 }
643 mountChildren(scope, children, widget);
644 return widget;
645 }
646
647 // The entries as the widget shows them, each binding read once; called inside the menu's one binding.
648 // NOLINTNEXTLINE(misc-no-recursion): a submenu is a menu one level down.
649 [[nodiscard]] static std::vector<MenuEntry> entriesOf(std::vector<MenuItem> const& items) {
650 std::vector<MenuEntry> entries;
651 entries.reserve(items.size());
652 for (MenuItem const& item : items) {
653 entries.push_back(MenuEntry{.label = item.label.evaluate(),
654 .icon = item.icon,
655 .keys = item.keys,
656 .checked = item.checked.evaluate(),
657 .enabled = item.enabled.evaluate(),
658 .items = entriesOf(item.items)});
659 }
660 return entries;
661 }
662
663 // Whether any entry, at any depth, has a bound label, check mark or enablement.
664 // NOLINTNEXTLINE(misc-no-recursion): a submenu is a menu one level down.
665 [[nodiscard]] static bool anyBound(std::vector<MenuItem> const& items) {
666 return std::ranges::any_of(items, entryBound);
667 }
668
669 // Whether this entry, or one in its submenu, has a bound label, check mark or enablement.
670 // NOLINTNEXTLINE(misc-no-recursion): a submenu is a menu one level down.
671 [[nodiscard]] static bool entryBound(MenuItem const& item) {
672 return item.label.isBound() || item.checked.isBound() || item.enabled.isBound() || anyBound(item.items);
673 }
674
675 // The entry a path names, or null when the path names none.
676 [[nodiscard]] static MenuItem const* itemAt(std::vector<MenuItem> const& items,
677 std::vector<std::size_t> const& path) {
678 std::vector<MenuItem> const* level = &items;
679 MenuItem const* found = nullptr;
680 for (std::size_t const index : path) {
681 if (level == nullptr || index >= level->size()) {
682 return nullptr;
683 }
684 found = &level->at(index);
685 level = &found->items;
686 }
687 return found;
688 }
689
690 /// An input's label and whether it shows it: only when it has a label or shows one, so an input without a label
691 /// is told nothing.
692 template <class Widget>
693 void applyCaption(reactive::Scope& scope, Widget& widget, Prop<std::string> const& label, bool shown) {
694 if (!shown && !label.isBound() && label.constant().empty()) {
695 return;
696 }
697 bind(scope, label, [&widget, shown](std::string const& text) { widget.setCaption(text, shown); });
698 }
699
700 // Like applyCommon, a constant default calls no setter.
701 void applyField(reactive::Scope& scope, FieldWidget& widget, FieldState const& field) {
702 if (field.readonly.isBound() || field.readonly.constant()) {
703 bind(scope, field.readonly, [&widget](bool readonly) { widget.setReadOnly(readonly); });
704 }
705 if (field.required.isBound() || field.required.constant()) {
706 bind(scope, field.required, [&widget](bool required) { widget.setRequired(required); });
707 }
708 if (field.errors.isBound() || !field.errors.constant().empty()) {
709 bind(scope, field.errors, [&widget](std::vector<std::string> const& errors) { widget.setErrors(errors); });
710 }
711 if (field.stale.isBound() || field.stale.constant()) {
712 bind(scope, field.stale, [&widget](bool stale) { widget.setStale(stale); });
713 }
714 }
715
716 Widget& mountKind(reactive::Scope& scope, Text const& spec, ContainerWidget* parent) {
717 TextWidget& widget = scope.adopt(_backend->createText(parent));
718 applyCommon(scope, widget, spec.common);
719 applyField(scope, widget, spec.field);
720 bind(scope, spec.text, [&widget](std::string const& text) { widget.setText(text); });
721 bind(scope, spec.role, [&widget](TextRole role) { widget.setRole(role); });
722 return widget;
723 }
724
725 Widget& mountKind(reactive::Scope& scope, Button const& spec, ContainerWidget* parent) {
726 if (spec.style == ButtonStyle::Push && (spec.selected.isBound() || spec.selected.constant())) {
727 throw std::logic_error{"morph::ui: `selected` marks a list item; give the button ButtonStyle::Item"};
728 }
729 ButtonWidget& widget = scope.adopt(_backend->createButton(parent));
730 applyCommon(scope, widget, spec.common);
731 bind(scope, spec.label, [&widget](std::string const& label) { widget.setLabel(label); });
732 if (spec.emphasis != ButtonEmphasis::Normal) {
733 widget.setEmphasis(spec.emphasis);
734 }
735 if (!spec.icon.empty()) {
736 widget.setIcon(spec.icon);
737 }
738 if (spec.style == ButtonStyle::Item) {
739 widget.setStyle(spec.style);
740 if (spec.selected.isBound() || spec.selected.constant()) {
741 bind(scope, spec.selected, [&widget](bool selected) { widget.setSelected(selected); });
742 }
743 }
744 widget.setOnClick(event(spec.onClick));
745 return widget;
746 }
747
748 Widget& mountKind(reactive::Scope& scope, TextInput const& spec, ContainerWidget* parent) {
749 bool const hasUnit = spec.unit.isBound() || !spec.unit.constant().empty();
750 if ((hasUnit || spec.decimals >= 0 || !spec.icon.empty()) && spec.mode == TextInputMode::Multiline) {
751 throw std::logic_error{
752 "morph::ui: a multiline text input holds no number, so it takes no unit, places or icon"};
753 }
754 TextInputWidget& widget = scope.adopt(_backend->createTextInput(parent, spec.mode));
755 applyCommon(scope, widget, spec.common);
756 applyField(scope, widget, spec.field);
757 auto reassert = controlled(scope, spec.value, [&widget](std::string const& value) { widget.setText(value); });
758 bind(scope, spec.placeholder, [&widget](std::string const& text) { widget.setPlaceholder(text); });
759 // An input without a unit or places is not told any, so a backend sees only what a field declares.
760 if (hasUnit) {
761 bind(scope, spec.unit, [&widget](std::string const& unit) { widget.setUnit(unit); });
762 }
763 if (spec.decimals >= 0) {
764 widget.setDecimals(spec.decimals);
765 }
766 if (!spec.icon.empty()) {
767 widget.setIcon(spec.icon);
768 }
769 applyCaption(scope, widget, spec.label, spec.showLabel);
770 widget.setOnChange(inputEvent(spec.onChange, reassert));
771 widget.setOnSubmit(inputEvent(spec.onSubmit, std::move(reassert)));
772 widget.setOnCommit(event(spec.onCommit));
773 return widget;
774 }
775
776 Widget& mountKind(reactive::Scope& scope, Checkbox const& spec, ContainerWidget* parent) {
777 CheckboxWidget& widget = scope.adopt(_backend->createCheckbox(parent));
778 applyCommon(scope, widget, spec.common);
779 applyField(scope, widget, spec.field);
780 bind(scope, spec.label, [&widget](std::string const& label) { widget.setLabel(label); });
781 widget.setOnToggle(inputEvent(
782 spec.onToggle, controlled(scope, spec.checked, [&widget](bool checked) { widget.setChecked(checked); })));
783 return widget;
784 }
785
786 Widget& mountKind(reactive::Scope& scope, Select const& spec, ContainerWidget* parent) {
787 SelectWidget& widget = scope.adopt(_backend->createSelect(parent, spec.style));
788 applyCommon(scope, widget, spec.common);
789 applyField(scope, widget, spec.field);
790 bind(scope, spec.options, [&widget](std::vector<SelectOption> const& options) { widget.setOptions(options); });
791 widget.setOnSelect(inputEvent(
792 spec.onSelect,
793 controlled(scope, spec.selected, [&widget](std::optional<Key> const& key) { widget.setSelected(key); })));
794 widget.setOnOpen(event(spec.onOpen));
795 applyCaption(scope, widget, spec.label, spec.showLabel);
796 return widget;
797 }
798
799 Widget& mountKind(reactive::Scope& scope, Menu const& spec, ContainerWidget* parent) {
800 MenuWidget& widget = scope.adopt(_backend->createMenu(parent));
801 applyCommon(scope, widget, spec.common);
802 std::vector<MenuItem> const& items = spec.items;
803 // One prop for the whole tree: a bound label, mark or enablement anywhere re-sends every entry, which keeps
804 // setItems the one setter.
805 Prop<std::vector<MenuEntry>> const entries =
806 anyBound(items) ? Prop<std::vector<MenuEntry>>([items = items] { return entriesOf(items); })
807 : Prop<std::vector<MenuEntry>>(entriesOf(items));
808 bind(scope, entries, [&widget](std::vector<MenuEntry> const& shown) { widget.setItems(shown); });
809 widget.setOnActivate(
810 event(std::function<void(std::vector<std::size_t>)>{[items = items](std::vector<std::size_t> const& path) {
811 MenuItem const* const item = itemAt(items, path);
812 if (item != nullptr && item->onSelect) {
813 item->onSelect();
814 }
815 }}));
816 return widget;
817 }
818
819 Widget& mountKind(reactive::Scope& scope, Column const& spec, ContainerWidget* parent) {
820 return mountStack(scope, Axis::Vertical, spec.children, spec.gap, spec.common, parent, spec.align,
821 spec.justify);
822 }
823
824 Widget& mountKind(reactive::Scope& scope, Row const& spec, ContainerWidget* parent) {
825 if (spec.tone && dynamic_cast<TableWidget*>(parent) == nullptr) {
826 throw std::logic_error{"morph::ui: a row's tone is a table row's; this row is not in a table"};
827 }
828 return mountStack(scope, Axis::Horizontal, spec.children, spec.gap, spec.common, parent, spec.align,
829 spec.justify);
830 }
831
832 Widget& mountKind(reactive::Scope& scope, Grid const& spec, ContainerWidget* parent) {
833 GridWidget& widget = scope.adopt(_backend->createGrid(parent));
834 applyCommon(scope, widget, spec.common);
835 widget.setColumns(spec.columns);
836 widget.setGap(spacingStep(spec.gap));
837 if (spec.align != Align::Stretch) {
838 widget.setAlign(spec.align);
839 }
840 for (GridCell const& cell : spec.cells) {
841 Widget* const child = mount(scope, cell.node, &widget);
842 if (child != nullptr && cell.span != 1) {
843 widget.setSpan(*child, cell.span);
844 }
845 }
846 return widget;
847 }
848
849 Widget& mountKind(reactive::Scope& scope, Spacer const& spec, ContainerWidget* parent) {
850 SpacerWidget& widget = scope.adopt(_backend->createSpacer(parent));
851 applyCommon(scope, widget, spec.common);
852 return widget;
853 }
854
855 Widget& mountKind(reactive::Scope& scope, Panel const& spec, ContainerWidget* parent) {
856 PanelWidget& widget = scope.adopt(_backend->createPanel(parent));
857 applyCommon(scope, widget, spec.common);
858 bind(scope, spec.title, [&widget](std::string const& title) { widget.setTitle(title); });
859 if (Widget* const header = mount(scope, spec.header, &widget); header != nullptr) {
860 widget.setHeader(*header);
861 }
862 widget.setPadding(spacingStep(spec.padding));
863 widget.setCollapsible(spec.collapsible);
864 if (spec.collapsible) {
865 widget.setOnToggle(inputEvent(spec.onToggle, controlled(scope, spec.collapsed, [&widget](bool collapsed) {
866 widget.setCollapsed(collapsed);
867 })));
868 }
869 static_cast<void>(mount(scope, spec.child, &widget));
870 return widget;
871 }
872
873 Widget& mountKind(reactive::Scope& scope, Scroll const& spec, ContainerWidget* parent) {
874 ScrollWidget& widget = scope.adopt(_backend->createScroll(parent, spec.axis));
875 applyCommon(scope, widget, spec.common);
876 static_cast<void>(mount(scope, spec.child, &widget));
877 return widget;
878 }
879
880 // Content a binding mounts (a Switch case, Dialog content), all or nothing: it is built in a scope of its own,
881 // which the caller takes only once the mount completed, so content that throws partway leaves nothing behind.
882 // Built untracked, so the binding's Effect depends on its own prop alone. The scope is one level deeper than the
883 // one that owns the binding, so the owner's Effects run before the content's.
884 [[nodiscard]] std::unique_ptr<reactive::Scope> mountContent(std::size_t depth, Node const& node,
885 ContainerWidget& host) {
886 return mountPass([&] {
887 auto content = std::make_unique<reactive::Scope>(*_rt, depth);
888 _rt->untracked([&] { static_cast<void>(mount(*content, node, &host)); });
889 return content;
890 });
891 }
892
893 // reset() tears the old case down, newest first, before the new one is built. The Computed behind a bound
894 // selector is equality-gated, so an unchanged key never gets here. A case that fails to mount leaves nothing for
895 // its key; the next change of the key mounts again.
896 Widget& mountKind(reactive::Scope& scope, Switch const& spec, ContainerWidget* parent) {
897 SlotWidget& widget = scope.adopt(_backend->createSlot(parent));
898 applyCommon(scope, widget, spec.common);
899 auto& content = scope.make<std::unique_ptr<reactive::Scope>>();
900 bind(scope, spec.selector,
901 [this, &widget, &content, depth = scope.depth() + 1, cases = spec.cases, fallback = spec.fallback,
902 overlay = _overlay](Key const& key) {
903 content.reset();
904 auto const chosen = std::ranges::find(cases, key, &SwitchCase::key);
905 Node const& node = chosen == cases.end() ? fallback : chosen->node;
906 if (node) {
907 content = underOverlay(overlay, [&] { return mountContent(depth, node, widget); });
908 }
909 });
910 return widget;
911 }
912
913 // Every page is owned by `pageScope`, a child scope made before the binding, so pages are destroyed newest first,
914 // after the binding that shows them and before the tab bar. A scope fixes that order; a vector's element
915 // destruction order differs between standard libraries.
916 //
917 // A bound index is not equality-gated: the Effect reads the binding itself, not a Computed. After a failed page,
918 // the application's onSelect writes the index back inside this Effect's own run; a Computed would keep the failed
919 // index and find the user's next pick of that tab unchanged, while the Effect's dependency on the binding's own
920 // signals sees it as the change it is. The cost is that showPage also runs when something the binding reads
921 // changes but the index does not: for a page that is mounted it only re-sends the highlight, and for a page that
922 // failed it tries the mount again, once per run.
923 Widget& mountKind(reactive::Scope& scope, Tabs const& spec, ContainerWidget* parent) {
924 TabsWidget& widget = scope.adopt(_backend->createTabs(parent));
925 applyCommon(scope, widget, spec.common);
926 std::vector<std::string> labels;
927 labels.reserve(spec.tabs.size());
928 for (Tab const& tab : spec.tabs) {
929 labels.push_back(tab.label);
930 }
931 widget.setTabs(labels);
932 auto& pageScope = scope.child();
933 auto& pages = scope.make<Pages>();
934 pages.pageSlots.resize(spec.tabs.size(), nullptr);
935 // A bound index is controlled: after a pick, the bar highlights the page that is shown, so a pick the
936 // document refuses moves the highlight back. A constant index is not a slot, and the bar keeps the pick.
937 std::function<void(std::size_t const&)> reassert;
938 if (spec.selected.isBound()) {
939 reassert = reasserter<std::size_t>(
940 scope, pages.highlighted,
941 [&pages] { return pages.shown.value_or(std::numeric_limits<std::size_t>::max()); },
942 [&widget, &pages](std::size_t index) { highlight(widget, pages, index); });
943 }
944 widget.setOnSelect(inputEvent(spec.onSelect, reassert));
945 pages.onSelect = inputEvent(spec.onSelect, std::move(reassert));
946 auto show = [this, &widget, &pageScope, &pages, pageSpecs = spec.tabs, overlay = _overlay](std::size_t index) {
947 _rt->untracked([&] { showPage(widget, pageScope, pages, pageSpecs, index, overlay); });
948 };
949 if (!spec.selected.isBound()) {
950 show(spec.selected.constant());
951 } else {
952 scope.effect([binding = spec.selected.binding(), show = std::move(show)] { show(binding()); });
953 }
954 return widget;
955 }
956
957 // A page is mounted the first time its tab is selected and only hidden afterwards, so its widgets keep their
958 // state (focus, scroll position, half-typed text) across tab switches. A new page is complete before anything
959 // else changes: one that fails to mount leaves the previous page shown and selected, and the bar's highlight,
960 // which the user's pick may already have moved, goes back to it. The next change of the index mounts again.
961 //
962 // Only the mount runs under the tabs' overlay, and that overlay is put back before a failure is reported:
963 // reporting it may destroy the Mounted, and with it this Mounter.
964 void showPage(TabsWidget& widget, reactive::Scope& pageScope, Pages& pages, std::vector<Tab> const& pageSpecs,
965 std::size_t index, ContainerWidget* overlay) {
966 bool const exists = index < pageSpecs.size();
967 bool const isNew = exists && pages.pageSlots.at(index) == nullptr;
968 if (isNew) {
969 try {
970 underOverlay(overlay, [&] { mountPage(widget, pageScope, pages, pageSpecs.at(index).node, index); });
971 } catch (...) {
972 if (pages.shown.has_value()) {
973 restoreSelection(widget, pages, *pages.shown);
974 }
975 throw;
976 }
977 }
978 if (pages.shown != index) {
979 if (pages.shown.has_value() && *pages.shown < pages.pageSlots.size()) {
980 if (SlotWidget* const previous = pages.pageSlots.at(*pages.shown); previous != nullptr) {
981 previous->setVisible(false);
982 }
983 }
984 if (exists && !isNew) {
985 pages.pageSlots.at(index)->setVisible(true);
986 }
987 }
988 pages.shown = index;
989 highlight(widget, pages, index);
990 }
991
992 // Moves the bar's highlight, and records it.
993 static void highlight(TabsWidget& widget, Pages& pages, std::size_t index) {
994 widget.setSelected(index);
995 pages.highlighted = index;
996 }
997
998 // After a page failed to mount: puts the bar's highlight back on the page still shown, and tells the application
999 // through `onSelect`, so its index matches the widget and selecting the failed tab again is a change that mounts
1000 // again.
1001 //
1002 // onSelect runs last, from a copy: it is a widget callback, which may destroy the Mounted and with it `pages`,
1003 // the widget and the Effect that called this. Nothing after it touches any of them; the caller only rethrows.
1004 //
1005 // Nothing here may replace the failure being reported: the callback's wrapper reports what the callback throws,
1006 // and anything else thrown here is dropped.
1007 static void restoreSelection(TabsWidget& widget, Pages& pages, std::size_t shown) noexcept {
1008 try {
1009 highlight(widget, pages, shown);
1010 } catch (...) { // NOLINT(bugprone-empty-catch)
1011 // The page's failure is the one reported; the bar keeps whatever highlight it had.
1012 }
1013 try {
1014 std::function<void(std::size_t)> const onSelect = pages.onSelect;
1015 if (onSelect) {
1016 onSelect(shown);
1017 }
1018 } catch (...) { // NOLINT(bugprone-empty-catch)
1019 // Only copying the wrapper can throw here; the application then keeps the failed index.
1020 }
1021 }
1022
1023 // The page is built in a scope of its own and handed to `pageScope` once complete, so one that fails to mount
1024 // leaves nothing behind.
1025 void mountPage(TabsWidget& widget, reactive::Scope& pageScope, Pages& pages, Node const& node, std::size_t index) {
1026 auto page = mountPass([&] {
1027 auto built = std::make_unique<reactive::Scope>(*_rt, pageScope.depth());
1028 SlotWidget& slot = built->adopt(_backend->createSlot(&widget));
1029 static_cast<void>(mount(*built, node, &slot));
1030 return std::pair{std::move(built), &slot};
1031 });
1032 pageScope.adopt(std::move(page.first));
1033 pages.pageSlots.at(index) = page.second;
1034 }
1035
1036 // The content is built before the overlay opens and torn down before it closes. Content that fails to mount
1037 // under a bound `open` leaves the dialog closed and calls `onDismiss`, so the application's `open` follows the
1038 // widget and opening it again is a change that mounts again.
1039 //
1040 // A bound `open` is not equality-gated, for the reason a Tabs index is not: onDismiss writes `open` inside this
1041 // Effect's own run, which a Computed would miss, and then find the next opening unchanged. An Effect that runs
1042 // with `open` unchanged re-sends `setOpen`, and mounts content only if none is mounted: once per run while `open`
1043 // stays true over content that failed.
1044 Widget& mountKind(reactive::Scope& scope, Dialog const& spec, ContainerWidget* parent) {
1045 checkOverlayLayout(spec.common, "dialog");
1046 DialogWidget& widget = scope.adopt(_backend->createDialog(parent));
1047 applyCommon(scope, widget, spec.common);
1048 return mountOverlay(scope, spec, widget);
1049 }
1050
1051 Widget& mountKind(reactive::Scope& scope, Drawer const& spec, ContainerWidget* parent) {
1052 checkOverlayLayout(spec.common, "drawer");
1053 DrawerWidget& widget = scope.adopt(_backend->createDrawer(parent, spec.side));
1054 applyCommon(scope, widget, spec.common);
1055 return mountOverlay(scope, spec, widget);
1056 }
1057
1058 // A Dialog or a Drawer: the title, the dismissal, and content that exists only while open.
1059 template <typename Spec, typename OverlayWidget>
1060 Widget& mountOverlay(reactive::Scope& scope, Spec const& spec, OverlayWidget& widget) {
1061 bind(scope, spec.title, [&widget](std::string const& title) { widget.setTitle(title); });
1062 auto& content = scope.make<std::unique_ptr<reactive::Scope>>();
1063 auto& shownOpen = scope.make<std::optional<bool>>();
1064 // A bound `open` is controlled: a dismissal the document refuses opens the dialog again, over the content it
1065 // still holds. Content that failed to mount is not there to show, so the dialog stays closed for it.
1066 std::function<void()> onDismiss = event(spec.onDismiss);
1067 if (spec.open.isBound()) {
1068 auto reassert =
1069 reasserter<bool>(scope, shownOpen, spec.open.binding(), [&widget, &content, &shownOpen](bool open) {
1070 if (!open || content != nullptr) {
1071 widget.setOpen(open);
1072 shownOpen = open;
1073 }
1074 });
1075 onDismiss = [inner = std::move(onDismiss), reassert = std::move(reassert)] {
1076 if (inner) {
1077 inner();
1078 }
1079 reassert(false);
1080 };
1081 }
1082 widget.setOnDismiss(onDismiss);
1083 auto show = [this, &widget, &content, &shownOpen, depth = scope.depth() + 1, child = spec.child](
1084 bool open, std::function<void()> const& dismiss) {
1085 if (!open) {
1086 content.reset();
1087 } else if (content == nullptr) {
1088 try {
1089 content = underOverlay(&widget, [&] { return mountContent(depth, child, widget); });
1090 } catch (...) {
1091 dismissFailed(dismiss);
1092 throw;
1093 }
1094 }
1095 widget.setOpen(open);
1096 shownOpen = open;
1097 };
1098 if (!spec.open.isBound()) {
1099 // A constant has no application state to bring back in line: content that fails throws out of the mount.
1100 show(spec.open.constant(), {});
1101 } else {
1102 scope.effect([binding = spec.open.binding(), show = std::move(show), onDismiss = std::move(onDismiss)] {
1103 show(binding(), onDismiss);
1104 });
1105 }
1106 return widget;
1107 }
1108
1109 // After a Dialog's content failed to mount: tells the application through `onDismiss` that the dialog is closed.
1110 // It runs last, from a copy: it is a widget callback, which may destroy the Mounted and with it the dialog and
1111 // the Effect that called this; the caller only rethrows after it. Nothing thrown here may replace the failure
1112 // being reported.
1113 static void dismissFailed(std::function<void()> const& onDismiss) noexcept {
1114 try {
1115 std::function<void()> const dismiss = onDismiss;
1116 if (dismiss) {
1117 dismiss();
1118 }
1119 } catch (...) { // NOLINT(bugprone-empty-catch)
1120 // Only copying the wrapper can throw here; the application then keeps `open` true.
1121 }
1122 }
1123
1124 Widget& mountKind(reactive::Scope& scope, ForEach const& spec, ContainerWidget* parent) {
1125 StackWidget& widget = scope.adopt(_backend->createStack(parent, spec.axis));
1126 applyCommon(scope, widget, spec.common);
1127 widget.setGap(spacingStep(spec.gap));
1128 mountRows(scope, spec.model, widget, {}, nullptr);
1129 return widget;
1130 }
1131
1132 // Rows first, then the selection. The widget marks a selected key whenever its row arrives, so the order only
1133 // shapes the log: the first setSelection comes after the rows and already marks them.
1134 Widget& mountKind(reactive::Scope& scope, Table const& spec, ContainerWidget* parent) {
1135 TableWidget& widget = scope.adopt(_backend->createTable(parent));
1136 applyCommon(scope, widget, spec.common);
1137 widget.setColumns(spec.columns);
1138 widget.setShowHeader(spec.showHeader);
1139 widget.setSelectionMode(spec.selectionMode);
1140 widget.setOnActivate(event(spec.onActivate));
1141 mountRows(
1142 scope, spec.rows, widget,
1143 [this, &widget](reactive::Scope& rowScope, Node const& node, Widget& rowWidget, Key const& key) {
1144 widget.setRowKey(rowWidget, key);
1145 // A row's tone binds in the row's scope, after its key.
1146 Row const* const view = node ? std::get_if<Row>(&node->kind) : nullptr;
1147 if (view != nullptr && view->tone) {
1148 bind(rowScope, *view->tone, [&widget, &rowWidget](std::optional<Tone> const& tone) {
1149 widget.setRowTone(rowWidget, tone);
1150 });
1151 }
1152 },
1153 &spec.virtualize);
1154 widget.setOnSelectionChange(inputEvent(
1155 spec.onSelectionChange, controlled(scope, spec.selection, [&widget](std::vector<Key> const& keys) {
1156 widget.setSelection(keys);
1157 })));
1158 return widget;
1159 }
1160
1161 // The model, then the session, then the rows, then the Effect(s): destroyed in reverse, so an Effect stops
1162 // first, the rows (whose slots point into the session's snapshot) go next, and the model outlives its session.
1163 // @p virtualize is null for a ForEach (no backend seam exists yet to dewindow a bare keyed stack); when
1164 // non-null but @p container is not also a TableWidget (today, impossible -- only Table passes a non-null
1165 // virtualize, and only its mountKind gives it a TableWidget -- kept as a defensive check, not a real case),
1166 // virtualization is silently not wired, same as if it were null.
1167 void mountRows(reactive::Scope& scope, std::shared_ptr<ForEachModel const> const& model,
1168 ContainerWidget& container, RowHook onRow, Virtualization const* virtualize) {
1169 if (!model) {
1170 return;
1171 }
1172 scope.make<std::shared_ptr<ForEachModel const>>(model);
1173 ForEachSession& session = scope.adopt(model->open(*_rt));
1174 auto& rows = scope.make<KeyedRows>();
1175 TableWidget* const table = virtualize != nullptr ? dynamic_cast<TableWidget*>(&container) : nullptr;
1176 if (table == nullptr) {
1177 scope.effect([this, &session, &rows, &container, depth = scope.depth() + 1, onRow, overlay = _overlay] {
1178 std::vector<Key> const keys = session.pull(); // tracked: the rows are this Effect's only source
1179 // Untracked: what a row view reads while it is built, or what a kept row's update touches, must
1180 // not become a dependency of the whole list.
1181 underOverlay(overlay, [&] {
1182 _rt->untracked([&] { reconcile(depth, session, rows.list, container, keys, onRow); });
1183 });
1184 });
1185 return;
1186 }
1187 // The windowing state: written by the backend's own scroll callback (via widgetEvent, so this is never
1188 // written from inside a flush this Effect caused), read by both Effects below -- the rows Effect, so a
1189 // brand-new row's very first mount already respects the window instead of needing a later windowing pass
1190 // to correct it; the windowing Effect, so a scroll alone realizes/releases without re-running reconcile.
1191 auto& range = scope.make<reactive::Signal<VisibleRange>>(*_rt, VisibleRange{});
1192 table->setOnVisibleRangeChanged([this, &range](std::size_t first, std::size_t count) {
1193 runCallback(*_rt, [&] { range.set({.first = first, .count = count}); });
1194 });
1195 scope.effect([this, &session, &rows, &container, &range, threshold = virtualize->threshold,
1196 overscan = virtualize->overscan, depth = scope.depth() + 1, onRow, table, overlay = _overlay] {
1197 std::vector<Key> const keys = session.pull(); // tracked: the rows are this Effect's only source
1198 VisibleRange const window = range.peek(); // untracked: a scroll alone must not re-run this Effect
1199 underOverlay(overlay, [&] {
1200 _rt->untracked([&] {
1201 if (keys.size() < threshold) {
1202 reconcile(depth, session, rows.list, container, keys, onRow);
1203 return;
1204 }
1205 std::pair<VisibleRange, std::size_t> const windowAndOverscan{window, overscan};
1206 reconcile(depth, session, rows.list, container, keys, onRow, &windowAndOverscan);
1207 // Only above threshold: a backend's own selection-filtering logic may need to know a key
1208 // still exists even without a realized row for it right now (backend.hpp's setExistingKeys).
1209 // Below threshold every key already has a realized row, so this would add nothing.
1210 table->setExistingKeys(keys);
1211 });
1212 });
1213 });
1214 scope.effect([this, &rows, &container, &range, threshold = virtualize->threshold,
1215 overscan = virtualize->overscan, depth = scope.depth() + 1, onRow, table, overlay = _overlay] {
1216 VisibleRange const window = range.get(); // tracked: this Effect's own trigger
1217 // Below threshold, every row is already realized (reconcile's own unconditional path, §4.4), and
1218 // setRealizedWindow is not called at all -- not even once with "everything realized" -- so a table
1219 // that never crosses its threshold is indistinguishable, on the backend, from one with no
1220 // virtualization machinery at all.
1221 if (rows.list.size() < threshold) {
1222 return;
1223 }
1224 underOverlay(overlay, [&] { applyWindow(depth, rows.list, container, window, overscan, onRow); });
1225 auto const [first, count] = realizedExtent(rows.list);
1226 table->setRealizedWindow(first, count, rows.list.size());
1227 });
1228 }
1229
1230 // The snapshot index of each key's first occurrence, in order; every later duplicate is reported.
1231 [[nodiscard]] std::vector<std::size_t> firstOccurrences(std::vector<Key> const& keys) {
1232 std::unordered_set<Key> seen;
1233 std::vector<std::size_t> order;
1234 order.reserve(keys.size());
1235 for (std::size_t index = 0; index < keys.size(); ++index) {
1236 if (seen.insert(keys.at(index)).second) {
1237 order.push_back(index);
1238 } else {
1239 _rt->core()->report(site::kDuplicateKey);
1240 }
1241 }
1242 return order;
1243 }
1244
1245 // All or nothing: the row's state and its widget are each built in a scope of their own, one level deeper than
1246 // the ForEach's, which the caller takes only once the mount completed. A new row's widget is realized in the
1247 // same pass as its state (so it is eligible for autofocus like any other newly-mounted content); a widget
1248 // realized later, on a row whose state already exists, goes through `realizeRowWidget` instead, which is not
1249 // its own `mountPass` -- see that function's own comment for why.
1250 [[nodiscard]] KeyedRow mountRow(std::size_t depth, ForEachSession& session, ContainerWidget& container,
1251 Key const& key, std::size_t index, RowHook const& onRow) {
1252 return mountPass([&] {
1253 KeyedRow row{.key = key, .stateScope = std::make_unique<reactive::Scope>(*_rt, depth)};
1254 row.slot = &row.stateScope->adopt(session.makeRow(index));
1255 row.widgetScope = std::make_unique<reactive::Scope>(*_rt, depth);
1256 row.widget = mount(*row.widgetScope, row.slot->view(), &container);
1257 row.widgetState = row.widget != nullptr ? RowWidgetState::Realized : RowWidgetState::NullView;
1258 if (row.widget != nullptr && onRow) {
1259 onRow(*row.widgetScope, row.slot->view(), *row.widget, key);
1260 }
1261 return row;
1262 });
1263 }
1264
1265 // Realizes a widget for a row whose state (`stateScope`/`slot`) already exists -- a row re-entering the
1266 // visible range after being dewindowed. Deliberately not a `mountPass` of its own: `mountPass` gives the first
1267 // `autofocus` widget it mounts the focus once it completes, which is right for a row mounting for the first
1268 // time (`mountRow`, inside the ForEach's own outer pass) but wrong here -- a row that already existed and
1269 // simply scrolled back into view must not steal focus back from wherever the user's focus already is. `mount`
1270 // itself carries no such behavior; only `mountPass` does, so simply not using it is what suppresses it.
1271 void realizeRowWidget(std::size_t depth, KeyedRow& row, ContainerWidget& container, RowHook const& onRow) {
1272 row.widgetScope = std::make_unique<reactive::Scope>(*_rt, depth);
1273 row.widget = mount(*row.widgetScope, row.slot->view(), &container);
1274 row.widgetState = row.widget != nullptr ? RowWidgetState::Realized : RowWidgetState::NullView;
1275 if (row.widget != nullptr && onRow) {
1276 onRow(*row.widgetScope, row.slot->view(), *row.widget, row.key);
1277 }
1278 }
1279
1280 // Releases a row's widget, keeping its state: the container sees an ordinary removal (`releasing` fires exactly
1281 // as it does for a real removal), but `stateScope`/`slot` are untouched, so the row's signal keeps tracking the
1282 // snapshot and is ready for `realizeRowWidget` to mount a fresh widget against it later.
1283 static void dewindowRowWidget(KeyedRow& row) {
1284 row.widgetScope.reset();
1285 row.widget = nullptr;
1286 row.widgetState = RowWidgetState::Dewindowed;
1287 }
1288
1289 // Builds only a new key's state -- no widget, no mount, no onRow call -- for a row `reconcile` decides is
1290 // outside the current window on its very first appearance (a virtualized table above its threshold). The row
1291 // starts `Dewindowed`, exactly as if it had been realized once and then released, so `realizeRowWidget` can
1292 // bring it in later with no special case for "never had a widget" versus "had one and lost it."
1293 [[nodiscard]] KeyedRow mountRowState(std::size_t depth, ForEachSession& session, Key const& key,
1294 std::size_t index) {
1295 KeyedRow row{.key = key, .stateScope = std::make_unique<reactive::Scope>(*_rt, depth)};
1296 row.slot = &row.stateScope->adopt(session.makeRow(index));
1297 row.widgetState = RowWidgetState::Dewindowed;
1298 return row;
1299 }
1300
1301 // Moves the widgets into the snapshot's order, and `rows` with them. A move that throws is recorded in `failure`
1302 // when it is the first failure, and leaves `rows` mirroring the container as it stands.
1303 static void reorder(std::vector<KeyedRow>& rows, ContainerWidget& container, std::vector<Key> const& keys,
1304 std::vector<std::size_t> const& order, std::exception_ptr& failure) {
1305 std::unordered_map<Key, std::size_t> position; // key -> index in `rows`
1306 std::unordered_map<Widget const*, std::size_t> rowOf;
1307 std::vector<Widget*> present;
1308 for (std::size_t i = 0; i < rows.size(); ++i) {
1309 KeyedRow const& row = rows.at(i);
1310 position.emplace(row.key, i);
1311 if (row.widget != nullptr) {
1312 rowOf.emplace(row.widget, i);
1313 present.push_back(row.widget);
1314 }
1315 }
1316 std::vector<Widget*> target;
1317 target.reserve(present.size());
1318 for (std::size_t const index : order) {
1319 if (auto const found = position.find(keys.at(index)); found != position.end()) {
1320 if (KeyedRow const& row = rows.at(found->second); row.widget != nullptr) {
1321 target.push_back(row.widget);
1322 }
1323 }
1324 }
1325 std::vector<KeyedRow> ordered;
1326 ordered.reserve(rows.size());
1327 try {
1328 reorderChildren(container, present, target);
1329 } catch (...) {
1330 if (!failure) {
1331 failure = std::current_exception();
1332 }
1333 }
1334 // `present` is the container's order now: the snapshot's, unless a move threw. Rows without a widget have
1335 // no place in the container and go last.
1336 for (Widget const* const widget : present) {
1337 ordered.push_back(std::move(rows.at(rowOf.at(widget))));
1338 }
1339 for (KeyedRow& row : rows) {
1340 if (row.widget == nullptr) {
1341 ordered.push_back(std::move(row));
1342 }
1343 }
1344 rows = std::move(ordered);
1345 }
1346
1347 // Brings `rows` to the snapshot `keys`. `rows` mirrors the container's order on entry and on exit, also when
1348 // something throws, so a failure costs this run's remaining work and never the bookkeeping of the next one.
1349 // A row that fails to mount leaves nothing behind and does not stop the other rows; the first failure is
1350 // rethrown at the end, for the Effect to report, and the next run mounts the row again.
1351 // @p window is the virtualization window a new key's widget is realized against on its very first appearance,
1352 // or null for a ForEach or a Table below its threshold -- unchanged from before virtualization existed. A new
1353 // key outside the window (or when there is no window at all, above threshold with nothing reported yet, taken
1354 // as {0, overscan}; see the design's "bootstrap window") gets state only (`mountRowState`), left Dewindowed
1355 // for the windowing Effect to realize later if it ever enters the visible range.
1356 void reconcile(std::size_t depth, ForEachSession& session, std::vector<KeyedRow>& rows, ContainerWidget& container,
1357 std::vector<Key> const& keys, RowHook const& onRow,
1358 std::pair<VisibleRange, std::size_t> const* windowAndOverscan = nullptr) {
1359 std::vector<std::size_t> const order = firstOccurrences(keys);
1360 std::unordered_map<Key, std::size_t> wanted; // key -> snapshot index
1361 std::unordered_map<Key, std::size_t> viewPositionOf; // key -> position in view order (order's own index)
1362 for (std::size_t viewPos = 0; viewPos < order.size(); ++viewPos) {
1363 wanted.emplace(keys.at(order.at(viewPos)), order.at(viewPos));
1364 viewPositionOf.emplace(keys.at(order.at(viewPos)), viewPos);
1365 }
1366
1367 // Unmount the gone keys, last first as KeyedRows does, then update the kept ones in place, which keeps
1368 // their widgets. A gone key's widget goes first (the Effect stops first within each row, mirroring the
1369 // order mountRow itself builds the two scopes in: state first, then widget), then its state.
1370 for (KeyedRow& row : std::views::reverse(rows)) {
1371 if (!wanted.contains(row.key)) {
1372 row.widgetScope.reset();
1373 row.stateScope.reset();
1374 }
1375 }
1376 std::erase_if(rows, [](KeyedRow const& row) { return row.stateScope == nullptr; });
1377 std::unordered_set<Key> kept;
1378 for (KeyedRow& row : rows) {
1379 kept.insert(row.key);
1380 row.slot->assign(wanted.at(row.key));
1381 row.viewPosition = viewPositionOf.at(row.key);
1382 }
1383
1384 std::size_t const bootstrapFirst = 0;
1385 std::size_t const bootstrapCount = windowAndOverscan != nullptr ? windowAndOverscan->second : 0;
1386 VisibleRange const window = windowAndOverscan != nullptr && windowAndOverscan->first.count > 0
1387 ? windowAndOverscan->first
1388 : VisibleRange{.first = bootstrapFirst, .count = bootstrapCount};
1389
1390 // Mount the new keys; each realized widget is appended to the container, and every new row (realized or
1391 // state-only) to `rows`. `viewPos` is the row's position in view order -- order's own index -- which is
1392 // what the window means positions in, not `index` (a position in the raw, possibly-reordered `keys`).
1393 std::exception_ptr failure;
1394 rows.reserve(order.size());
1395 for (std::size_t viewPos = 0; viewPos < order.size(); ++viewPos) {
1396 std::size_t const index = order.at(viewPos);
1397 if (kept.contains(keys.at(index))) {
1398 continue;
1399 }
1400 bool const inWindow =
1401 windowAndOverscan == nullptr || (viewPos >= window.first && viewPos < window.first + window.count);
1402 try {
1403 KeyedRow row = inWindow ? mountRow(depth, session, container, keys.at(index), index, onRow)
1404 : mountRowState(depth, session, keys.at(index), index);
1405 row.viewPosition = viewPos;
1406 rows.push_back(std::move(row));
1407 } catch (...) {
1408 if (!failure) {
1409 failure = std::current_exception();
1410 }
1411 }
1412 }
1413
1414 reorder(rows, container, keys, order, failure);
1415 if (failure) {
1416 std::rethrow_exception(failure);
1417 }
1418 }
1419
1420 // Realizes a widget for every row within `window` (± `overscan`) that does not already have one, and releases
1421 // every realized widget outside it. A row's position in view order is its own `viewPosition`, set by the most
1422 // recent `reconcile` pass -- not its position in `rows` itself, which groups realized rows ahead of
1423 // dewindowed ones (so the container only ever holds the widgets that exist) and so no longer matches view
1424 // order once any row is dewindowed. Called only when `rows.size() >= threshold`, from the windowing Effect's
1425 // own Signal<VisibleRange>, never from reconcile, so a scroll alone costs O(rows) to scan, not the full
1426 // reconciliation pass.
1427 void applyWindow(std::size_t depth, std::vector<KeyedRow>& rows, ContainerWidget& container,
1428 VisibleRange const& window, std::size_t overscan, RowHook const& onRow) {
1429 std::size_t const lo = window.first > overscan ? window.first - overscan : 0;
1430 std::size_t const hi =
1431 window.first + window.count + overscan; // exclusive; saturates harmlessly past rows.size()
1432 std::exception_ptr failure;
1433 for (KeyedRow& row : rows) {
1434 bool const shouldBeRealized = row.viewPosition >= lo && row.viewPosition < hi;
1435 if (shouldBeRealized && row.widgetState != RowWidgetState::Realized &&
1436 row.widgetState != RowWidgetState::NullView) {
1437 try {
1438 realizeRowWidget(depth, row, container, onRow);
1439 } catch (...) {
1440 if (!failure) {
1441 failure = std::current_exception();
1442 }
1443 }
1444 } else if (!shouldBeRealized && row.widgetState == RowWidgetState::Realized) {
1445 dewindowRowWidget(row);
1446 }
1447 }
1448 // Realized widgets may now be a different subset of `rows` than before; bring the container back to the
1449 // rows' own view order (`viewPosition`), restricted to what is realized -- `reorder` needs `order` as
1450 // positions into `keys` in the target (view) order, not `rows`' own current array order, which groups
1451 // realized ahead of dewindowed and so is not view order once any row is dewindowed.
1452 std::vector<Key> const keys = [&] {
1453 std::vector<Key> out;
1454 out.reserve(rows.size());
1455 for (KeyedRow const& row : rows) {
1456 out.push_back(row.key);
1457 }
1458 return out;
1459 }();
1460 std::vector<std::size_t> order(rows.size());
1461 std::iota(order.begin(), order.end(), std::size_t{0});
1462 std::ranges::sort(order, {}, [&rows](std::size_t index) { return rows.at(index).viewPosition; });
1463 reorder(rows, container, keys, order, failure);
1464 if (failure) {
1465 std::rethrow_exception(failure);
1466 }
1467 }
1468
1469 // The realized subset's extent in view order: the first realized row's view position and how many rows from
1470 // there are realized. Used only to tell the backend what to size a scrollbar against
1471 // (`TableWidget::setRealizedWindow`); a gap of dewindowed rows between two realized ones (the overscan margin
1472 // straddling a scroll position mid-transition) is not expected in steady state, so this reports the outermost
1473 // realized view positions, not a strict contiguous run.
1474 [[nodiscard]] static std::pair<std::size_t, std::size_t> realizedExtent(std::vector<KeyedRow> const& rows) {
1475 std::optional<std::size_t> first;
1476 std::size_t last = 0;
1477 for (KeyedRow const& row : rows) {
1478 if (row.widgetState == RowWidgetState::Realized) {
1479 first = first ? std::min(*first, row.viewPosition) : row.viewPosition;
1480 last = std::max(last, row.viewPosition);
1481 }
1482 }
1483 return first ? std::pair{*first, last - *first + 1} : std::pair{std::size_t{0}, std::size_t{0}};
1484 }
1485
1486 Widget& mountKind(reactive::Scope& scope, Busy const& spec, ContainerWidget* parent) {
1487 BusyWidget& widget = scope.adopt(_backend->createBusy(parent));
1488 applyCommon(scope, widget, spec.common);
1489 bind(scope, spec.active, [&widget](bool active) { widget.setActive(active); });
1490 bind(scope, spec.label, [&widget](std::string const& label) { widget.setLabel(label); });
1491 return widget;
1492 }
1493
1494 Widget& mountKind(reactive::Scope& scope, Banner const& spec, ContainerWidget* parent) {
1495 BannerWidget& widget = scope.adopt(_backend->createBanner(parent));
1496 applyCommon(scope, widget, spec.common);
1497 bind(scope, spec.tone, [&widget](Tone tone) { widget.setTone(tone); });
1498 bind(scope, spec.text, [&widget](std::string const& text) { widget.setText(text); });
1499 bind(scope, spec.action.label, [&widget](std::string const& label) { widget.setActionLabel(label); });
1500 widget.setOnAction(event(spec.action.onClick));
1501 widget.setDismissible(spec.dismissible);
1502 widget.setOnDismiss(event(spec.onDismiss));
1503 return widget;
1504 }
1505
1506 Widget& mountKind(reactive::Scope& scope, Badge const& spec, ContainerWidget* parent) {
1507 BadgeWidget& widget = scope.adopt(_backend->createBadge(parent));
1508 applyCommon(scope, widget, spec.common);
1509 bind(scope, spec.tone, [&widget](Tone tone) { widget.setTone(tone); });
1510 bind(scope, spec.text, [&widget](std::string const& text) { widget.setText(text); });
1511 widget.setIcon(spec.icon);
1512 return widget;
1513 }
1514
1515 Widget& mountKind(reactive::Scope& scope, Progress const& spec, ContainerWidget* parent) {
1516 ProgressWidget& widget = scope.adopt(_backend->createProgress(parent));
1517 applyCommon(scope, widget, spec.common);
1518 bind(scope, spec.value, [&widget](std::optional<double> value) { widget.setValue(value); });
1519 bind(scope, spec.label, [&widget](std::string const& label) { widget.setLabel(label); });
1520 return widget;
1521 }
1522
1523 Widget& mountKind(reactive::Scope& scope, Steps const& spec, ContainerWidget* parent) {
1524 StepsWidget& widget = scope.adopt(_backend->createSteps(parent));
1525 applyCommon(scope, widget, spec.common);
1526 bind(scope, spec.items, [&widget](std::vector<Step> const& steps) { widget.setSteps(steps); });
1527 bind(scope, spec.current, [&widget](std::optional<std::size_t> index) { widget.setCurrent(index); });
1528 return widget;
1529 }
1530
1531 Widget& mountKind(reactive::Scope& scope, KeyValue const& spec, ContainerWidget* parent) {
1532 KeyValueWidget& widget = scope.adopt(_backend->createKeyValue(parent));
1533 applyCommon(scope, widget, spec.common);
1534 bind(scope, spec.items, [&widget](std::vector<KeyValueItem> const& items) { widget.setItems(items); });
1535 return widget;
1536 }
1537
1538 Widget& mountKind(reactive::Scope& scope, EmptyState const& spec, ContainerWidget* parent) {
1539 EmptyStateWidget& widget = scope.adopt(_backend->createEmptyState(parent));
1540 applyCommon(scope, widget, spec.common);
1541 bind(scope, spec.title, [&widget](std::string const& title) { widget.setTitle(title); });
1542 bind(scope, spec.text, [&widget](std::string const& text) { widget.setText(text); });
1543 widget.setIcon(spec.icon);
1544 bind(scope, spec.action.label, [&widget](std::string const& label) { widget.setActionLabel(label); });
1545 widget.setOnAction(event(spec.action.onClick));
1546 return widget;
1547 }
1548
1549 Widget& mountKind(reactive::Scope& scope, Splitter const& spec, ContainerWidget* parent) {
1550 SplitterWidget& widget = scope.adopt(_backend->createSplitter(parent, spec.orientation));
1551 applyCommon(scope, widget, spec.common);
1552 mountChildren(scope, spec.children, widget);
1553 widget.setOnResize(inputEvent(
1554 spec.onResize,
1555 controlled(scope, spec.sizes, [&widget](std::vector<int> const& sizes) { widget.setSizes(sizes); })));
1556 return widget;
1557 }
1558
1559 // The header is mounted first, so it is the first child; the content follows it.
1560 Widget& mountKind(reactive::Scope& scope, Collapsible const& spec, ContainerWidget* parent) {
1561 CollapsibleWidget& widget = scope.adopt(_backend->createCollapsible(parent));
1562 applyCommon(scope, widget, spec.common);
1563 bind(scope, spec.title, [&widget](std::string const& title) { widget.setTitle(title); });
1564 if (Widget* const header = mount(scope, spec.header, &widget); header != nullptr) {
1565 widget.setHeader(*header);
1566 }
1567 static_cast<void>(mount(scope, spec.child, &widget));
1568 widget.setOnToggle(
1569 inputEvent(spec.onToggle, controlled(scope, spec.open, [&widget](bool open) { widget.setOpen(open); })));
1570 return widget;
1571 }
1572
1573 Widget& mountKind(reactive::Scope& scope, DropZone const& spec, ContainerWidget* parent) {
1574 DropZoneWidget& widget = scope.adopt(_backend->createDropZone(parent));
1575 applyCommon(scope, widget, spec.common);
1576 widget.setAccept(spec.accept);
1577 widget.setMultiple(spec.multiple);
1578 widget.setOnDrop(event(spec.onDrop));
1579 static_cast<void>(mount(scope, spec.child, &widget));
1580 return widget;
1581 }
1582
1583 // One prop of a loaded component, bound to the setter its kind crosses by.
1584 void bindCustomProp(reactive::Scope& scope, CustomWidget& widget, CustomProp const& prop) {
1585 std::string const& name = prop.name;
1586 if (auto const* const text = std::get_if<Prop<std::string>>(&prop.value)) {
1587 bind(scope, *text, [&widget, name](std::string const& value) { widget.setTextProp(name, value); });
1588 } else if (auto const* const flag = std::get_if<Prop<bool>>(&prop.value)) {
1589 bind(scope, *flag, [&widget, name](bool value) { widget.setBoolProp(name, value); });
1590 } else if (auto const* const number = std::get_if<Prop<double>>(&prop.value)) {
1591 bind(scope, *number, [&widget, name](double value) { widget.setNumberProp(name, value); });
1592 }
1593 }
1594
1595 // The component the backend loads in the custom node's place, or the fallback when it loads none.
1596 // An ActionBar is a horizontal stack in the footer of the overlay whose content holds it, not in its own parent.
1597 Widget& mountKind(reactive::Scope& scope, ActionBar const& spec, ContainerWidget* parent) {
1598 static_cast<void>(parent);
1599 ContainerWidget* const footer = overlayFooter();
1600 if (footer == nullptr) {
1601 throw std::logic_error{"morph::ui: an action bar mounts only in a dialog's or drawer's content"};
1602 }
1603 StackWidget& widget = scope.adopt(_backend->createStack(footer, Axis::Horizontal));
1604 applyCommon(scope, widget, spec.common);
1605 widget.setGap(spacingStep(2));
1606 for (Node const& child : spec.children) {
1607 static_cast<void>(mount(scope, child, &widget));
1608 }
1609 return widget;
1610 }
1611
1612 Widget& mountKind(reactive::Scope& scope, Custom const& spec, ContainerWidget* parent) {
1613 CustomRequest request{.name = spec.name, .props = {}, .minVersion = spec.minVersion};
1614 request.props.reserve(spec.props.size());
1615 for (CustomProp const& prop : spec.props) {
1616 request.props.push_back(prop.name);
1617 }
1618 if (std::unique_ptr<CustomWidget> loaded = _backend->createCustom(parent, request)) {
1619 CustomWidget& widget = scope.adopt(std::move(loaded));
1620 for (CustomProp const& prop : spec.props) {
1621 bindCustomProp(scope, widget, prop);
1622 }
1623 widget.setOnEvent(spec.onEvent);
1624 return widget;
1625 }
1626 Widget* const fallback = mount(scope, spec.fallback, parent);
1627 if (fallback == nullptr) {
1628 throw std::invalid_argument{"morph::ui::Mounted: the custom node " + spec.name + " has no fallback"};
1629 }
1630 return *fallback;
1631 }
1632
1633 Widget& mountKind(reactive::Scope& scope, DateTimeInput const& spec, ContainerWidget* parent) {
1634 DateTimeInputWidget& widget =
1635 scope.adopt(_backend->createDateTimeInput(parent, spec.mode, spec.offsetMinutes));
1636 applyCommon(scope, widget, spec.common);
1637 applyField(scope, widget, spec.field);
1638 widget.setOnChange(inputEvent(
1639 spec.onChange,
1640 controlled(scope, spec.value,
1641 [&widget](std::optional<morph::time::Timestamp> const& value) { widget.setValue(value); })));
1642 applyCaption(scope, widget, spec.label, spec.showLabel);
1643 return widget;
1644 }
1645
1646 Widget& mountKind(reactive::Scope& scope, Slider const& spec, ContainerWidget* parent) {
1647 SliderWidget& widget = scope.adopt(_backend->createSlider(parent));
1648 applyCommon(scope, widget, spec.common);
1649 applyField(scope, widget, spec.field);
1650 widget.setRange(spec.minimum, spec.maximum, spec.step);
1651 widget.setOnChange(inputEvent(
1652 spec.onChange, controlled(scope, spec.value, [&widget](std::int64_t value) { widget.setValue(value); })));
1653 widget.setOnCommit(event(spec.onCommit));
1654 return widget;
1655 }
1656
1657 Widget& mountKind(reactive::Scope& scope, FilePicker const& spec, ContainerWidget* parent) {
1658 FilePickerWidget& widget = scope.adopt(_backend->createFilePicker(parent, spec.mode));
1659 applyCommon(scope, widget, spec.common);
1660 applyField(scope, widget, spec.field);
1661 widget.setOnPicked(inputEvent(spec.onPicked, controlled(scope, spec.path, [&widget](std::string const& path) {
1662 widget.setPath(path);
1663 })));
1664 return widget;
1665 }
1666
1667 reactive::Runtime* _rt;
1668 IViewBackend* _backend;
1669 // The dialog or drawer whose content is being mounted, while it is: an ActionBar mounts into its footer. A binding
1670 // that mounts later (a Switch case, a Tabs page, a row) mounts under the overlay it was made under. The footer is
1671 // asked for only when an ActionBar mounts, so an overlay without actions never makes one.
1672 ContainerWidget* _overlay = nullptr;
1673 // The first autofocus widget of the mount pass in progress, and how deeply passes are nested.
1674 Widget* _autofocus = nullptr;
1675 std::size_t _passDepth = 0;
1676 // The mounted widgets with a focus name. A multimap keeps equal names in insertion order, so the first mounted
1677 // is found first.
1678 FocusMap _focusable;
1679};
1680
1681} // namespace detail
1682
1683/// @brief A view tree mounted on a backend: its widgets, built once, and the bindings that keep them current.
1684///
1685/// Destroying it destroys every binding before the widget it drives and every child before its parent. The
1686/// runtime, the backend and every signal a binding reads must outlive it; the owner of the runtime constructs and
1687/// destroys it. Non-copyable and non-movable: bindings point into it.
1688///
1689/// Every widget callback, a drop's `accepts` predicate included, runs as a `reactive::Runtime::widgetEvent` and
1690/// untracked, so a binding whose setter makes the backend call a handler does not subscribe to what the handler
1691/// reads. An exception a callback throws is reported (`detail::site::kCallbackThrew`) and goes no further: the
1692/// backend's call returns normally, a predicate that threw refuses the drop, and what the callback wrote before
1693/// throwing still flushes.
1694///
1695/// An input whose value is a slot is controlled: a text input's text, a checkbox's state, a select's key, a slider's,
1696/// date-time input's or file picker's value, a collapsible panel's state, a Tabs index, a Dialog's `open` and a
1697/// Table's selection. After a user's request, and after the flush it caused, a posted turn shows the slot's value if
1698/// the widget shows anything else: a refused request snaps back, an accepted one costs no setter call, and a
1699/// transformed one shows the request for that one turn. That holds with no handler too, which makes a slot without one
1700/// read-only. A constant is not a slot: the widget keeps what the user did to it.
1701///
1702/// A mount that throws goes no further than the content it was building, and leaves none of it behind. A node mounted
1703/// directly, a constant Switch selector, Tabs index or Dialog `open` included, throws out of the constructor. Content
1704/// a binding mounts, which a bound selector, index or `open` does in an Effect even during the constructor, is
1705/// reported (`reactive::detail::site::kEffectThrew`) instead: a Switch shows nothing for that key, having torn the old
1706/// case down first; a Tabs keeps the previous page shown and selected; a Dialog stays closed. A Tabs then calls its
1707/// `onSelect` with the page still shown, and a Dialog its `onDismiss`, each as a widget callback and as the last thing
1708/// the mount does, so the application's state follows the widget and its next pick of the failed tab, or opening of
1709/// the dialog, mounts again. A Switch selector is equality-gated, so its next change of key mounts again. A Tabs
1710/// index and a Dialog `open` are not: while one still names content that failed — no `onSelect` or `onDismiss` moved
1711/// it — every run of its binding, a change to any signal the binding reads included, tries that mount again, once
1712/// per run, reporting each failure and calling `onSelect` or `onDismiss` again.
1713///
1714/// A ForEach or a Table mounts its rows in an Effect too, one row at a time: a row that fails is reported, leaves
1715/// nothing behind and is left out, the other rows are mounted and ordered all the same, and the next change of the
1716/// rows mounts it again. A key repeated in one snapshot is reported (`detail::site::kDuplicateKey`), and only its
1717/// first row is shown.
1718///
1719/// Bindings run in owner order. The tree's own bindings are at the depth the mount was given; the content of a Switch
1720/// case, a Tabs page or a Dialog, and every ForEach or Table row, is in a scope one level deeper than the binding that
1721/// mounted it. So an Effect that removes content runs before the content's own bindings in the same flush, and a
1722/// binding never evaluates against state its owner is about to take away. A mount placed under an outer scope (a
1723/// screen's, a row's) takes that scope's depth plus one, so the outer scope's Effects run first too.
1724///
1725/// A mount must run to completion: nothing it calls, a backend factory or setter or a binding's evaluation, may
1726/// destroy this object, its runtime or its backend. Widget callbacks are events, not part of a mount, and may.
1727class Mounted {
1728public:
1729 /// @param runtime The runtime the bindings are made in. Borrowed.
1730 /// @param backend Makes the widgets. Borrowed.
1731 /// @param root The tree; kept alive as long as the mount.
1732 /// @param parent The container the root widget is appended to, or null for a backend root.
1733 /// @param depth The scope depth of the tree's own bindings (`reactive::Scope::depth`): zero for a mount no other
1734 /// scope owns, one more than the owner's for a mount placed under a scope.
1735 /// @throws std::invalid_argument when @p root is null.
1736 Mounted(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, IViewBackend& backend MORPH_LIFETIMEBOUND, Node root,
1737 ContainerWidget* parent = nullptr, std::size_t depth = 0)
1738 : _root{nonNull(std::move(root))},
1739 _mounter{runtime, backend},
1740 _scope{runtime, depth},
1741 // Untracked, so mounting from inside an Effect does not subscribe that Effect to anything read here.
1742 _widget{runtime.untracked([this, parent] { return _mounter.mountRoot(_scope, _root, parent); })} {}
1743
1744 ~Mounted() = default;
1745 Mounted(Mounted const&) = delete;
1746 Mounted& operator=(Mounted const&) = delete;
1747 Mounted(Mounted&&) = delete;
1748 Mounted& operator=(Mounted&&) = delete;
1749
1750 /// @brief The root node's widget.
1751 /// @return The widget; valid as long as this object.
1752 [[nodiscard]] Widget& root() const noexcept { return *_widget; }
1753
1754 /// @brief Moves keyboard focus to the mounted widget whose `Common::focusId` is @p focusId, at any depth,
1755 /// including the content of a Switch case, a Tabs page, a Dialog and every row mounted now.
1756 ///
1757 /// Of several such widgets, the one mounted first is taken; one whose content has since been unmounted is not
1758 /// found.
1759 /// @param focusId The name.
1760 /// @return Whether a mounted widget has that name.
1761 bool focus(std::string_view focusId) { return _mounter.focus(focusId); }
1762
1763private:
1764 [[nodiscard]] static Node nonNull(Node root) {
1765 if (!root) {
1766 throw std::invalid_argument{"morph::ui::Mounted: the root node is null"};
1767 }
1768 return root;
1769 }
1770
1771 Node _root;
1772 detail::Mounter _mounter;
1773 reactive::Scope _scope; // after _mounter: destroyed first, while the mounter its bindings call is alive
1774 Widget* _widget;
1775};
1776
1777} // namespace morph::ui
Node drawer(Element const &element, Nodes const &children, DialogOptions const &options={})
A drawer node, a scope of its own.
Definition author.hpp:3629
Handle< T > row()
The innermost row, {"row": ""}.
Definition author.hpp:1145
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
Node dialog(Element const &element, Nodes const &children, DialogOptions const &options={})
A dialog node, a scope of its own; open and close name it by its element.
Definition author.hpp:3611
Command open(Element const &dialog)
open: opens a dialog or drawer.
Definition author.hpp:2779
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
Node table(AnyHandle const &rows, TableOptions const &options)
A table over a list, with the columns in its options.
Definition author.hpp:3806
void present(NodeWriter &node, FormPresentation const &presentation)
Writes presentation's keys on node.
Definition author.hpp:4221
The owner of a signal graph: every node takes one, and every operation runs on its owner.
Definition runtime.hpp:70
Owns effects, computeds and adopted objects; destroys them in reverse creation order.
Definition scope.hpp:44
A widget with children. A factory given this container appends the new widget as its last child.
Definition backend.hpp:103
virtual void moveChild(Widget &child, std::size_t index)=0
Moves child to position index: it is removed, then inserted before the child now at index.
Makes widgets. Each factory appends the new widget to parent, or makes a root when it is null,...
Definition backend.hpp:810
Widget & root() const noexcept
The root node's widget.
Definition mount.hpp:1752
bool focus(std::string_view focusId)
Moves keyboard focus to the mounted widget whose Common::focusId is focusId, at any depth,...
Definition mount.hpp:1761
Mounted(reactive::Runtime &runtime, IViewBackend &backend, Node root, ContainerWidget *parent=nullptr, std::size_t depth=0)
Definition mount.hpp:1736
Children stacked along one axis, fixed at creation.
Definition backend.hpp:310
Keyed rows under column headers, with selection and activation by key.
Definition backend.hpp:462
A retained widget. A new widget is visible, enabled, content-sized, not draggable and not a drop targ...
Definition backend.hpp:34
Builds widgets for nodes and wires their props and callbacks.
Definition mount.hpp:213
Mounter(reactive::Runtime &runtime, IViewBackend &backend) noexcept
Definition mount.hpp:217
Widget * mountRoot(reactive::Scope &scope, Node const &node, ContainerWidget *parent)
Mounts node as mount does, as a mount of its own: the first autofocus widget it mounts,...
Definition mount.hpp:248
bool focus(std::string_view focusId)
Moves keyboard focus to the mounted widget whose focusId is focusId.
Definition mount.hpp:258
Widget * mount(reactive::Scope &scope, Node const &node, ContainerWidget *parent)
Mounts node under parent; scope owns its widgets and bindings.
Definition mount.hpp:228
One mounted row of a ForEach or a Table: the row's signal, and the view built from it once.
Definition view.hpp:720
void refuse(std::string const &path, std::string reason)
Refuses the document.
Definition document.hpp:599
void reorderChildren(ContainerWidget &container, std::vector< Widget * > &current, std::vector< Widget * > const &target)
Brings a container's children from their current order into the target order with moveChild,...
Definition mount.hpp:159
std::function< void(reactive::Scope &, Node const &, Widget &, Key const &)> RowHook
Called for each newly mounted row with its widget and key; a Table tells its widget the row's key.
Definition mount.hpp:51
std::vector< bool > stableTargets(std::vector< std::size_t > const &sequence)
Which entries of a permutation lie on one longest increasing run.
Definition mount.hpp:129
RowWidgetState
Whether a row's widget is realized, and if not, why – so the windowing pass of reconcile never confus...
Definition mount.hpp:56
@ Dewindowed
The row's view is not null, but its widget is not realized: out of the visible range.
Definition mount.hpp:58
@ NullView
The row's view is a null node; it never had a widget and never will while that stays true.
Definition mount.hpp:59
@ Realized
widgetScope/widget are set.
Definition mount.hpp:57
The site names a misuse in this layer is reported under, through the runtime's owner probe.
Definition mount.hpp:43
constexpr char const * kDuplicateKey
A ForEach or Table snapshot repeated a key; the later row is refused and the first one kept.
Definition mount.hpp:47
constexpr char const * kCallbackThrew
An exception escaped a widget callback; the mount caught it, so it never reaches the backend.
Definition mount.hpp:45
std::expected< PageRows, TableError > apply(DataSource const &source, TableQuery const &query, ApplyOptions const &options={})
Runs query over source in the calling thread: the reference implementation of server mode.
Definition query.hpp:289
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
One widget per row of a keyed collection, updated in place when a row with the same key changes.
Definition view.hpp:864
std::string chord
The chord: modifiers (Ctrl, Shift, Alt, Meta) joined to a key name, such as "Ctrl+S".
Definition view.hpp:341
@ Fixed
Exactly amount units.
Definition view.hpp:274
@ Stretch
A share of the leftover space, weighted by amount.
Definition view.hpp:275
Keyed rows of cells under column headers. Each row's view is a Row whose children are the cells,...
Definition view.hpp:888
One mounted row of a ForEach or Table.
Definition mount.hpp:70
RowWidgetState widgetState
Which of the three states above widget is in.
Definition mount.hpp:82
std::unique_ptr< reactive::Scope > widgetScope
Owns the row's widget and its bindings; null while the row is dewindowed or has no view.
Definition mount.hpp:78
std::size_t viewPosition
This row's position in view order, as of the most recent reconcile pass – not its position in KeyedRo...
Definition mount.hpp:87
std::unique_ptr< reactive::Scope > stateScope
Owns the row's slot; made once, for as long as the key is in the data.
Definition mount.hpp:74
RowSlot * slot
The row's slot, owned by stateScope.
Definition mount.hpp:76
Widget * widget
The row's widget, owned by widgetScope; null exactly when widgetScope is null.
Definition mount.hpp:80
Key key
The row's key.
Definition mount.hpp:72
The mounted rows of one ForEach or Table, in their container's order.
Definition mount.hpp:105
std::vector< KeyedRow > list
The rows; those with a widget are in the container's order.
Definition mount.hpp:120
~KeyedRows()
Unmounts the rows last first, as a container's children are; a vector's own destruction order differs...
Definition mount.hpp:109
Where one Tabs keeps its pages: a slot per tab, made the first time the tab is selected and kept,...
Definition mount.hpp:198
std::optional< std::size_t > shown
The page shown now, if any; may be past the last page, and then none is shown.
Definition mount.hpp:202
std::vector< SlotWidget * > pageSlots
Each page's slot; null until the tab is first selected.
Definition mount.hpp:200
std::optional< std::size_t > highlighted
The tab the bar highlights, as far as the mount knows: the last one it set, or the user's pick since.
Definition mount.hpp:204
std::function< void(std::size_t)> onSelect
The Tabs' onSelect, run as a widget callback; empty when the Tabs has none.
Definition mount.hpp:206
What the backend's own scroll position asks a virtualized table to realize next.
Definition mount.hpp:91
std::size_t count
How many rows from first the backend wants realized; 0 (the default) asks for nothing,...
Definition mount.hpp:97
bool operator==(VisibleRange const &) const =default
Memberwise equality, so an unchanged range notifies nobody.
std::size_t first
The view index of the first row the backend wants realized.
Definition mount.hpp:93
The backend contract: the retained widgets a mount drives, and the factory that makes them.
The toolkit-agnostic view tree: props, node kinds and their builders.
constexpr int spacingStep(int value) noexcept
A gap or padding as the spacing step a backend receives.
Definition view.hpp:561
std::shared_ptr< NodeData const > Node
A view-tree node: immutable, shared data.
Definition view.hpp:403
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
SlotBinding< std::decay_t< F > > slot(SlotId slotId, F &&read)
A slot with an id.
Definition view.hpp:63