morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
backend_conformance.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <array>
6#include <chrono>
7#include <cstddef>
8#include <cstdint>
9#include <functional>
10#include <memory>
11#include <optional>
12#include <span>
13#include <string>
14#include <string_view>
15#include <utility>
16#include <variant>
17#include <vector>
18
22#include "../backend.hpp"
23#include "../mount.hpp"
24#include "../view.hpp"
25
26/// @file
27/// @brief The scripted cases every `IViewBackend` must pass, and the probe a backend author implements to run them.
28///
29/// Specified in `docs/spec/ui/backend_contract.md`, "The conformance suite".
30
31namespace morph::ui::testing {
32
33/// @brief Where a widget is drawn, in its backend's own units (a cell on the terminal; `Theme.unitWidth` and
34/// `Theme.unitHeight` on Qt Quick), from the top left of the window or screen.
36 /// @brief The left edge.
37 double x = 0;
38 /// @brief The top edge.
39 double y = 0;
40 /// @brief The width.
41 double width = 0;
42 /// @brief The height.
43 double height = 0;
44};
45
46/// @brief How the conformance cases read and drive one backend under test.
47///
48/// The driving operations act as a user would, through the backend's own input path where it has one; each may run
49/// pending work before and after it. Reading operations report what the widget shows now.
50///
51/// Whether a handler can run after its widget was destroyed is observable only for a backend whose input can be
52/// delivered later than the driving call, such as a queued connection; a probe whose `click` delivers before it
53/// returns passes that case without exercising it.
54class ConformanceProbe {
55public:
56 ConformanceProbe() = default;
57 virtual ~ConformanceProbe() = default;
58 ConformanceProbe(ConformanceProbe const&) = delete;
59 ConformanceProbe& operator=(ConformanceProbe const&) = delete;
60 ConformanceProbe(ConformanceProbe&&) = delete;
61 ConformanceProbe& operator=(ConformanceProbe&&) = delete;
62
63 /// @brief The backend under test.
64 /// @return The backend every case mounts on.
65 virtual IViewBackend& backend() = 0;
66
67 /// @brief The runtime the cases' signals and mounts belong to.
68 /// @return The runtime, whose owner `settle` drives.
69 virtual reactive::Runtime& runtime() = 0;
70
71 /// @brief Runs posted flushes and pending native events until nothing is left.
72 virtual void settle() = 0;
73
74 /// @brief The text a widget shows: a Text's or TextInput's text, a Button's or Checkbox's label, and the label of
75 /// the option a Select marks.
76 /// @param widget The widget.
77 /// @return The text, UTF-8; empty for a Select that marks no option.
78 [[nodiscard]] virtual std::string textOf(Widget const& widget) = 0;
79
80 /// @brief The widget's own visibility flag, as its last `setVisible` left it.
81 ///
82 /// Only the widget's own flag counts: a hidden ancestor, a collapsed panel or a closed dialog around it does
83 /// not make this false. That is what `RecordingBackend` records, and it lets a case tell which Tabs page slot
84 /// the mount hid.
85 /// @param widget The widget.
86 /// @return True for a new widget; false once `setVisible(false)` reached it and until `setVisible(true)` does.
87 [[nodiscard]] virtual bool visibleOf(Widget const& widget) = 0;
88
89 /// @brief Whether a widget accepts input, by its own flag: a disabled ancestor does not make this false, which
90 /// is what `RecordingBackend` records.
91 /// @param widget The widget.
92 /// @return True for a new widget; false once `setEnabled(false)` reached it and until `setEnabled(true)` does.
93 [[nodiscard]] virtual bool enabledOf(Widget const& widget) = 0;
94
95 /// @brief How many children a container has.
96 /// @param container The container.
97 /// @return The count.
98 [[nodiscard]] virtual std::size_t childCount(ContainerWidget const& container) = 0;
99
100 /// @brief A container's child.
101 /// @param container The container.
102 /// @param index The position.
103 /// @return The child, or null past the end. A case drives it as it drives a root.
104 [[nodiscard]] virtual Widget* childAt(ContainerWidget const& container, std::size_t index) = 0;
105
106 /// @brief Activates a button as a user would.
107 /// @param widget The button.
108 virtual void click(Widget& widget) = 0;
109
110 /// @brief Replaces a text field's text as a user typing would: whatever the field held is cleared first (select
111 /// all, or delete), so it ends up holding exactly @p text. `onChange` may see intermediate texts.
112 /// @param widget The field.
113 /// @param text The text to end up with.
114 virtual void type(Widget& widget, std::string_view text) = 0;
115
116 /// @brief Drags one widget onto another as a user would.
117 /// @param source The dragged widget.
118 /// @param target The widget it is dropped on.
119 virtual void drag(Widget& source, Widget& target) = 0;
120
121 /// @brief Dismisses a dialog or drawer as a user would (Esc on the TUI), or a dismissible banner.
122 /// @param dialog The dialog, drawer or banner.
123 virtual void dismiss(Widget& dialog) = 0;
124
125 /// @brief Selects rows of a table as a user would, so that the user's selection is exactly these rows.
126 /// @param table The table.
127 /// @param rows Positions among the table's current rows, in the order the user picks them: at least one, and
128 /// one only for a `Single` table.
129 virtual void selectRows(Widget& table, std::vector<std::size_t> const& rows) = 0;
130
131 /// @brief The rows a table shows as selected.
132 /// @param table The table.
133 /// @return Their positions among the table's current rows, ascending.
134 [[nodiscard]] virtual std::vector<std::size_t> selectedRows(Widget const& table) = 0;
135
136 /// @brief Moves a text field's cursor as a user would (the arrow keys, a click), without changing the text.
137 /// @param field The text field.
138 /// @param position A byte offset into the text it shows.
139 virtual void moveCursor(Widget& field, std::size_t position) = 0;
140
141 /// @brief Where a text field's cursor is.
142 /// @param field The text field.
143 /// @return A byte offset into the text it shows.
144 [[nodiscard]] virtual std::size_t cursorOf(Widget const& field) = 0;
145
146 /// @brief Presses a keyboard chord as a user would with focus on a widget.
147 /// @param focused The widget focus is on; the probe moves focus there first if it must.
148 /// @param chord The chord, as a document declares it, such as `"Ctrl+S"`.
149 virtual void press(Widget& focused, std::string_view chord) = 0;
150
151 /// @brief Whether a widget has keyboard focus.
152 /// @param widget The widget.
153 /// @return True when it is the widget that has focus.
154 [[nodiscard]] virtual bool hasFocus(Widget const& widget) = 0;
155
156 /// @brief Chooses a menu entry as a user would, opening each submenu on the path first.
157 /// @param menu The menu.
158 /// @param path The entry's index at each level, from the top.
159 virtual void chooseMenuEntry(Widget& menu, std::vector<std::size_t> const& path) = 0;
160
161 /// @brief Opens or closes a collapsible section with its own control, as a user would.
162 /// @param section The section.
163 /// @param open The state asked for.
164 virtual void expand(Widget& section, bool open) = 0;
165
166 /// @brief Drops files from the operating system onto a drop zone, as a user would.
167 /// @param zone The drop zone.
168 /// @param paths The files' paths, in the order dropped; not empty.
169 virtual void dropFiles(Widget& zone, std::vector<std::string> const& paths) = 0;
170
171 /// @brief Where a widget is drawn, for the layout cases (`layoutConformanceCases`). A backend that lays nothing
172 /// out, such as `RecordingBackend`, answers none and runs only `conformanceCases`; a layout case run on
173 /// it fails.
174 /// @param widget The widget.
175 /// @return Its bounds, in the backend's own units; `nullopt` when the probe reads no geometry.
176 [[nodiscard]] virtual std::optional<WidgetBounds> boundsOf(Widget const& widget) {
177 static_cast<void>(widget);
178 return std::nullopt;
179 }
180};
181
182/// @brief One scripted case.
184 /// @brief What the case checks; unique among the cases.
185 std::string_view name;
186 /// @brief Runs the case on a fresh probe.
187 std::function<std::optional<std::string>(ConformanceProbe&)> run;
188};
189
190namespace detail {
191
192/// @brief Collects a case's checks and keeps the first failure, so a case reads top to bottom.
193class Checks {
194public:
195 /// @brief Checks that a text is what it should be.
196 /// @param what What is checked, for the message.
197 /// @param expected The text it should be.
198 /// @param actual The text it is.
199 void text(std::string_view what, std::string_view expected, std::string_view actual) {
200 if (expected != actual) {
201 fail(std::string{what} + ": expected '" + std::string{expected} + "', got '" + std::string{actual} + "'");
202 }
203 }
204
205 /// @brief Checks a condition.
206 /// @param holds The condition.
207 /// @param what The failure message when it does not hold.
208 void that(bool holds, std::string_view what) {
209 if (!holds) {
210 fail(std::string{what});
211 }
212 }
213
214 /// @brief The case's verdict.
215 /// @return `nullopt` when every check held, else the first failure.
216 [[nodiscard]] std::optional<std::string> result() const { return _failure; }
217
218private:
219 void fail(std::string message) {
220 if (!_failure.has_value()) {
221 _failure = std::move(message);
222 }
223 }
224
225 std::optional<std::string> _failure;
226};
227
228/// @brief A row of the ForEach and Table cases.
229struct Entry {
230 /// @brief The row's key.
231 std::int64_t id = 0;
232 /// @brief The text the row shows.
233 std::string label;
234
235 /// @brief Memberwise equality.
236 /// @return Whether both fields match.
237 bool operator==(Entry const&) const = default;
238};
239
240/// @brief A ForEach of Texts, one per entry, keyed by id.
241/// @param rows The entries; must outlive the node's mounts.
242/// @param builds Counts the row views built, when not null; must outlive the node's mounts.
243/// @return The node.
244[[nodiscard]] inline Node entryList(reactive::Signal<std::vector<Entry>> const& rows, int* builds = nullptr) {
245 return ui::forEach<Entry>(
246 rows, [](Entry const& entry) { return Key{entry.id}; },
247 [builds](reactive::Signal<Entry> const& entry) {
248 if (builds != nullptr) {
249 ++*builds;
250 }
251 return ui::text({.text = [&entry] { return entry.get().label; }});
252 });
253}
254
255/// @brief A one-column Table of entries, keyed by id, whose cell is a Text of the label.
256/// @param rows The entries; must outlive the node's mounts.
257/// @param options Selection, activation and `Common`.
258/// @return The node.
259[[nodiscard]] inline Node entryTable(reactive::Signal<std::vector<Entry>> const& rows, TableOptions options) {
260 return ui::table<Entry>(
261 {{.label = "Name"}}, [&rows] { return rows.get(); }, [](Entry const& entry) { return Key{entry.id}; },
262 [](reactive::Signal<Entry> const& entry) {
263 return std::vector<Node>{ui::text({.text = [&entry] { return entry.get().label; }})};
264 },
265 std::move(options));
266}
267
268/// @brief A widget as a container.
269/// @param widget The widget.
270/// @return It, or null when it is not a `ContainerWidget`.
271[[nodiscard]] inline ContainerWidget const* asContainer(Widget const& widget) {
272 return dynamic_cast<ContainerWidget const*>(&widget);
273}
274
275/// @brief The root widget of a mounted container node, as the container it is.
276///
277/// The root of a mounted Switch, Tabs, Column, ForEach or Dialog is the widget the backend's `createSlot`,
278/// `createTabs`, `createStack` or `createDialog` returned, and each of those is a `ContainerWidget` by its declared
279/// type, so the downcast cannot fail for any backend that compiles.
280/// @param view The mount, of a container node.
281/// @return The root widget as a container.
282[[nodiscard]] inline ContainerWidget const& rootContainer(Mounted const& view) {
283 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-static-cast-downcast): a container by the factory's type, see above
284 return static_cast<ContainerWidget const&>(view.root());
285}
286
287/// @brief A child of a widget that should be a container.
288/// @param probe The backend under test.
289/// @param parent The widget; null, or a widget that is no container, has no children.
290/// @param index The position.
291/// @return The child, or null.
292[[nodiscard]] inline Widget* childOf(ConformanceProbe& probe, Widget const* parent, std::size_t index) {
293 ContainerWidget const* const container = parent == nullptr ? nullptr : asContainer(*parent);
294 return container == nullptr ? nullptr : probe.childAt(*container, index);
295}
296
297/// @brief The texts of a container's children, joined by commas.
298/// @param probe The backend under test.
299/// @param container The container.
300/// @return For example `a,b,c`; a missing child reads `<null>`.
301[[nodiscard]] inline std::string childTexts(ConformanceProbe& probe, ContainerWidget const& container) {
302 std::string texts;
303 for (std::size_t i = 0; i < probe.childCount(container); ++i) {
304 if (i != 0) {
305 texts += ',';
306 }
307 Widget const* const child = probe.childAt(container, i);
308 texts += child == nullptr ? std::string{"<null>"} : probe.textOf(*child);
309 }
310 return texts;
311}
312
313/// @brief The texts of the rows an `entryTable` shows as selected, joined by commas.
314/// @param probe The backend under test.
315/// @param table The table.
316/// @return For example `a,c`; a row without a first cell reads `<null>`.
317[[nodiscard]] inline std::string selectedTexts(ConformanceProbe& probe, Widget const& table) {
318 std::string texts;
319 for (std::size_t const row : probe.selectedRows(table)) {
320 if (!texts.empty()) {
321 texts += ',';
322 }
323 Widget const* const cell = childOf(probe, childOf(probe, &table, row), 0);
324 texts += cell == nullptr ? std::string{"<null>"} : probe.textOf(*cell);
325 }
326 return texts;
327}
328
329/// @brief Keys as text, for a failure message.
330/// @param keys The keys.
331/// @return For example `1,"b"`.
332[[nodiscard]] inline std::string keyTexts(std::vector<Key> const& keys) {
333 std::string texts;
334 for (Key const& key : keys) {
335 if (!texts.empty()) {
336 texts += ',';
337 }
338 if (auto const* const number = std::get_if<std::int64_t>(&key); number != nullptr) {
339 texts += std::to_string(*number);
340 } else {
341 texts += '"' + std::get<std::string>(key) + '"';
342 }
343 }
344 return texts;
345}
346
347/// @brief Case: a constant Text shows its text.
348/// @param probe The backend under test.
349/// @return `nullopt`, or what went wrong.
350[[nodiscard]] inline std::optional<std::string> textShowsItsText(ConformanceProbe& probe) {
351 Mounted const view{probe.runtime(), probe.backend(), ui::text({.text = "hello"})};
352 probe.settle();
353 Checks checks;
354 checks.text("a constant Text", "hello", probe.textOf(view.root()));
355 return checks.result();
356}
357
358/// @brief Case: a bound Text follows its signal, once per batch, and not for an equal write.
359/// @param probe The backend under test.
360/// @return `nullopt`, or what went wrong.
361[[nodiscard]] inline std::optional<std::string> boundTextUpdatesOncePerChange(ConformanceProbe& probe) {
362 reactive::Runtime& runtime = probe.runtime();
363 reactive::Signal<std::string> word{runtime, "one"};
364 int evaluations = 0;
365 Mounted const view{runtime, probe.backend(), ui::text({.text = [&word, &evaluations] {
366 ++evaluations;
367 return word.get();
368 }})};
369 probe.settle();
370 Checks checks;
371 checks.text("a bound Text", "one", probe.textOf(view.root()));
372 runtime.batch([&word] {
373 word.set("two");
374 word.set("three");
375 });
376 probe.settle();
377 checks.text("a bound Text after a batch of two writes", "three", probe.textOf(view.root()));
378 word.set("three");
379 probe.settle();
380 checks.that(evaluations == 2, "the binding ran " + std::to_string(evaluations) +
381 " times; expected once at mount and once for the batch");
382 return checks.result();
383}
384
385/// @brief Case: `visible` and `enabled` follow their bindings.
386/// @param probe The backend under test.
387/// @return `nullopt`, or what went wrong.
388[[nodiscard]] inline std::optional<std::string> visibleAndEnabledFollowBindings(ConformanceProbe& probe) {
389 reactive::Signal<bool> shown{probe.runtime(), true};
390 Mounted const view{probe.runtime(), probe.backend(),
391 ui::button({.label = "Go",
392 .common = {.visible = [&shown] { return shown.get(); },
393 .enabled = [&shown] { return shown.get(); }}})};
394 probe.settle();
395 Checks checks;
396 checks.that(probe.visibleOf(view.root()), "a Button bound visible=true is hidden");
397 checks.that(probe.enabledOf(view.root()), "a Button bound enabled=true is disabled");
398 shown.set(false);
399 probe.settle();
400 checks.that(!probe.visibleOf(view.root()), "a Button bound visible=false is shown");
401 checks.that(!probe.enabledOf(view.root()), "a Button bound enabled=false is enabled");
402 return checks.result();
403}
404
405/// @brief Case: a click runs the button's action once, and what it wrote reaches the screen.
406/// @param probe The backend under test.
407/// @return `nullopt`, or what went wrong.
408[[nodiscard]] inline std::optional<std::string> clickReachesAction(ConformanceProbe& probe) {
409 reactive::Signal<int> clicks{probe.runtime(), 0};
410 Mounted const view{probe.runtime(), probe.backend(),
411 ui::button({.label = [&clicks] { return "clicked " + std::to_string(clicks.get()); },
412 .onClick = [&clicks] { clicks.set(clicks.peek() + 1); }})};
413 probe.settle();
414 probe.click(view.root());
415 probe.settle();
416 Checks checks;
417 checks.that(clicks.peek() == 1, "one click ran the action " + std::to_string(clicks.peek()) + " times");
418 checks.text("the label bound to the click count", "clicked 1", probe.textOf(view.root()));
419 return checks.result();
420}
421
422/// @brief Case: typing reaches `onChange`; a value set by the application is shown and not reported back.
423/// @param probe The backend under test.
424/// @return `nullopt`, or what went wrong.
425[[nodiscard]] inline std::optional<std::string> typingWithoutEcho(ConformanceProbe& probe) {
426 reactive::Signal<std::string> name{probe.runtime(), ""};
427 int changes = 0;
428 Mounted const view{probe.runtime(), probe.backend(),
429 ui::textInput({.value = [&name] { return name.get(); },
430 .onChange =
431 [&name, &changes](std::string text) {
432 ++changes;
433 name.set(std::move(text));
434 }})};
435 probe.settle();
436 probe.type(view.root(), "abc");
437 probe.settle();
438 Checks checks;
439 checks.that(changes >= 1, "typing did not reach onChange");
440 checks.text("the value after typing", "abc", name.peek());
441 checks.text("the field after typing", "abc", probe.textOf(view.root()));
442 int const typed = changes;
443 name.set("xyz");
444 probe.settle();
445 checks.text("the field after the application set a value", "xyz", probe.textOf(view.root()));
446 checks.that(changes == typed, "a value set by the application was reported through onChange");
447 return checks.result();
448}
449
450/// @brief Case: a Switch shows the selected case, nothing for a key without one, and remounts on a change.
451/// @param probe The backend under test.
452/// @return `nullopt`, or what went wrong.
453[[nodiscard]] inline std::optional<std::string> switchShowsSelectedCase(ConformanceProbe& probe) {
454 reactive::Signal<std::int64_t> which{probe.runtime(), 0};
455 Mounted const view{probe.runtime(), probe.backend(),
456 ui::switchOf({.selector = [&which] { return Key{which.get()}; },
457 .cases = {{.key = Key{std::int64_t{0}}, .node = ui::text({.text = "zero"})},
458 {.key = Key{std::int64_t{1}}, .node = ui::text({.text = "one"})}}})};
459 probe.settle();
460 ContainerWidget const& slot = rootContainer(view);
461 Checks checks;
462 checks.text("the case for key 0", "zero", childTexts(probe, slot));
463 which.set(1);
464 probe.settle();
465 checks.text("the case for key 1", "one", childTexts(probe, slot));
466 which.set(7);
467 probe.settle();
468 checks.that(probe.childCount(slot) == 0, "a key with no case and no fallback still shows a child");
469 which.set(0);
470 probe.settle();
471 checks.text("the case for key 0 again", "zero", childTexts(probe, slot));
472 return checks.result();
473}
474
475/// @brief Case: Tabs mount a page on its first selection, keep it, and show only the selected one.
476/// @param probe The backend under test.
477/// @return `nullopt`, or what went wrong.
478[[nodiscard]] inline std::optional<std::string> tabsMountPagesLazily(ConformanceProbe& probe) {
479 reactive::Signal<std::size_t> selected{probe.runtime(), 0};
480 Mounted const view{probe.runtime(), probe.backend(),
481 ui::tabs({.tabs = {{.label = "A", .node = ui::text({.text = "first"})},
482 {.label = "B", .node = ui::text({.text = "second"})}},
483 .selected = [&selected] { return selected.get(); },
484 .onSelect = [&selected](std::size_t index) { selected.set(index); }})};
485 probe.settle();
486 ContainerWidget const& bar = rootContainer(view);
487 Checks checks;
488 checks.that(probe.childCount(bar) == 1, "Tabs mounted a page that was never selected");
489 selected.set(1);
490 probe.settle();
491 checks.that(probe.childCount(bar) == 2, "selecting a tab did not mount its page");
492 Widget const* const first = probe.childAt(bar, 0);
493 Widget const* const second = probe.childAt(bar, 1);
494 checks.that(first != nullptr && !probe.visibleOf(*first), "the unselected tab's page is shown");
495 checks.that(second != nullptr && probe.visibleOf(*second), "the selected tab's page is hidden");
496 selected.set(0);
497 probe.settle();
498 // Read again rather than through `first`: a backend that destroyed the page would leave that pointer dangling.
499 checks.that(probe.childCount(bar) == 2, "re-selecting a tab mounted its page again");
500 Widget const* const reselected = probe.childAt(bar, 0);
501 checks.that(reselected != nullptr && probe.visibleOf(*reselected), "the re-selected tab's page is hidden");
502 return checks.result();
503}
504
505/// @brief Case: a ForEach updates a row whose key stays in place, keeping its widget and not building its view
506/// again.
507///
508/// The build count is the deciding check: a widget address alone cannot tell a kept widget from a new one that the
509/// allocator placed where the old one was.
510/// @param probe The backend under test.
511/// @return `nullopt`, or what went wrong.
512[[nodiscard]] inline std::optional<std::string> forEachUpdatesInPlace(ConformanceProbe& probe) {
513 reactive::Signal<std::vector<Entry>> rows{probe.runtime(), {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}};
514 int builds = 0;
515 Mounted const view{probe.runtime(), probe.backend(), entryList(rows, &builds)};
516 probe.settle();
517 ContainerWidget const& list = rootContainer(view);
518 Widget const* const before = probe.childAt(list, 0);
519 rows.set({{.id = 1, .label = "a2"}, {.id = 2, .label = "b"}});
520 probe.settle();
521 Checks checks;
522 checks.that(builds == 2, "updating a kept row built its view again: " + std::to_string(builds) +
523 " row views built for two keys");
524 checks.that(probe.childAt(list, 0) == before, "updating a kept row replaced its widget");
525 checks.text("the rows after an update", "a2,b", childTexts(probe, list));
526 return checks.result();
527}
528
529/// @brief Case: a ForEach follows inserts, removals and reorders.
530/// @param probe The backend under test.
531/// @return `nullopt`, or what went wrong.
532[[nodiscard]] inline std::optional<std::string> forEachInsertsRemovesReorders(ConformanceProbe& probe) {
534 probe.runtime(), {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}, {.id = 3, .label = "c"}}};
535 Mounted const view{probe.runtime(), probe.backend(), entryList(rows)};
536 probe.settle();
537 ContainerWidget const& list = rootContainer(view);
538 Checks checks;
539 checks.text("the rows as mounted", "a,b,c", childTexts(probe, list));
540 rows.set({{.id = 3, .label = "c"}, {.id = 1, .label = "a"}});
541 probe.settle();
542 checks.text("the rows after removing b and moving c first", "c,a", childTexts(probe, list));
543 rows.set({{.id = 3, .label = "c"}, {.id = 4, .label = "d"}, {.id = 1, .label = "a"}});
544 probe.settle();
545 checks.text("the rows after inserting d in the middle", "c,d,a", childTexts(probe, list));
546 return checks.result();
547}
548
549/// @brief Case: a widget moved among its siblings keeps its native state; here, the text a user typed into a field.
550/// @param probe The backend under test.
551/// @return `nullopt`, or what went wrong.
552[[nodiscard]] inline std::optional<std::string> movedRowKeepsNativeState(ConformanceProbe& probe) {
554 probe.runtime(), {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}, {.id = 3, .label = "c"}}};
555 // The field's text is the widget's own: no binding sets it after the mount, so only the user's typing does.
556 Mounted const view{probe.runtime(), probe.backend(),
558 rows, [](Entry const& entry) { return Key{entry.id}; },
559 [](reactive::Signal<Entry> const& entry) {
560 return ui::textInput({.placeholder = [&entry] { return entry.get().label; }});
561 })};
562 probe.settle();
563 ContainerWidget const& list = rootContainer(view);
564 Widget* const field = probe.childAt(list, 0);
565 if (field == nullptr) {
566 return "a ForEach of text fields shows no first field";
567 }
568 probe.type(*field, "edited");
569 probe.settle();
570 Checks checks;
571 checks.text("the fields after typing into the first", "edited,,", childTexts(probe, list));
572 rows.set({{.id = 2, .label = "b"}, {.id = 3, .label = "c"}, {.id = 1, .label = "a"}});
573 probe.settle();
574 checks.that(probe.childAt(list, 2) == field, "moving a row replaced its widget");
575 checks.text("the fields after moving the edited one last; moveChild must keep a widget's native state", ",,edited",
576 childTexts(probe, list));
577 return checks.result();
578}
579
580/// @brief Case: a Dialog holds its content only while it is open.
581/// @param probe The backend under test.
582/// @return `nullopt`, or what went wrong.
583[[nodiscard]] inline std::optional<std::string> dialogHoldsContentWhileOpen(ConformanceProbe& probe) {
584 reactive::Signal<bool> open{probe.runtime(), false};
585 Mounted const view{
586 probe.runtime(), probe.backend(),
588 {.open = [&open] { return open.get(); }, .title = "Confirm", .child = ui::text({.text = "inside"})})};
589 probe.settle();
590 ContainerWidget const& dialog = rootContainer(view);
591 Checks checks;
592 checks.that(probe.childCount(dialog) == 0, "a closed Dialog holds content");
593 open.set(true);
594 probe.settle();
595 checks.text("an open Dialog's content", "inside", childTexts(probe, dialog));
596 open.set(false);
597 probe.settle();
598 checks.that(probe.childCount(dialog) == 0, "a Dialog closed again still holds content");
599 return checks.result();
600}
601
602/// @brief Case: a new dialog is closed, and a closed dialog takes no input, neither a dismissal nor a click on a
603/// child; an open one takes both.
604///
605/// Built through the backend directly: a mount opens or closes every dialog it makes, and mounts a dialog's content
606/// only while it is open, so neither a new dialog nor a closed one with children can be reached through it.
607/// @param probe The backend under test.
608/// @return `nullopt`, or what went wrong.
609[[nodiscard]] inline std::optional<std::string> closedDialogTakesNoInput(ConformanceProbe& probe) {
610 int clicks = 0;
611 int dismissals = 0;
612 std::unique_ptr<DialogWidget> const dialog = probe.backend().createDialog(nullptr);
613 dialog->setTitle("Confirm");
614 dialog->setOnDismiss([&dismissals] { ++dismissals; });
615 std::unique_ptr<ButtonWidget> const button = probe.backend().createButton(dialog.get());
616 button->setLabel("OK");
617 button->setOnClick([&clicks] { ++clicks; });
618 probe.settle();
619 probe.click(*button);
620 probe.dismiss(*dialog);
621 probe.settle();
622 Checks checks;
623 checks.that(clicks == 0, "a click reached a button inside a new dialog; a new dialog is closed");
624 checks.that(dismissals == 0, "a new dialog was dismissed; a new dialog is closed");
625 dialog->setOpen(true);
626 probe.settle();
627 probe.click(*button);
628 probe.settle();
629 checks.that(clicks == 1,
630 "a click on a button inside an open dialog ran its action " + std::to_string(clicks) + " times");
631 probe.dismiss(*dialog);
632 probe.settle();
633 checks.that(dismissals == 1,
634 "dismissing an open dialog called onDismiss " + std::to_string(dismissals) + " times");
635 dialog->setOpen(false);
636 probe.settle();
637 probe.click(*button);
638 probe.dismiss(*dialog);
639 probe.settle();
640 checks.that(clicks == 1, "a click reached a button inside a closed dialog");
641 checks.that(dismissals == 1, "a closed dialog was dismissed");
642 return checks.result();
643}
644
645/// @brief Case: a widget inside a hidden, disabled or collapsed container takes no input — no click, edit, drag or
646/// row selection — and takes it again once the container is shown, enabled and expanded.
647/// @param probe The backend under test.
648/// @return `nullopt`, or what went wrong.
649[[nodiscard]] inline std::optional<std::string> unreachableWidgetsTakeNoInput(ConformanceProbe& probe) {
650 reactive::Runtime& runtime = probe.runtime();
651 reactive::Signal<bool> hidden{runtime, false};
652 reactive::Signal<bool> disabled{runtime, false};
653 reactive::Signal<bool> collapsed{runtime, false};
654 reactive::Signal<std::vector<Entry>> const rows{runtime, {{.id = 1, .label = "r1"}, {.id = 2, .label = "r2"}}};
655 int clicks = 0;
656 int edits = 0;
657 int drops = 0;
658 int selections = 0;
659 // Every gate sits at least two levels above the widgets it gates, so a backend that consults only a widget's
660 // direct parent fails: an outer Column hides or disables, a Panel inside it collapses, and an inner Column
661 // holds the widgets.
662 Node const widgets = ui::column(
663 {.children = {
664 ui::button({.label = "Go", .onClick = [&clicks] { ++clicks; }}),
665 ui::textInput({.onChange = [&edits](std::string const&) { ++edits; }}),
666 ui::text({.text = "card", .common = {.dragKey = std::optional<Key>{Key{std::int64_t{7}}}}}),
667 entryTable(rows, {.selectionMode = SelectionMode::Single,
668 .onSelectionChange = [&selections](std::vector<Key> const&) { ++selections; }})}});
669 Mounted const gated{runtime, probe.backend(),
670 ui::column({.children = {ui::panel({.title = "Outer",
671 .child = widgets,
672 .collapsible = true,
673 .collapsed = [&collapsed] { return collapsed.get(); }})},
674 .common = {.visible = [&hidden] { return !hidden.get(); },
675 .enabled = [&disabled] { return !disabled.get(); }}})};
676 Mounted const target{runtime, probe.backend(),
677 ui::panel({.title = "Target", .common = {.onDrop = [&drops](Key const&) { ++drops; }}})};
678 probe.settle();
679 Widget const* const column = childOf(probe, childOf(probe, &gated.root(), 0), 0);
680 Widget* const button = childOf(probe, column, 0);
681 Widget* const field = childOf(probe, column, 1);
682 Widget* const card = childOf(probe, column, 2);
683 Widget* const table = childOf(probe, column, 3);
684 if (button == nullptr || field == nullptr || card == nullptr || table == nullptr) {
685 return "a Column in a Panel in a Column does not show the inner Column's four children";
686 }
687
688 struct Gate {
689 std::string_view name;
691 };
692 std::array<Gate, 3> const gates{{{.name = "hidden", .flag = &hidden},
693 {.name = "disabled", .flag = &disabled},
694 {.name = "collapsed", .flag = &collapsed}}};
695 std::size_t row = 1; // each round selects the row not selected yet, so a reachable table reports a change
696 std::string text = "a";
697 Checks checks;
698 for (Gate const& gate : gates) {
699 std::string const where = " inside a " + std::string{gate.name} + " container";
700 auto const act = [&] {
701 probe.click(*button);
702 probe.type(*field, text);
703 probe.drag(*card, target.root());
704 probe.selectRows(*table, {row});
705 probe.settle();
706 };
707 gate.flag->set(true);
708 probe.settle();
709 std::array<int, 4> const before{clicks, edits, drops, selections};
710 act();
711 checks.that(clicks == before.at(0), "a click reached a button" + where);
712 checks.that(edits == before.at(1), "typing reached a text field" + where);
713 checks.that(drops == before.at(2), "a widget" + where + " was dragged and dropped");
714 checks.that(selections == before.at(3), "a row selection reached a table" + where);
715 gate.flag->set(false);
716 probe.settle();
717 act();
718 std::string const again = " once its container was no longer " + std::string{gate.name};
719 checks.that(clicks == before.at(0) + 1, "a click did not reach a button" + again);
720 checks.that(edits > before.at(1), "typing did not reach a text field" + again);
721 checks.that(drops == before.at(2) + 1, "a drag did not deliver its key" + again);
722 checks.that(selections == before.at(3) + 1, "a row selection did not reach a table" + again);
723 row = 1 - row;
724 text += "b";
725 }
726 return checks.result();
727}
728
729/// @brief Case: a setter given the text a field already shows changes nothing; in particular the cursor stays.
730///
731/// Built through the backend directly, so the setter is called with the shown text on purpose: a mount sends no such
732/// call for an accepted edit, but a re-assertion of a transformed one, or another renderer, may.
733/// @param probe The backend under test.
734/// @return `nullopt`, or what went wrong.
735[[nodiscard]] inline std::optional<std::string> unchangedTextKeepsCursor(ConformanceProbe& probe) {
736 std::unique_ptr<TextInputWidget> const field = probe.backend().createTextInput(nullptr, TextInputMode::SingleLine);
737 probe.settle();
738 probe.type(*field, "abc");
739 probe.moveCursor(*field, 1);
740 probe.settle();
741 Checks checks;
742 checks.that(probe.cursorOf(*field) == 1,
743 "moving the cursor to 1 left it at " + std::to_string(probe.cursorOf(*field)));
744 field->setText("abc");
745 probe.settle();
746 checks.text("the field after setText with the text it shows", "abc", probe.textOf(*field));
747 checks.that(probe.cursorOf(*field) == 1, "setText with the text the field shows moved the cursor from 1 to " +
748 std::to_string(probe.cursorOf(*field)));
749 return checks.result();
750}
751
752/// @brief Case: an edit the document refuses snaps back, and one it transforms shows the transform.
753/// @param probe The backend under test.
754/// @return `nullopt`, or what went wrong.
755[[nodiscard]] inline std::optional<std::string> textInputIsControlled(ConformanceProbe& probe) {
756 reactive::Signal<std::string> name{probe.runtime(), "kept"};
757 bool refuse = true;
758 Mounted const view{probe.runtime(), probe.backend(),
759 ui::textInput({.value = [&name] { return name.get(); },
760 .onChange =
761 [&name, &refuse](std::string text) {
762 if (!refuse) {
763 std::ranges::transform(text, text.begin(), [](char character) {
764 return character >= 'a' && character <= 'z'
765 ? static_cast<char>(character - 'a' + 'A')
766 : character;
767 });
768 name.set(std::move(text));
769 }
770 }})};
771 probe.settle();
772 Checks checks;
773 probe.type(view.root(), "typed");
774 probe.settle();
775 checks.text("the field after an edit the document refused", "kept", probe.textOf(view.root()));
776 refuse = false;
777 // A character past `z`, and a capital, are left as they are.
778 probe.type(view.root(), "ty~Ped");
779 probe.settle();
780 checks.text("the field after an edit the document upper-cased", "TY~PED", probe.textOf(view.root()));
781 checks.text("the value after an edit the document upper-cased", "TY~PED", name.peek());
782 return checks.result();
783}
784
785/// @brief Case: a dialog the user dismisses but the document keeps open is shown again, with its content.
786/// @param probe The backend under test.
787/// @return `nullopt`, or what went wrong.
788[[nodiscard]] inline std::optional<std::string> refusedDismissalKeepsDialogOpen(ConformanceProbe& probe) {
789 reactive::Signal<bool> const open{probe.runtime(), true};
790 int dismissals = 0;
791 int clicks = 0;
792 Mounted const view{probe.runtime(), probe.backend(),
793 ui::dialog({.open = [&open] { return open.get(); },
794 .title = "Confirm",
795 .child = ui::button({.label = "OK", .onClick = [&clicks] { ++clicks; }}),
796 .onDismiss = [&dismissals] { ++dismissals; }})};
797 probe.settle();
798 ContainerWidget const& dialog = rootContainer(view);
799 probe.dismiss(view.root());
800 probe.settle();
801 Checks checks;
802 checks.that(dismissals == 1, "dismissing the dialog called onDismiss " + std::to_string(dismissals) + " times");
803 Widget* const button = probe.childAt(dialog, 0);
804 checks.that(button != nullptr, "a dialog kept open lost its content");
805 if (button != nullptr) {
806 probe.click(*button);
807 probe.settle();
808 }
809 checks.that(clicks == 1, "a click inside a dialog the document kept open ran its action " +
810 std::to_string(clicks) + " times; the dialog did not open again");
811 return checks.result();
812}
813
814/// @brief Case: no setter calls a handler. Setting a value, replacing options, items or rows, requesting a selection,
815/// opening or closing — none of it is a user's doing, so none of it reaches `onChange`, `onToggle`,
816/// `onSelect`, `onSelectionChange` and the like.
817/// @param probe The backend under test.
818/// @return `nullopt`, or what went wrong.
819[[nodiscard]] inline std::optional<std::string> settersCallNoHandler(ConformanceProbe& probe) {
820 using Options = std::vector<SelectOption>;
821 using std::chrono::milliseconds;
822 reactive::Runtime& runtime = probe.runtime();
823 reactive::Signal<int> phase{runtime, 0};
824 reactive::Signal<std::vector<Entry>> rows{runtime, {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}};
825 std::vector<std::string> fired;
826 auto const note = [&fired](std::string_view handler) {
827 return [&fired, handler](auto const&...) { fired.emplace_back(handler); };
828 };
829 auto const odd = [&phase] { return phase.get() % 2 == 1; };
830 auto const instant = [odd, &phase] {
831 using Instant = std::chrono::sys_time<milliseconds>;
832 return odd() ? std::optional<morph::time::Timestamp>{morph::time::DateTime{Instant{milliseconds{phase.get()}}}}
833 : std::nullopt;
834 };
835 Mounted const view{
836 runtime, probe.backend(),
838 {.children = {
839 ui::textInput({.value = [&phase] { return "v" + std::to_string(phase.get()); },
840 .onChange = note("TextInput onChange"),
841 .onSubmit = note("TextInput onSubmit")}),
842 ui::checkbox({.label = "c", .checked = odd, .onToggle = note("Checkbox onToggle")}),
843 // The requested key is 2, then 1; the options hold 2 in odd phases only, so setOptions
844 // re-resolves the request, which is not a user's choice either.
846 {.options =
847 [odd] {
848 // Built up by name rather than as two braced lists in a conditional, whose backing
849 // arrays GCC's dangling-pointer analysis mistakes for escaping temporaries.
850 Options options;
851 options.push_back({.key = Key{std::int64_t{1}}, .label = "One"});
852 if (odd()) {
853 options.push_back({.key = Key{std::int64_t{2}}, .label = "Two"});
854 }
855 return options;
856 },
857 .selected = [&phase] { return std::optional<Key>{Key{std::int64_t{phase.get() < 2 ? 2 : 1}}}; },
858 .onSelect = note("Select onSelect")}),
859 ui::menu({.items = {{.label = [&phase] { return "m" + std::to_string(phase.get()); },
860 .onSelect = note("Menu item onSelect")}}}),
861 ui::slider({.value = [&phase] { return std::int64_t{phase.get()}; },
862 .onChange = note("Slider onChange"),
863 .onCommit = note("Slider onCommit")}),
864 ui::dateTimeInput({.value = instant, .onChange = note("DateTimeInput onChange")}),
865 ui::filePicker({.path = [&phase] { return "/p" + std::to_string(phase.get()); },
866 .onPicked = note("FilePicker onPicked")}),
867 ui::panel({.title = "p", .collapsible = true, .collapsed = odd, .onToggle = note("Panel onToggle")}),
868 ui::tabs({.tabs = {{.label = "A", .node = ui::text({.text = "first"})},
869 {.label = "B", .node = ui::text({.text = "second"})}},
870 .selected = [odd] { return odd() ? std::size_t{1} : std::size_t{0}; },
871 .onSelect = note("Tabs onSelect")}),
872 ui::dialog({.open = odd, .title = "d", .onDismiss = note("Dialog onDismiss")}),
873 entryTable(rows, {.selectionMode = SelectionMode::Multiple,
874 .selection =
875 [&phase] {
876 return phase.get() < 2
877 ? std::vector<Key>{Key{std::int64_t{3}}}
878 : std::vector<Key>{Key{std::int64_t{1}}, Key{std::int64_t{2}}};
879 },
880 .onSelectionChange = note("Table onSelectionChange"),
881 .onActivate = note("Table onActivate")}),
882 }})};
883 probe.settle();
884 for (int next = 1; next <= 3; ++next) {
885 phase.set(next);
886 // Rows arriving with a requested key, and rows going away, re-mark the table without a user's doing.
887 rows.set(next % 2 == 1 ? std::vector<Entry>{{.id = 2, .label = "b"}, {.id = 3, .label = "c"}}
888 : std::vector<Entry>{{.id = 1, .label = "a"}, {.id = 2, .label = "b"}});
889 probe.settle();
890 }
891 std::string list;
892 for (std::string const& handler : fired) {
893 list += (list.empty() ? "" : ", ") + handler;
894 }
895 Checks checks;
896 checks.that(fired.empty(), "a setter called a handler; only a user's action may: " + list);
897 return checks.result();
898}
899
900/// @brief Case: a Select marks the requested key whenever its options contain it, whichever arrives first, and
901/// remembers the request across options that lack it.
902/// @param probe The backend under test.
903/// @return `nullopt`, or what went wrong.
904[[nodiscard]] inline std::optional<std::string> selectReResolvesRequestedKey(ConformanceProbe& probe) {
905 using Options = std::vector<SelectOption>;
906 SelectOption const one{.key = Key{std::int64_t{1}}, .label = "One"};
907 SelectOption const two{.key = Key{std::int64_t{2}}, .label = "Two"};
908 SelectOption const three{.key = Key{std::int64_t{3}}, .label = "Three"};
909 reactive::Signal<Options> options{probe.runtime(), Options{}};
910 int choices = 0;
911 Mounted const view{probe.runtime(), probe.backend(),
912 ui::select({.options = [&options] { return options.get(); },
913 .selected = std::optional<Key>{two.key},
914 .onSelect = [&choices](Key const&) { ++choices; }})};
915 probe.settle();
916 Checks checks;
917 checks.text("a Select whose requested key came before any options", "", probe.textOf(view.root()));
918 options.set({one, three});
919 probe.settle();
920 checks.text("a Select whose options lack the requested key", "", probe.textOf(view.root()));
921 options.set({one, two});
922 probe.settle();
923 checks.text("a Select once options with the requested key arrived", "Two", probe.textOf(view.root()));
924 options.set({one});
925 probe.settle();
926 checks.text("a Select whose new options lack the requested key", "", probe.textOf(view.root()));
927 options.set({two, one});
928 probe.settle();
929 checks.text("a Select whose options bring the requested key back", "Two", probe.textOf(view.root()));
930 checks.that(choices == 0, "re-marking the requested key called onSelect " + std::to_string(choices) + " times");
931 return checks.result();
932}
933
934/// @brief Case: a Table in `Multiple` mode reports a user's change as exactly the existing rows now selected.
935///
936/// A requested key without a row stays requested through row changes and is marked when its row arrives, and a
937/// row that goes is unmarked, all without a handler call; a user's change drops the requested keys without a row.
938/// @param probe The backend under test.
939/// @return `nullopt`, or what went wrong.
940[[nodiscard]] inline std::optional<std::string> tableReportsExistingSelectedRows(ConformanceProbe& probe) {
941 reactive::Runtime& runtime = probe.runtime();
942 std::vector<Entry> const three{{.id = 1, .label = "a"}, {.id = 2, .label = "b"}, {.id = 3, .label = "c"}};
943 std::vector<Entry> withNine = three;
944 withNine.push_back({.id = 9, .label = "i"});
945 reactive::Signal<std::vector<Entry>> rows{runtime, three};
946 reactive::Signal<std::vector<Key>> selection{runtime, {Key{std::int64_t{2}}, Key{std::int64_t{9}}}};
947 std::vector<std::vector<Key>> reports;
948 // The selection is a slot, so the application writes what the user picked for the table to keep showing it.
949 Mounted const view{runtime, probe.backend(),
950 entryTable(rows, {.selectionMode = SelectionMode::Multiple,
951 .selection = [&selection] { return selection.get(); },
952 .onSelectionChange =
953 [&reports, &selection](std::vector<Key> keys) {
954 reports.push_back(keys);
955 selection.set(std::move(keys));
956 }})};
957 probe.settle();
958 Checks checks;
959 checks.text("the rows marked for requested keys 2 and 9, with no row 9", "b", selectedTexts(probe, view.root()));
960 rows.set(withNine);
961 probe.settle();
962 checks.text("the rows marked once row 9 arrived", "b,i", selectedTexts(probe, view.root()));
963 rows.set(three);
964 probe.settle();
965 checks.text("the rows marked once row 9 went", "b", selectedTexts(probe, view.root()));
966 rows.set(withNine);
967 probe.settle();
968 checks.text("the rows marked once row 9 returned", "b,i", selectedTexts(probe, view.root()));
969 checks.that(reports.empty(), "rows arriving or going called onSelectionChange");
970 rows.set(three);
971 probe.settle();
972 probe.selectRows(view.root(), {0, 2});
973 probe.settle();
974 checks.text("the rows marked after the user selected a and c", "a,c", selectedTexts(probe, view.root()));
975 // A backend may report each step of the user's picking (a click, then a Ctrl-click), so only the last report
976 // must be the whole selection; but no report may name a key without a row.
977 bool const rowlessReported = std::ranges::any_of(reports, [](std::vector<Key> const& keys) {
978 return std::ranges::any_of(keys, [](Key const& key) {
979 return key != Key{std::int64_t{1}} && key != Key{std::int64_t{2}} && key != Key{std::int64_t{3}};
980 });
981 });
982 std::vector<Key> last = reports.empty() ? std::vector<Key>{} : reports.back();
983 std::ranges::sort(last);
984 checks.that(!reports.empty(), "a user's selection of rows 1 and 3 did not call onSelectionChange");
985 checks.that(!rowlessReported,
986 "a user's selection, with key 9 requested and no row 9, reported a key without a row");
987 checks.text("the last report of a user's selection of rows 1 and 3, with key 9 requested and no row 9", "1,3",
988 keyTexts(last));
989 rows.set(withNine);
990 probe.settle();
991 checks.text("the rows marked once row 9 arrived after the user's selection", "a,c",
992 selectedTexts(probe, view.root()));
993 return checks.result();
994}
995
996/// @brief Case: a Table shows the user's selection even when the application binds no selection.
997/// @param probe The backend under test.
998/// @return `nullopt`, or what went wrong.
999[[nodiscard]] inline std::optional<std::string> tableShowsUnboundUserSelection(ConformanceProbe& probe) {
1000 reactive::Signal<std::vector<Entry>> rows{probe.runtime(), {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}};
1001 int reports = 0;
1002 Mounted const view{probe.runtime(), probe.backend(),
1003 entryTable(rows, {.selectionMode = SelectionMode::Single,
1004 .onSelectionChange = [&reports](std::vector<Key> const&) { ++reports; }})};
1005 probe.settle();
1006 probe.selectRows(view.root(), {1});
1007 probe.settle();
1008 Checks checks;
1009 checks.that(reports == 1, "a user's selection called onSelectionChange " + std::to_string(reports) + " times");
1010 checks.text("the rows marked after the user selected b, with no selection bound", "b",
1011 selectedTexts(probe, view.root()));
1012 rows.set({{.id = 1, .label = "a"}, {.id = 2, .label = "b2"}});
1013 probe.settle();
1014 checks.text("the rows marked after the selected row was updated", "b2", selectedTexts(probe, view.root()));
1015 return checks.result();
1016}
1017
1018/// @brief Case: a hidden Table cell keeps its column: it stays in its row, so the cells after it keep their places.
1019///
1020/// This checks the row's structure only; that the cells after it are drawn under their own headers is the
1021/// backend's own test to make.
1022/// @param probe The backend under test.
1023/// @return `nullopt`, or what went wrong.
1024[[nodiscard]] inline std::optional<std::string> hiddenCellKeepsItsColumn(ConformanceProbe& probe) {
1025 reactive::Signal<std::vector<Entry>> const rows{probe.runtime(), {{.id = 1, .label = "a"}}};
1026 Mounted const view{probe.runtime(), probe.backend(),
1028 {{.label = "Note"}, {.label = "Name"}}, [&rows] { return rows.get(); },
1029 [](Entry const& entry) { return Key{entry.id}; },
1030 [](reactive::Signal<Entry> const& entry) {
1031 return std::vector<Node>{ui::text({.text = "note", .common = {.visible = false}}),
1032 ui::text({.text = [&entry] { return entry.get().label; }})};
1033 })};
1034 probe.settle();
1035 Widget const* const row = childOf(probe, &view.root(), 0);
1036 ContainerWidget const* const cells = row == nullptr ? nullptr : asContainer(*row);
1037 if (cells == nullptr) {
1038 return "a Table with one row shows no row container";
1039 }
1040 Checks checks;
1041 checks.text("the cells of a row whose first cell is hidden", "note,a", childTexts(probe, *cells));
1042 Widget const* const first = probe.childAt(*cells, 0);
1043 checks.that(first != nullptr && !probe.visibleOf(*first), "the hidden cell is shown");
1044 return checks.result();
1045}
1046
1047/// @brief Case: no handler runs after its widget's destructor returned, even for input that arrived before.
1048/// @param probe The backend under test.
1049/// @return `nullopt`, or what went wrong.
1050[[nodiscard]] inline std::optional<std::string> noHandlerAfterDestruction(ConformanceProbe& probe) {
1051 int clicks = 0;
1052 std::optional<Mounted> view;
1053 view.emplace(probe.runtime(), probe.backend(), ui::button({.label = "Go", .onClick = [&clicks] { ++clicks; }}));
1054 probe.settle();
1055 probe.click(view->root());
1056 probe.settle();
1057 Checks checks;
1058 checks.that(clicks == 1, "a click ran the action " + std::to_string(clicks) + " times");
1059 probe.click(view->root());
1060 int const before = clicks;
1061 view.reset();
1062 probe.settle();
1063 checks.that(clicks == before, "a click's handler ran after the button was destroyed");
1064 return checks.result();
1065}
1066
1067/// @brief What `LifeSentinel` reports about the handler that holds it.
1069 /// @brief The sentinel inside the handler while that handler runs; null otherwise.
1070 void const* running = nullptr;
1071 /// @brief Set when the running handler's sentinel was destroyed before the handler returned.
1072 bool freedWhileRunning = false;
1073};
1074
1075/// @brief Captured by value in a handler, tells whether the backend destroyed the handler while it ran.
1076///
1077/// Every copy of the handler holds its own sentinel; only the destruction of the copy that is running counts.
1079public:
1080 /// @param life Where to report; must outlive every copy.
1081 explicit LifeSentinel(HandlerLife& life) noexcept : _life{&life} {}
1082
1083 /// @brief Reports its own destruction when it belongs to the running handler.
1085 if (_life->running == this) {
1086 _life->freedWhileRunning = true;
1087 }
1088 }
1089
1090 /// @brief A copy, for another copy of the handler.
1091 LifeSentinel(LifeSentinel const&) = default;
1092 /// @brief Copies the report target.
1093 /// @return This sentinel.
1095 /// @brief A copy, for a moved handler.
1097 /// @brief Copies the report target.
1098 /// @return This sentinel.
1100
1101 /// @brief Where it reports.
1102 /// @return The report.
1103 [[nodiscard]] HandlerLife* life() const noexcept { return _life; }
1104
1105private:
1106 HandlerLife* _life;
1107};
1108
1109/// @brief Case: a handler may destroy its own widget; the backend keeps the running handler alive until it returns,
1110/// and stays usable after.
1111/// @param probe The backend under test.
1112/// @return `nullopt`, or what went wrong.
1113[[nodiscard]] inline std::optional<std::string> handlerMayDestroyItsWidget(ConformanceProbe& probe) {
1114 HandlerLife life;
1115 int clicks = 0;
1116 std::optional<Mounted> view;
1117 // After the reset the handler touches nothing it captured: its captures are gone if the backend let them go.
1118 view.emplace(probe.runtime(), probe.backend(),
1119 ui::button({.label = "Close", .onClick = [sentinel = LifeSentinel{life}, &view, &clicks] {
1120 HandlerLife* const report = sentinel.life();
1121 std::optional<Mounted>* const own = &view;
1122 ++clicks;
1123 report->running = &sentinel;
1124 own->reset();
1125 report->running = nullptr;
1126 }}));
1127 probe.settle();
1128 probe.click(view->root());
1129 probe.settle();
1130 Checks checks;
1131 checks.that(clicks == 1,
1132 "the click that destroys its own button ran the action " + std::to_string(clicks) + " times");
1133 checks.that(!view.has_value(), "the click did not destroy its own button");
1134 checks.that(!life.freedWhileRunning,
1135 "destroying a button from its own handler destroyed the running handler; the backend must keep it "
1136 "alive until it returns");
1137 Mounted const after{probe.runtime(), probe.backend(), ui::text({.text = "after"})};
1138 probe.settle();
1139 checks.text("a Text mounted after a handler destroyed its own widget", "after", probe.textOf(after.root()));
1140 checks.that(clicks == 1, "a handler ran again after it destroyed its own widget");
1141 return checks.result();
1142}
1143
1144/// @brief Drags a card carrying key 7 onto a panel built with @p accepts, and returns what the panel received.
1145/// @param probe The backend under test.
1146/// @param accepts The panel's predicate; empty leaves `Common::accepts` unset.
1147/// @return The keys the panel's `onDrop` received.
1148[[nodiscard]] inline std::vector<Key> dropOntoPanel(ConformanceProbe& probe, std::function<bool(Key const&)> accepts) {
1149 std::vector<Key> dropped;
1150 Mounted const card{probe.runtime(), probe.backend(),
1151 ui::text({.text = "card", .common = {.dragKey = std::optional<Key>{Key{std::int64_t{7}}}}})};
1152 Mounted const target{
1153 probe.runtime(), probe.backend(),
1154 ui::panel({.title = "Done", .common = {.accepts = std::move(accepts), .onDrop = [&dropped](Key key) {
1155 dropped.push_back(std::move(key));
1156 }}})};
1157 probe.settle();
1158 probe.drag(card.root(), target.root());
1159 probe.settle();
1160 return dropped;
1161}
1162
1163/// @brief Case: a drag onto a target that accepts the key delivers it once.
1164/// @param probe The backend under test.
1165/// @return `nullopt`, or what went wrong.
1166[[nodiscard]] inline std::optional<std::string> dragOntoAcceptingTarget(ConformanceProbe& probe) {
1167 Checks checks;
1168 checks.that(dropOntoPanel(probe, [](Key const& key) { return key == Key{std::int64_t{7}}; }) ==
1169 std::vector<Key>{Key{std::int64_t{7}}},
1170 "a drop the target accepts did not deliver the dragged key exactly once");
1171 return checks.result();
1172}
1173
1174/// @brief Case: a drag onto a target that refuses the key delivers nothing.
1175/// @param probe The backend under test.
1176/// @return `nullopt`, or what went wrong.
1177[[nodiscard]] inline std::optional<std::string> dragOntoRefusingTarget(ConformanceProbe& probe) {
1178 Checks checks;
1179 checks.that(dropOntoPanel(probe, [](Key const&) { return false; }).empty(),
1180 "a drop the target refuses reached its onDrop");
1181 return checks.result();
1182}
1183
1184/// @brief Case: a drop target without `accepts` takes every key.
1185/// @param probe The backend under test.
1186/// @return `nullopt`, or what went wrong.
1187[[nodiscard]] inline std::optional<std::string> dropTargetWithoutAcceptsTakesAll(ConformanceProbe& probe) {
1188 Checks checks;
1189 checks.that(dropOntoPanel(probe, {}) == std::vector<Key>{Key{std::int64_t{7}}},
1190 "a drop target without accepts refused a key");
1191 return checks.result();
1192}
1193
1194/// @brief Case: a chord goes to the innermost widget that declares it, and one only an outer widget declares reaches
1195/// that widget.
1196/// @param probe The backend under test.
1197/// @return `nullopt`, or what went wrong.
1198[[nodiscard]] inline std::optional<std::string> innermostChordWins(ConformanceProbe& probe) {
1199 std::vector<std::string> pressed;
1200 Mounted const view{
1201 probe.runtime(), probe.backend(),
1202 ui::column({.children = {ui::column(
1203 {.children = {ui::button(
1204 {.label = "Save",
1205 .common = {.keys = {{.chord = "Ctrl+S",
1206 .onPress = [&pressed] { pressed.emplace_back("inner"); }}}}})}})},
1207 .common = {.keys = {{.chord = "Ctrl+S", .onPress = [&pressed] { pressed.emplace_back("outer"); }},
1208 {.chord = "Ctrl+Q",
1209 .onPress = [&pressed] { pressed.emplace_back("outer quit"); }}}}})};
1210 probe.settle();
1211 Widget* const button = childOf(probe, childOf(probe, &view.root(), 0), 0);
1212 if (button == nullptr) {
1213 return "a Button in a Column in a Column is not shown";
1214 }
1215 probe.press(*button, "Ctrl+S");
1216 probe.settle();
1217 probe.press(*button, "Ctrl+Q");
1218 probe.settle();
1219 Checks checks;
1220 checks.that(pressed == std::vector<std::string>{"inner", "outer quit"},
1221 "Ctrl+S then Ctrl+Q with focus on the button ran " + std::to_string(pressed.size()) +
1222 " handlers, not the inner Ctrl+S and the outer Ctrl+Q");
1223 return checks.result();
1224}
1225
1226/// @brief Case: a chord pressed inside a container hidden two levels up runs nothing; while shown it runs once.
1227/// @param probe The backend under test.
1228/// @return `nullopt`, or what went wrong.
1229[[nodiscard]] inline std::optional<std::string> hiddenChordRunsNothing(ConformanceProbe& probe) {
1230 reactive::Signal<bool> shown{probe.runtime(), true};
1231 int presses = 0;
1232 Mounted const view{
1233 probe.runtime(), probe.backend(),
1234 ui::column({.children = {ui::column(
1235 {.children = {ui::button(
1236 {.label = "Save",
1237 .common = {.keys = {{.chord = "Ctrl+S", .onPress = [&presses] { ++presses; }}}}})}})},
1238 .common = {.visible = [&shown] { return shown.get(); }}})};
1239 probe.settle();
1240 Widget* const button = childOf(probe, childOf(probe, &view.root(), 0), 0);
1241 if (button == nullptr) {
1242 return "a Button in a Column in a Column is not shown";
1243 }
1244 probe.press(*button, "Ctrl+S"); // shown: the chord runs, so the probe's press is known to reach handlers
1245 probe.settle();
1246 shown.set(false);
1247 probe.settle();
1248 probe.press(*button, "Ctrl+S");
1249 probe.settle();
1250 Checks checks;
1251 checks.that(presses == 1, "a chord ran " + std::to_string(presses) +
1252 " times; once while shown and not again once hidden is expected");
1253 return checks.result();
1254}
1255
1256/// @brief Case: the first `autofocus` widget in document order has focus once the mount completes.
1257/// @param probe The backend under test.
1258/// @return `nullopt`, or what went wrong.
1259[[nodiscard]] inline std::optional<std::string> firstAutofocusWins(ConformanceProbe& probe) {
1260 Mounted const view{
1261 probe.runtime(), probe.backend(),
1262 ui::column({.children = {ui::text({.text = "Name"}), ui::textInput({.common = {.autofocus = true}}),
1263 ui::textInput({.common = {.autofocus = true}})}})};
1264 probe.settle();
1265 Widget const* const first = childOf(probe, &view.root(), 1);
1266 Widget const* const second = childOf(probe, &view.root(), 2);
1267 if (first == nullptr || second == nullptr) {
1268 return "a Column does not show its three children";
1269 }
1270 Checks checks;
1271 checks.that(probe.hasFocus(*first), "the first autofocus input does not have focus");
1272 checks.that(!probe.hasFocus(*second), "the second autofocus input has focus");
1273 return checks.result();
1274}
1275
1276/// @brief Case: a menu reports only an enabled entry without a submenu, and an entry enabled later can be chosen.
1277/// @param probe The backend under test.
1278/// @return `nullopt`, or what went wrong.
1279[[nodiscard]] inline std::optional<std::string> onlyEnabledMenuLeavesAreChosen(ConformanceProbe& probe) {
1280 reactive::Signal<bool> deletable{probe.runtime(), false};
1281 std::vector<std::string> chosen;
1282 auto const choose = [&chosen](std::string name) {
1283 return [&chosen, name = std::move(name)] { chosen.push_back(name); };
1284 };
1285 Mounted const view{probe.runtime(), probe.backend(),
1286 ui::menu({.items = {{.label = "Recent",
1287 .onSelect = choose("Recent"),
1288 .items = {{.label = "a.txt", .onSelect = choose("a.txt")}}},
1289 {.label = "Delete", .onSelect = choose("Delete"), .enabled = [&deletable] {
1290 return deletable.get();
1291 }}}})};
1292 probe.settle();
1293 probe.chooseMenuEntry(view.root(), {0}); // opens the submenu
1294 probe.chooseMenuEntry(view.root(), {0, 0});
1295 probe.chooseMenuEntry(view.root(), {1}); // disabled
1296 probe.settle();
1297 deletable.set(true);
1298 probe.settle();
1299 probe.chooseMenuEntry(view.root(), {1});
1300 probe.settle();
1301 Checks checks;
1302 checks.that(chosen == std::vector<std::string>{"a.txt", "Delete"},
1303 "choosing a submenu, its entry, a disabled entry and then that entry enabled ran " +
1304 std::to_string(chosen.size()) + " handlers, not the submenu's entry and the enabled one");
1305 return checks.result();
1306}
1307
1308/// @brief Case: typing into a read-only input reaches no handler; once editable again, it does.
1309/// @param probe The backend under test.
1310/// @return `nullopt`, or what went wrong.
1311[[nodiscard]] inline std::optional<std::string> readOnlyInputTakesNoEdit(ConformanceProbe& probe) {
1312 reactive::Signal<bool> readonly{probe.runtime(), true};
1313 std::vector<std::string> changes;
1314 Mounted const view{probe.runtime(), probe.backend(),
1315 ui::textInput({.value = "kept",
1316 .onChange = [&changes](std::string text) { changes.push_back(std::move(text)); },
1317 .field = {.readonly = [&readonly] { return readonly.get(); }}})};
1318 probe.settle();
1319 probe.type(view.root(), "typed");
1320 probe.settle();
1321 Checks checks;
1322 checks.that(changes.empty(), "typing into a read-only input reached onChange");
1323 checks.that(probe.textOf(view.root()) == "kept",
1324 "a read-only input shows '" + probe.textOf(view.root()) + "' after typing, not its value 'kept'");
1325 readonly.set(false);
1326 probe.settle();
1327 probe.type(view.root(), "typed");
1328 probe.settle();
1329 checks.that(changes == std::vector<std::string>{"typed"},
1330 "typing into the input once editable again did not reach onChange once with the text");
1331 return checks.result();
1332}
1333
1334/// @brief Case: dismissing a dismissible banner reaches `onDismiss` once, and the banner stays shown.
1335/// @param probe The backend under test.
1336/// @return `nullopt`, or what went wrong.
1337[[nodiscard]] inline std::optional<std::string> dismissedBannerStays(ConformanceProbe& probe) {
1338 int dismissed = 0;
1339 Mounted const view{probe.runtime(), probe.backend(),
1340 ui::banner({.text = "Saved", .dismissible = true, .onDismiss = [&dismissed] { ++dismissed; }})};
1341 probe.settle();
1342 probe.dismiss(view.root());
1343 probe.settle();
1344 Checks checks;
1345 checks.that(dismissed == 1,
1346 "dismissing the banner ran onDismiss " + std::to_string(dismissed) + " times, not once");
1347 checks.that(probe.visibleOf(view.root()), "the banner hid itself; the application hides it");
1348 return checks.result();
1349}
1350
1351/// @brief Case: a closed collapsible section's content takes no click and its header does; the user's opening
1352/// reaches `onToggle`, and once the application opens it the content takes clicks.
1353/// @param probe The backend under test.
1354/// @return `nullopt`, or what went wrong.
1355[[nodiscard]] inline std::optional<std::string> closedCollapsibleKeepsItsHeader(ConformanceProbe& probe) {
1356 reactive::Signal<bool> open{probe.runtime(), false};
1357 std::vector<std::string> ran;
1358 Mounted const view{
1359 probe.runtime(), probe.backend(),
1361 {.title = "Advanced",
1362 .open = [&open] { return open.get(); },
1363 .onToggle = [&open](bool wanted) { open.set(wanted); },
1364 .header = ui::button({.label = "Reset", .onClick = [&ran] { ran.emplace_back("header"); }}),
1365 .child = ui::button({.label = "Apply", .onClick = [&ran] { ran.emplace_back("content"); }})})};
1366 probe.settle();
1367 Widget* const header = childOf(probe, &view.root(), 0);
1368 Widget* const content = childOf(probe, &view.root(), 1);
1369 if (header == nullptr || content == nullptr) {
1370 return "a Collapsible does not show its header and its content as its two children";
1371 }
1372 probe.click(*header);
1373 probe.click(*content);
1374 probe.settle();
1375 probe.expand(view.root(), true);
1376 probe.settle();
1377 probe.click(*content);
1378 probe.settle();
1379 Checks checks;
1380 checks.that(ran == std::vector<std::string>{"header", "content"},
1381 "clicking the header and the content while closed, then the content once opened, ran " +
1382 std::to_string(ran.size()) + " handlers, not the header's and then the content's");
1383 return checks.result();
1384}
1385
1386/// @brief Case: a drop zone takes a drop of one accepted file, compared without case, and refuses a file it does
1387/// not accept and several files when it takes one.
1388/// @param probe The backend under test.
1389/// @return `nullopt`, or what went wrong.
1390[[nodiscard]] inline std::optional<std::string> dropZoneTakesWholeDrops(ConformanceProbe& probe) {
1391 std::vector<std::vector<std::string>> drops;
1392 Mounted const view{probe.runtime(), probe.backend(),
1393 ui::dropZone({.accept = {".csv"}, .onDrop = [&drops](std::vector<std::string> paths) {
1394 drops.push_back(std::move(paths));
1395 }})};
1396 probe.settle();
1397 probe.dropFiles(view.root(), {"/in/RUN.CSV"});
1398 probe.dropFiles(view.root(), {"/in/notes.txt"});
1399 probe.dropFiles(view.root(), {"/in/a.csv", "/in/b.csv"});
1400 probe.settle();
1401 Checks checks;
1402 checks.that(drops == std::vector<std::vector<std::string>>{{"/in/RUN.CSV"}},
1403 "of an accepted file, a refused one and two at once, the zone took " + std::to_string(drops.size()) +
1404 " drops, not the accepted file alone");
1405 return checks.result();
1406}
1407
1408/// @brief Reads where the layout cases' widgets are drawn, and fails a case whose probe reads no geometry.
1410public:
1411 /// @brief Over @p probe.
1412 /// @param probe The backend under test.
1413 /// @param checks The case's checks.
1414 Geometry(ConformanceProbe& probe, Checks& checks) : _probe{&probe}, _checks{&checks} {}
1415
1416 /// @brief Where @p widget is drawn; zero, with the case failed, when the probe reads no geometry.
1417 /// @param widget The widget.
1418 /// @return Its bounds.
1419 [[nodiscard]] WidgetBounds of(Widget const& widget) {
1420 std::optional<WidgetBounds> const bounds = _probe->boundsOf(widget);
1421 _checks->that(bounds.has_value(), "the probe reads no bounds, so a layout case measures nothing");
1422 return bounds.value_or(WidgetBounds{});
1423 }
1424
1425 /// @brief Checks that a length is what it should be, within one unit.
1426 /// @param what What is measured, for the message.
1427 /// @param expected The length it should be.
1428 /// @param actual The length it is.
1429 void near(std::string_view what, double expected, double actual) {
1430 _checks->that(
1431 actual >= expected - 1.0 && actual <= expected + 1.0,
1432 std::string{what} + ": expected " + std::to_string(expected) + ", got " + std::to_string(actual));
1433 }
1434
1435private:
1436 ConformanceProbe* _probe;
1437 Checks* _checks;
1438};
1439
1440/// @brief A text that sizes as @p layout says.
1441/// @param text The text.
1442/// @param layout Its size request.
1443/// @return The node.
1444[[nodiscard]] inline Node sizedText(std::string text, LayoutHints layout) {
1445 Text made{.text = std::move(text)};
1446 made.common.layout = layout;
1447 return ui::text(std::move(made));
1448}
1449
1450/// @brief @p inner inside a column, so it is sized as a child is: a root fills the window or screen whatever its
1451/// own sizing.
1452/// @param inner The container a case measures.
1453/// @return The column.
1454[[nodiscard]] inline Node insideColumn(Node inner) { return ui::column(Column{.children = {std::move(inner)}}); }
1455
1456/// @brief @p inner inside a row, so its height is sized as a child's is.
1457/// @param inner The container a case measures.
1458/// @return The row.
1459[[nodiscard]] inline Node insideRow(Node inner) { return ui::row(Row{.children = {std::move(inner)}}); }
1460
1461/// @brief The container a case measures, the one child of the mounted root, and its children in order.
1462struct Measured {
1463 /// @brief The measured container; null when the root holds none.
1464 Widget* container = nullptr;
1465 /// @brief Its children.
1466 std::vector<Widget*> children;
1467};
1468
1469/// @brief The container a case measures, inside the root `insideColumn` or `insideRow` made.
1470/// @param probe The backend under test.
1471/// @param view The mount.
1472/// @return It and its children.
1473[[nodiscard]] inline Measured measured(ConformanceProbe& probe, Mounted const& view) {
1474 Measured found;
1475 found.container = probe.childAt(rootContainer(view), 0);
1476 ContainerWidget const* const inner = found.container == nullptr ? nullptr : asContainer(*found.container);
1477 for (std::size_t index = 0; inner != nullptr && index < probe.childCount(*inner); ++index) {
1478 found.children.push_back(probe.childAt(*inner, index));
1479 }
1480 return found;
1481}
1482
1483/// @brief Case: along a row, a stretch child's share stops at its maximum and its stretch sibling takes the rest.
1484/// @param probe The backend under test.
1485/// @return `nullopt`, or what went wrong.
1486[[nodiscard]] inline std::optional<std::string> maximumCapsStretchShare(ConformanceProbe& probe) {
1487 Row row{.children = {sizedText("a", {.width = Sizing::stretch(), .maxWidth = 20}),
1488 sizedText("b", {.width = Sizing::stretch()})}};
1489 row.common.layout = {.width = Sizing::fixed(60)};
1490 Mounted const view{probe.runtime(), probe.backend(), insideColumn(ui::row(std::move(row)))};
1491 probe.settle();
1492 Checks checks;
1493 Geometry geometry{probe, checks};
1494 std::vector<Widget*> const children = measured(probe, view).children;
1495 checks.that(children.size() == 2, "the row holds two children");
1496 if (children.size() == 2) {
1497 geometry.near("the capped stretch child's width", 20, geometry.of(*children.at(0)).width);
1498 geometry.near("its stretch sibling's width", 40, geometry.of(*children.at(1)).width);
1499 }
1500 return checks.result();
1501}
1502
1503/// @brief Case: along a row, a content child is at least its minimum wide.
1504/// @param probe The backend under test.
1505/// @return `nullopt`, or what went wrong.
1506[[nodiscard]] inline std::optional<std::string> minimumWidensContent(ConformanceProbe& probe) {
1507 Row row{.children = {sizedText("ab", {.minWidth = 30}), sizedText("c", {})}};
1508 row.common.layout = {.width = Sizing::fixed(60)};
1509 Mounted const view{probe.runtime(), probe.backend(), insideColumn(ui::row(std::move(row)))};
1510 probe.settle();
1511 Checks checks;
1512 Geometry geometry{probe, checks};
1513 std::vector<Widget*> const children = measured(probe, view).children;
1514 checks.that(children.size() == 2, "the row holds two children");
1515 if (children.size() == 2) {
1516 geometry.near("a content text with minWidth 30", 30, geometry.of(*children.at(0)).width);
1517 }
1518 return checks.result();
1519}
1520
1521/// @brief Case: across a column, a stretch child stops at its maximum, at the start.
1522/// @param probe The backend under test.
1523/// @return `nullopt`, or what went wrong.
1524[[nodiscard]] inline std::optional<std::string> maximumCapsAcross(ConformanceProbe& probe) {
1525 Column column{.children = {sizedText("card", {.width = Sizing::stretch(), .maxWidth = 44})}};
1526 column.common.layout = {.width = Sizing::fixed(70)};
1527 Mounted const view{probe.runtime(), probe.backend(), insideColumn(ui::column(std::move(column)))};
1528 probe.settle();
1529 Checks checks;
1530 Geometry geometry{probe, checks};
1531 Measured const inside = measured(probe, view);
1532 std::vector<Widget*> const& children = inside.children;
1533 checks.that(children.size() == 1, "the column holds one child");
1534 if (children.size() == 1) {
1535 WidgetBounds const outer = geometry.of(*inside.container);
1536 geometry.near("the column's width", 70, outer.width);
1537 WidgetBounds const card = geometry.of(*children.at(0));
1538 geometry.near("a stretch child with maxWidth 44 across a 70-unit column", 44, card.width);
1539 geometry.near("its left edge, at the column's start", outer.x, card.x);
1540 }
1541 return checks.result();
1542}
1543
1544/// @brief Case: down a column, a stretch child's height stops at its maximum and its stretch sibling takes the
1545/// rest.
1546/// @param probe The backend under test.
1547/// @return `nullopt`, or what went wrong.
1548[[nodiscard]] inline std::optional<std::string> maximumCapsStretchHeight(ConformanceProbe& probe) {
1549 Column column{.children = {sizedText("top", {.height = Sizing::stretch(), .maxHeight = 5}),
1550 sizedText("rest", {.height = Sizing::stretch()})}};
1551 column.common.layout = {.width = Sizing::fixed(30), .height = Sizing::fixed(20)};
1552 Mounted const view{probe.runtime(), probe.backend(), insideRow(ui::column(std::move(column)))};
1553 probe.settle();
1554 Checks checks;
1555 Geometry geometry{probe, checks};
1556 std::vector<Widget*> const children = measured(probe, view).children;
1557 checks.that(children.size() == 2, "the column holds two children");
1558 if (children.size() == 2) {
1559 geometry.near("the capped stretch child's height", 5, geometry.of(*children.at(0)).height);
1560 geometry.near("its stretch sibling's height", 15, geometry.of(*children.at(1)).height);
1561 }
1562 return checks.result();
1563}
1564
1565/// @brief Case: a row 3 units tall with `align: center` places a 1-unit text a unit down.
1566/// @param probe The backend under test.
1567/// @return `nullopt`, or what went wrong.
1568[[nodiscard]] inline std::optional<std::string> rowCentresAcross(ConformanceProbe& probe) {
1569 Row row{.children = {sizedText("x", {})}, .align = Align::Center};
1570 row.common.layout = {.width = Sizing::fixed(30), .height = Sizing::fixed(3)};
1571 Mounted const view{probe.runtime(), probe.backend(), insideColumn(ui::row(std::move(row)))};
1572 probe.settle();
1573 Checks checks;
1574 Geometry geometry{probe, checks};
1575 Measured const inside = measured(probe, view);
1576 checks.that(inside.children.size() == 1, "the row holds one child");
1577 if (inside.children.size() == 1) {
1578 WidgetBounds const outer = geometry.of(*inside.container);
1579 WidgetBounds const text = geometry.of(*inside.children.at(0));
1580 geometry.near("the text's height", 1, text.height);
1581 geometry.near("the text's offset down the row", 1, text.y - outer.y);
1582 }
1583 return checks.result();
1584}
1585
1586/// @brief Case: a column with `justify: end` places its last child's end at its own end, and `between` its first
1587/// child at the start and its last at the end.
1588/// @param probe The backend under test.
1589/// @return `nullopt`, or what went wrong.
1590[[nodiscard]] inline std::optional<std::string> columnJustifies(ConformanceProbe& probe) {
1591 Checks checks;
1592 Geometry geometry{probe, checks};
1593 for (Justify const justify : {Justify::End, Justify::Between}) {
1594 Column column{.children = {sizedText("a", {}), sizedText("b", {}), sizedText("c", {})}, .justify = justify};
1595 column.common.layout = {.width = Sizing::fixed(20), .height = Sizing::fixed(12)};
1596 Mounted const view{probe.runtime(), probe.backend(), insideRow(ui::column(std::move(column)))};
1597 probe.settle();
1598 Measured const inside = measured(probe, view);
1599 checks.that(inside.children.size() == 3, "the column holds three children");
1600 if (inside.children.size() != 3) {
1601 continue;
1602 }
1603 WidgetBounds const outer = geometry.of(*inside.container);
1604 WidgetBounds const first = geometry.of(*inside.children.at(0));
1605 WidgetBounds const last = geometry.of(*inside.children.at(2));
1606 geometry.near(justify == Justify::End ? "end: the last child's end" : "between: the last child's end",
1607 outer.y + outer.height, last.y + last.height);
1608 if (justify == Justify::Between) {
1609 geometry.near("between: the first child's start", outer.y, first.y);
1610 } else {
1611 checks.that(first.y > outer.y + 1, "end: the first child is not at the column's start");
1612 }
1613 }
1614 return checks.result();
1615}
1616
1617/// @brief Case: a child capped below its column's width with `alignSelf: center` has equal space either side; a
1618/// stretch child with `maxWidth: 44` in a 70-unit column with `align: center` is 44 wide and centred.
1619/// @param probe The backend under test.
1620/// @return `nullopt`, or what went wrong.
1621[[nodiscard]] inline std::optional<std::string> cappedChildCentres(ConformanceProbe& probe) {
1622 Checks checks;
1623 Geometry geometry{probe, checks};
1624 for (bool const own : {true, false}) {
1625 LayoutHints card{.width = Sizing::stretch(), .maxWidth = own ? 20 : 44};
1626 if (own) {
1627 card.alignSelf = Align::Center;
1628 }
1629 Column column{.children = {sizedText("card", card)}, .align = own ? Align::Stretch : Align::Center};
1630 column.common.layout = {.width = Sizing::fixed(70)};
1631 Mounted const view{probe.runtime(), probe.backend(), insideColumn(ui::column(std::move(column)))};
1632 probe.settle();
1633 Measured const inside = measured(probe, view);
1634 checks.that(inside.children.size() == 1, "the column holds one child");
1635 if (inside.children.size() != 1) {
1636 continue;
1637 }
1638 WidgetBounds const outer = geometry.of(*inside.container);
1639 WidgetBounds const placed = geometry.of(*inside.children.at(0));
1640 double const before = placed.x - outer.x;
1641 double const after = (outer.x + outer.width) - (placed.x + placed.width);
1642 geometry.near(own ? "alignSelf center: the capped child's width" : "align center: the card's width",
1643 own ? 20 : 44, placed.width);
1644 geometry.near(own ? "alignSelf center: the space after it, against the space before"
1645 : "align center: the space after the card, against the space before",
1646 before, after);
1647 }
1648 return checks.result();
1649}
1650
1651} // namespace detail
1652
1653/// @brief The layout cases: where a backend that lays widgets out draws them. Run each on a fresh probe whose
1654/// `boundsOf` reads geometry; a probe that reads none fails every one.
1655/// @return The cases.
1656[[nodiscard]] inline std::span<ConformanceCase const> layoutConformanceCases() {
1657 static std::array<ConformanceCase, 7> const cases{{
1658 {.name = "a stretch child's maximum caps its share along a row, and its sibling takes the rest",
1660 {.name = "a content child is at least its minimum wide along a row", .run = detail::minimumWidensContent},
1661 {.name = "a stretch child's maximum caps it across a column", .run = detail::maximumCapsAcross},
1662 {.name = "a stretch child's maximum height caps its share down a column",
1664 {.name = "a row centres a child across it", .run = detail::rowCentresAcross},
1665 {.name = "a column's justify places its spare height", .run = detail::columnJustifies},
1666 {.name = "a capped child is centred by its own alignment or its column's", .run = detail::cappedChildCentres},
1667 }};
1668 return cases;
1669}
1670
1671/// @brief Every conformance case, in a fixed order.
1672///
1673/// Run each on a fresh probe; `nullopt` is a pass, anything else is the failure message. A backend's test suite
1674/// loops over these with its own `ConformanceProbe`.
1675/// @return The cases.
1676[[nodiscard]] inline std::span<ConformanceCase const> conformanceCases() {
1677 static std::array<ConformanceCase, 34> const cases{{
1678 {.name = "a Text shows its constant text", .run = detail::textShowsItsText},
1679 {.name = "a bound Text updates once per batch and not for an equal write",
1681 {.name = "visible and enabled follow their bindings", .run = detail::visibleAndEnabledFollowBindings},
1682 {.name = "a click runs the button's action once", .run = detail::clickReachesAction},
1683 {.name = "typing reaches onChange and a value set by the application is not echoed",
1685 {.name = "a Switch shows the selected case and nothing for a key without one",
1687 {.name = "Tabs mount a page on first selection and show only the selected one",
1689 {.name = "a ForEach updates a kept row in place", .run = detail::forEachUpdatesInPlace},
1690 {.name = "a ForEach follows inserts, removals and reorders", .run = detail::forEachInsertsRemovesReorders},
1691 {.name = "a moved widget keeps its native state", .run = detail::movedRowKeepsNativeState},
1692 {.name = "a Dialog holds its content only while open", .run = detail::dialogHoldsContentWhileOpen},
1693 {.name = "a new Dialog is closed and a closed Dialog takes no input", .run = detail::closedDialogTakesNoInput},
1694 {.name = "a widget inside a hidden, disabled or collapsed container takes no input",
1696 {.name = "a setter given the text a field shows keeps the cursor", .run = detail::unchangedTextKeepsCursor},
1697 {.name = "a text field shows what the document makes of an edit", .run = detail::textInputIsControlled},
1698 {.name = "a dismissal the document refuses leaves the dialog open",
1700 {.name = "no setter calls a handler", .run = detail::settersCallNoHandler},
1701 {.name = "a Select marks the requested key whenever its options contain it",
1703 {.name = "a Table reports a user's selection as exactly the existing rows selected",
1705 {.name = "a Table shows a user's selection with no selection bound",
1707 {.name = "a hidden Table cell keeps its column", .run = detail::hiddenCellKeepsItsColumn},
1708 {.name = "no handler runs after its widget was destroyed", .run = detail::noHandlerAfterDestruction},
1709 {.name = "a handler may destroy its own widget", .run = detail::handlerMayDestroyItsWidget},
1710 {.name = "a drag onto an accepting target delivers the key", .run = detail::dragOntoAcceptingTarget},
1711 {.name = "a drag onto a refusing target delivers nothing", .run = detail::dragOntoRefusingTarget},
1712 {.name = "a drop target without accepts takes every key", .run = detail::dropTargetWithoutAcceptsTakesAll},
1713 {.name = "a chord goes to the innermost widget that declares it", .run = detail::innermostChordWins},
1714 {.name = "a chord pressed inside a hidden container runs nothing", .run = detail::hiddenChordRunsNothing},
1715 {.name = "the first autofocus widget in document order has focus", .run = detail::firstAutofocusWins},
1716 {.name = "a menu reports only an enabled entry without a submenu",
1718 {.name = "a read-only input takes no edit", .run = detail::readOnlyInputTakesNoEdit},
1719 {.name = "a dismissed banner reports it and stays shown", .run = detail::dismissedBannerStays},
1720 {.name = "a closed collapsible's content takes no input, its header does",
1722 {.name = "a drop zone takes a drop whole or refuses it whole", .run = detail::dropZoneTakesWholeDrops},
1723 }};
1724 return cases;
1725}
1726
1727} // namespace morph::ui::testing
Justify
Where a stack places its spare length along its axis, while no shown child stretches.
Definition alignment.hpp:23
@ Between
In equal gaps between the children, never less than the stack's gap.
Definition alignment.hpp:27
@ End
Packed at the end.
Definition alignment.hpp:26
@ Center
Centred.
Definition alignment.hpp:18
@ Stretch
A content child fills the cross extent; one its maximum stops sits at the start.
Definition alignment.hpp:16
std::optional< std::string > maximumCapsAcross(ConformanceProbe &probe)
Case: across a column, a stretch child stops at its maximum, at the start.
std::optional< std::string > dragOntoRefusingTarget(ConformanceProbe &probe)
Case: a drag onto a target that refuses the key delivers nothing.
std::optional< std::string > tabsMountPagesLazily(ConformanceProbe &probe)
Case: Tabs mount a page on its first selection, keep it, and show only the selected one.
std::optional< std::string > switchShowsSelectedCase(ConformanceProbe &probe)
Case: a Switch shows the selected case, nothing for a key without one, and remounts on a change.
std::optional< std::string > maximumCapsStretchHeight(ConformanceProbe &probe)
Case: down a column, a stretch child's height stops at its maximum and its stretch sibling takes the ...
std::optional< std::string > hiddenChordRunsNothing(ConformanceProbe &probe)
Case: a chord pressed inside a container hidden two levels up runs nothing; while shown it runs once.
ContainerWidget const & rootContainer(Mounted const &view)
The root widget of a mounted container node, as the container it is.
std::optional< std::string > unreachableWidgetsTakeNoInput(ConformanceProbe &probe)
Case: a widget inside a hidden, disabled or collapsed container takes no input — no click,...
std::string childTexts(ConformanceProbe &probe, ContainerWidget const &container)
The texts of a container's children, joined by commas.
std::optional< std::string > movedRowKeepsNativeState(ConformanceProbe &probe)
Case: a widget moved among its siblings keeps its native state; here, the text a user typed into a fi...
std::optional< std::string > closedDialogTakesNoInput(ConformanceProbe &probe)
Case: a new dialog is closed, and a closed dialog takes no input, neither a dismissal nor a click on ...
std::optional< std::string > maximumCapsStretchShare(ConformanceProbe &probe)
Case: along a row, a stretch child's share stops at its maximum and its stretch sibling takes the res...
std::optional< std::string > dismissedBannerStays(ConformanceProbe &probe)
Case: dismissing a dismissible banner reaches onDismiss once, and the banner stays shown.
std::span< ConformanceCase const > conformanceCases()
Every conformance case, in a fixed order.
std::optional< std::string > closedCollapsibleKeepsItsHeader(ConformanceProbe &probe)
Case: a closed collapsible section's content takes no click and its header does; the user's opening r...
Node sizedText(std::string text, LayoutHints layout)
A text that sizes as layout says.
Widget * childOf(ConformanceProbe &probe, Widget const *parent, std::size_t index)
A child of a widget that should be a container.
std::optional< std::string > hiddenCellKeepsItsColumn(ConformanceProbe &probe)
Case: a hidden Table cell keeps its column: it stays in its row, so the cells after it keep their pla...
Node entryList(reactive::Signal< std::vector< Entry > > const &rows, int *builds=nullptr)
A ForEach of Texts, one per entry, keyed by id.
std::optional< std::string > innermostChordWins(ConformanceProbe &probe)
Case: a chord goes to the innermost widget that declares it, and one only an outer widget declares re...
std::optional< std::string > readOnlyInputTakesNoEdit(ConformanceProbe &probe)
Case: typing into a read-only input reaches no handler; once editable again, it does.
std::optional< std::string > tableReportsExistingSelectedRows(ConformanceProbe &probe)
Case: a Table in Multiple mode reports a user's change as exactly the existing rows now selected.
std::vector< Key > dropOntoPanel(ConformanceProbe &probe, std::function< bool(Key const &)> accepts)
Drags a card carrying key 7 onto a panel built with accepts, and returns what the panel received.
std::optional< std::string > tableShowsUnboundUserSelection(ConformanceProbe &probe)
Case: a Table shows the user's selection even when the application binds no selection.
std::optional< std::string > noHandlerAfterDestruction(ConformanceProbe &probe)
Case: no handler runs after its widget's destructor returned, even for input that arrived before.
std::optional< std::string > dragOntoAcceptingTarget(ConformanceProbe &probe)
Case: a drag onto a target that accepts the key delivers it once.
std::optional< std::string > cappedChildCentres(ConformanceProbe &probe)
Case: a child capped below its column's width with alignSelf: center has equal space either side; a s...
ContainerWidget const * asContainer(Widget const &widget)
A widget as a container.
std::optional< std::string > rowCentresAcross(ConformanceProbe &probe)
Case: a row 3 units tall with align: center places a 1-unit text a unit down.
std::optional< std::string > settersCallNoHandler(ConformanceProbe &probe)
Case: no setter calls a handler. Setting a value, replacing options, items or rows,...
std::optional< std::string > minimumWidensContent(ConformanceProbe &probe)
Case: along a row, a content child is at least its minimum wide.
std::optional< std::string > handlerMayDestroyItsWidget(ConformanceProbe &probe)
Case: a handler may destroy its own widget; the backend keeps the running handler alive until it retu...
std::optional< std::string > visibleAndEnabledFollowBindings(ConformanceProbe &probe)
Case: visible and enabled follow their bindings.
std::optional< std::string > selectReResolvesRequestedKey(ConformanceProbe &probe)
Case: a Select marks the requested key whenever its options contain it, whichever arrives first,...
std::optional< std::string > boundTextUpdatesOncePerChange(ConformanceProbe &probe)
Case: a bound Text follows its signal, once per batch, and not for an equal write.
std::optional< std::string > refusedDismissalKeepsDialogOpen(ConformanceProbe &probe)
Case: a dialog the user dismisses but the document keeps open is shown again, with its content.
std::string selectedTexts(ConformanceProbe &probe, Widget const &table)
The texts of the rows an entryTable shows as selected, joined by commas.
std::optional< std::string > textInputIsControlled(ConformanceProbe &probe)
Case: an edit the document refuses snaps back, and one it transforms shows the transform.
std::optional< std::string > textShowsItsText(ConformanceProbe &probe)
Case: a constant Text shows its text.
std::optional< std::string > dropZoneTakesWholeDrops(ConformanceProbe &probe)
Case: a drop zone takes a drop of one accepted file, compared without case, and refuses a file it doe...
std::optional< std::string > dropTargetWithoutAcceptsTakesAll(ConformanceProbe &probe)
Case: a drop target without accepts takes every key.
Measured measured(ConformanceProbe &probe, Mounted const &view)
The container a case measures, inside the root insideColumn or insideRow made.
std::optional< std::string > clickReachesAction(ConformanceProbe &probe)
Case: a click runs the button's action once, and what it wrote reaches the screen.
std::optional< std::string > forEachUpdatesInPlace(ConformanceProbe &probe)
Case: a ForEach updates a row whose key stays in place, keeping its widget and not building its view ...
std::optional< std::string > dialogHoldsContentWhileOpen(ConformanceProbe &probe)
Case: a Dialog holds its content only while it is open.
Node entryTable(reactive::Signal< std::vector< Entry > > const &rows, TableOptions options)
A one-column Table of entries, keyed by id, whose cell is a Text of the label.
std::optional< std::string > forEachInsertsRemovesReorders(ConformanceProbe &probe)
Case: a ForEach follows inserts, removals and reorders.
Node insideColumn(Node inner)
inner inside a column, so it is sized as a child is: a root fills the window or screen whatever its o...
Node insideRow(Node inner)
inner inside a row, so its height is sized as a child's is.
std::string keyTexts(std::vector< Key > const &keys)
Keys as text, for a failure message.
std::span< ConformanceCase const > layoutConformanceCases()
The layout cases: where a backend that lays widgets out draws them. Run each on a fresh probe whose b...
std::optional< std::string > typingWithoutEcho(ConformanceProbe &probe)
Case: typing reaches onChange; a value set by the application is shown and not reported back.
std::optional< std::string > unchangedTextKeepsCursor(ConformanceProbe &probe)
Case: a setter given the text a field already shows changes nothing; in particular the cursor stays.
std::optional< std::string > firstAutofocusWins(ConformanceProbe &probe)
Case: the first autofocus widget in document order has focus once the mount completes.
std::optional< std::string > columnJustifies(ConformanceProbe &probe)
Case: a column with justify: end places its last child's end at its own end, and between its first ch...
std::optional< std::string > onlyEnabledMenuLeavesAreChosen(ConformanceProbe &probe)
Case: a menu reports only an enabled entry without a submenu, and an entry enabled later can be chose...
The owner of a signal graph: every node takes one, and every operation runs on its owner.
Definition runtime.hpp:70
decltype(auto) batch(F &&fn)
Runs fn as one batch: every write inside it is one flush.
Definition runtime.hpp:104
A source value. Reading it with get() inside a tracked run subscribes that run.
Definition signal.hpp:49
T const & get() const
Reads the value, subscribing the innermost tracked run.
Definition signal.hpp:76
void set(T value)
Replaces the value and notifies observers, unless it equals the current one and the policy is Equalit...
Definition signal.hpp:90
T const & peek() const noexcept
Reads the value without subscribing anything.
Definition signal.hpp:85
A widget with children. A factory given this container appends the new widget as its last child.
Definition backend.hpp:103
Makes widgets. Each factory appends the new widget to parent, or makes a root when it is null,...
Definition backend.hpp:810
virtual std::unique_ptr< ButtonWidget > createButton(ContainerWidget *parent)=0
Makes a push button.
virtual std::unique_ptr< DialogWidget > createDialog(ContainerWidget *parent)=0
Makes a modal overlay.
virtual std::unique_ptr< TextInputWidget > createTextInput(ContainerWidget *parent, TextInputMode mode)=0
Makes a text field.
A view tree mounted on a backend: its widgets, built once, and the bindings that keep them current.
Definition mount.hpp:1727
Widget & root() const noexcept
The root node's widget.
Definition mount.hpp:1752
A retained widget. A new widget is visible, enabled, content-sized, not draggable and not a drop targ...
Definition backend.hpp:34
How the conformance cases read and drive one backend under test.
virtual void selectRows(Widget &table, std::vector< std::size_t > const &rows)=0
Selects rows of a table as a user would, so that the user's selection is exactly these rows.
virtual std::string textOf(Widget const &widget)=0
The text a widget shows: a Text's or TextInput's text, a Button's or Checkbox's label,...
virtual void moveCursor(Widget &field, std::size_t position)=0
Moves a text field's cursor as a user would (the arrow keys, a click), without changing the text.
virtual std::vector< std::size_t > selectedRows(Widget const &table)=0
The rows a table shows as selected.
virtual std::optional< WidgetBounds > boundsOf(Widget const &widget)
Where a widget is drawn, for the layout cases (layoutConformanceCases). A backend that lays nothing o...
virtual IViewBackend & backend()=0
The backend under test.
virtual reactive::Runtime & runtime()=0
The runtime the cases' signals and mounts belong to.
virtual bool hasFocus(Widget const &widget)=0
Whether a widget has keyboard focus.
virtual void chooseMenuEntry(Widget &menu, std::vector< std::size_t > const &path)=0
Chooses a menu entry as a user would, opening each submenu on the path first.
virtual std::size_t childCount(ContainerWidget const &container)=0
How many children a container has.
virtual void settle()=0
Runs posted flushes and pending native events until nothing is left.
virtual void click(Widget &widget)=0
Activates a button as a user would.
virtual bool enabledOf(Widget const &widget)=0
Whether a widget accepts input, by its own flag: a disabled ancestor does not make this false,...
virtual void dismiss(Widget &dialog)=0
Dismisses a dialog or drawer as a user would (Esc on the TUI), or a dismissible banner.
virtual void drag(Widget &source, Widget &target)=0
Drags one widget onto another as a user would.
virtual bool visibleOf(Widget const &widget)=0
The widget's own visibility flag, as its last setVisible left it.
virtual void press(Widget &focused, std::string_view chord)=0
Presses a keyboard chord as a user would with focus on a widget.
virtual std::size_t cursorOf(Widget const &field)=0
Where a text field's cursor is.
virtual void dropFiles(Widget &zone, std::vector< std::string > const &paths)=0
Drops files from the operating system onto a drop zone, as a user would.
virtual Widget * childAt(ContainerWidget const &container, std::size_t index)=0
A container's child.
virtual void expand(Widget &section, bool open)=0
Opens or closes a collapsible section with its own control, as a user would.
virtual void type(Widget &widget, std::string_view text)=0
Replaces a text field's text as a user typing would: whatever the field held is cleared first (select...
Collects a case's checks and keeps the first failure, so a case reads top to bottom.
std::optional< std::string > result() const
The case's verdict.
void text(std::string_view what, std::string_view expected, std::string_view actual)
Checks that a text is what it should be.
void that(bool holds, std::string_view what)
Checks a condition.
Reads where the layout cases' widgets are drawn, and fails a case whose probe reads no geometry.
void near(std::string_view what, double expected, double actual)
Checks that a length is what it should be, within one unit.
Geometry(ConformanceProbe &probe, Checks &checks)
Over probe.
WidgetBounds of(Widget const &widget)
Where widget is drawn; zero, with the case failed, when the probe reads no geometry.
LifeSentinel(LifeSentinel &&)=default
A copy, for a moved handler.
~LifeSentinel()
Reports its own destruction when it belongs to the running handler.
LifeSentinel & operator=(LifeSentinel &&)=default
Copies the report target.
HandlerLife * life() const noexcept
Where it reports.
LifeSentinel & operator=(LifeSentinel const &)=default
Copies the report target.
LifeSentinel(LifeSentinel const &)=default
A copy, for another copy of the handler.
UTC timestamps for morph actions.
morph::ui::Mounted: builds a view tree's widgets through a backend once, and keeps them current throu...
morph::reactive::Runtime: the owner, batching and flush scheduling of a signal graph.
Signal<T>, Computed<T> and Effect: the nodes of a reactive graph.
A UTC instant with millisecond precision.
Definition datetime.hpp:71
Children stacked top to bottom.
Definition view.hpp:564
LayoutHints layout
The size request; set once.
Definition view.hpp:354
A widget's size request in both dimensions, and the bounds its extent is clamped into.
Definition view.hpp:308
std::optional< Align > alignSelf
Where the node sits across its row's, column's or grid row's axis; nullopt takes the container's alig...
Definition view.hpp:323
Children laid out left to right.
Definition view.hpp:578
One option of a Select.
Definition view.hpp:487
Key key
The option's identity.
Definition view.hpp:489
static constexpr Sizing stretch(int weight=1) noexcept
A share of the leftover space.
Definition view.hpp:294
static constexpr Sizing fixed(int units) noexcept
A fixed size.
Definition view.hpp:289
Everything about a table beyond its columns and rows.
Definition view.hpp:1424
Static or bound text.
Definition view.hpp:406
Common common
Visibility, enablement, layout, drag-and-drop.
Definition view.hpp:414
std::function< std::optional< std::string >(ConformanceProbe &)> run
Runs the case on a fresh probe.
std::string_view name
What the case checks; unique among the cases.
Where a widget is drawn, in its backend's own units (a cell on the terminal; Theme....
A row of the ForEach and Table cases.
std::string label
The text the row shows.
bool operator==(Entry const &) const =default
Memberwise equality.
What LifeSentinel reports about the handler that holds it.
bool freedWhileRunning
Set when the running handler's sentinel was destroyed before the handler returned.
void const * running
The sentinel inside the handler while that handler runs; null otherwise.
The container a case measures, the one child of the mounted root, and its children in order.
std::vector< Widget * > children
Its children.
Widget * container
The measured container; null when the root holds none.
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.
Node panel(Panel spec)
A titled frame, optionally collapsible.
Definition view.hpp:1244
Node collapsible(Collapsible spec)
A titled section the user opens and closes.
Definition view.hpp:1334
@ SingleLine
One line; Enter submits.
Definition view.hpp:210
Node forEach(std::function< std::vector< RowT >()> rows, std::function< Key(RowT const &)> keyOf, std::function< Node(reactive::Signal< RowT > const &)> rowView, Axis axis=Axis::Vertical, int gap=0)
One widget per row of a keyed collection read through a binding (a Query's value() included).
Definition view.hpp:1396
Node slider(Slider spec)
An integer on a range.
Definition view.hpp:1279
Node banner(Banner spec)
A message across the top of a region.
Definition view.hpp:1289
Node textInput(TextInput spec)
An editable text field.
Definition view.hpp:1204
Node column(Column spec)
Children stacked top to bottom.
Definition view.hpp:1224
Node dateTimeInput(DateTimeInput spec)
A date or date-and-time field.
Definition view.hpp:1274
Node dropZone(DropZone spec)
A region that takes dropped files.
Definition view.hpp:1339
@ Single
At most one row.
Definition view.hpp:265
@ Multiple
Any number of rows.
Definition view.hpp:266
Node menu(Menu spec)
A list of commands.
Definition view.hpp:1219
Node filePicker(FilePicker spec)
A file path field.
Definition view.hpp:1284
Node checkbox(Checkbox spec)
A labelled check box.
Definition view.hpp:1209
Node switchOf(Switch spec)
Shows the case the selector picks. Spelled switchOf because switch is a keyword.
Definition view.hpp:1254
Node text(Text spec)
Static or bound text.
Definition view.hpp:1194
Node select(Select spec)
A choice of one option.
Definition view.hpp:1214
std::shared_ptr< NodeData const > Node
A view-tree node: immutable, shared data.
Definition view.hpp:403
Node row(Row spec)
Children laid out left to right.
Definition view.hpp:1229
std::variant< std::int64_t, std::string > Key
The identity of a Switch case, a Select option, a ForEach or Table row and a drag payload.
Definition view.hpp:38
Node table(std::vector< TableColumn > columns, std::function< std::vector< RowT >()> rows, std::function< Key(RowT const &)> keyOf, std::function< std::vector< Node >(reactive::Signal< RowT > const &)> cells, TableOptions options={})
A table of keyed rows: one Row of cells per row, under the column headers.
Definition view.hpp:1464
Node tabs(Tabs spec)
A tab bar over pages.
Definition view.hpp:1259
Node dialog(Dialog spec)
A modal overlay.
Definition view.hpp:1264
Node button(Button spec)
A push button.
Definition view.hpp:1199
SlotBinding< std::decay_t< F > > slot(SlotId slotId, F &&read)
A slot with an id.
Definition view.hpp:63