From a47688c1caf399bfcfe776ad1769347d1ad25bfa Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 10 Oct 2026 17:45:05 +0300 Subject: [PATCH 1/9] docs: implementation plan for the ui view tree, mount and frontend seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The part-3 plan of the declarative UI program, written against the specs as on master. It starts from the reviewed view-tree implementation on feature/declarative-ui and lists where that code and the specs disagree: child scopes for content and rows, slots with ids, controlled inputs, setters that change nothing, and the frontend seam of spec 1 §5b. It also names three spec amendments the part needs: AppSource::close(), the palette this part carries, and host.hpp landing with hosting. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-10-ui-view-tree.md | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-10-ui-view-tree.md diff --git a/docs/superpowers/plans/2026-10-10-ui-view-tree.md b/docs/superpowers/plans/2026-10-10-ui-view-tree.md new file mode 100644 index 000000000..d9b678b97 --- /dev/null +++ b/docs/superpowers/plans/2026-10-10-ui-view-tree.md @@ -0,0 +1,315 @@ +# `morph::ui` view tree, mount and frontend seam — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan +> task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the part of `morph::ui` that needs no document: the view tree (`view.hpp`), the backend contract +(`backend.hpp`, `IViewBackend`), `Mounted` (`mount.hpp`), the frontend seam (`frontend.hpp`), and the headless +`testing::RecordingBackend` with the backend-conformance cases (`testing/recording_backend.hpp`, +`testing/backend_conformance.hpp`), header-only in the base `morph` target. + +**Architecture:** Nodes are aggregates (`ui::Text`, `ui::Column`, …) behind `ui::Node = std::shared_ptr`. A property is a `ui::Prop`: a constant, or a slot — a reactive read with an optional stable `SlotId` +that a code-generating renderer refers to. `Mounted` walks the tree once: each node gets a widget from a typed +factory, each slot becomes an equality-gated `Computed` plus an `Effect` that calls one setter, each constant is set +once. Switch, Dialog, every Tabs page and every ForEach/Table row mount into a child `reactive::Scope`, adopted +widget-first, so teardown is bindings before widgets and children before parents, and owner Effects run before the +Effects of the scopes they own. Every widget callback runs inside `Runtime::widgetEvent`, untracked; an input whose +value is a slot is controlled: after a user event the mount re-asserts the slot in a posted turn. The frontend seam +is `AppContext`, `AppSource`, `Frontend`, `FrontendOption` and `selectFrontend`; `runApp` fixes the order in which +a frontend opens the source, mounts the shell and tears both down. + +**Tech Stack:** C++23, header-only; `morph::reactive` (on `master`); `morph/util/datetime.hpp` +(`time::Timestamp`); the installed owner test kit `morph/testing/step_executor.hpp` and +`owner_probe_recorder.hpp`; Catch2 v3; Doxygen with `WARN_AS_ERROR`. + +**Spec:** `docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md` (spec 1): §2 (headers), §5 (view tree, +mount, backend contract, `RecordingBackend`), §5b (frontend seam), §7 ("ui", "frontend seam", "backend +conformance"), §9 (docs). Spec 5 (`2026-10-07-ui-document-design.md`) §9 is the node palette. Spec 3 +(`2026-10-04-qtquick-frontend-design.md`) §2, §4 and §7 fix what a slot id and a controlled widget mean for the +other renderer. Spec 6 (`2026-10-08-app-hosting-design.md`) §4 (`host.hpp`), §5 and §9 (`AppSource`, `Backend`). +Issue: #888. Program: #885. + +**Starting point.** The 2026-10-04 part-2 plan (`29ca9b1f:docs/superpowers/plans/2026-10-04-declarative-ui-tui-2-ui.md`) +predates spec 5 and is stale; it is used for structure only. Its reviewed implementation is commit `69a18881` on +`origin/feature/declarative-ui` (ten tasks, each reviewed, and a final review; ledger in +`origin/docs/declarative-ui-execution-notes:execution-notes/part-2-ui/ledger.md`). That code compiles against +`morph::reactive` as on `master` with two test-only changes, and its 134 `[ui]` cases pass there. It is taken as the +base, and the revisions below bring it to the specs as they are on `master`. Where it and the specs disagree, the +specs win, unless this plan amends the spec (§ "Spec amendments"). + +**Delivery.** Branch `feature/888-ui-view-tree` from `origin/master`; this plan is its first commit; then one commit +per task; its own pull request, `Closes #888`. + +## Global Constraints + +- C++23, header-only, every new public header in the `morph` target's `FILE_SET HEADERS` (the install + consumability check compiles every installed public header); no new CMake option, no new dependency. +- `morph::ui` depends on `morph::reactive`, `morph/core/executor.hpp` and `morph/util/datetime.hpp`; nothing under + `include/morph/ui/` includes a toolkit, a terminal library, `morph/core/bridge.hpp` or anything under + `morph/net`. +- Every file starts with `// SPDX-License-Identifier: Apache-2.0`, then `#pragma once` for headers. +- Naming (`.clang-tidy`): types `CamelCase`, functions and variables `camelBack`, private members `_camelBack`, + constants `kName`; parameter names of at least three characters (`widgetId`, not `id`). +- Headers index containers with `.at()`, never `operator[]`. +- AGENTS.md: comments and docs state what the code does now and why; no history, no issue numbers. +- Doxygen `WARN_AS_ERROR = FAIL_ON_WARNINGS`: every public symbol, `detail` included, has a brief and complete + `@param`/`@tparam`/`@return`. A concept takes no `@tparam`. +- Strict warnings (`-Weverything -Werror` on Clang), clang-tidy-diff clean. +- Misuse in this layer is reported through `reactive::detail::RuntimeCore::report` under `ui::detail::site` + names, then refused; a test that triggers a report installs `morph::testing::OwnerProbeRecorder`. +- Strings crossing the backend contract are UTF-8. `Key = std::variant`, never a + double. +- Tests are tagged `[ui]`; every test states the mutation that makes it fail, and the key ones are run. +- One local build at a time, `-j 4`, examples off (`-DMORPH_BUILD_EXAMPLES=OFF`). + +## Review Focus + +1. **Child-first remount.** A Switch case, a Tabs page, a Dialog's content and a ForEach/Table row tear down + bindings before their widget and children before their parent, and a failed content mount leaves nothing behind + (Task 1). +2. **ForEach identity.** A kept key keeps its widget and its row scope; a changed row updates in place; reorders use + the fewest `moveChild` calls (longest increasing run); a duplicate key is reported and refused; a row whose + mount throws leaves the old rows intact (Task 1). +3. **Owner-first order across mount scopes.** Content, page and row scopes are one level deeper than the scope that + owns them, and `Mounted` can be placed under an outer scope's depth, so an owner's Effect that removes content + runs before that content's bindings (Task 2). +4. **Controlled inputs.** A user event on an input whose value is a slot is re-asserted from the slot in a posted + turn after the event's flush: a refused edit snaps back, a transformed one shows the raw text for that one + turn, an accepted one costs no setter call. A constant is not a slot and is not re-asserted (Task 3). +5. **A setter called with what the widget already shows changes nothing**: a text input keeps its cursor + (`RecordingBackend` models one; a conformance case pins it) (Task 3). +6. **Frontend seam order.** `runApp` unmounts the shell, then closes the source, before it returns, whether the + loop returns or throws, and a `ready` delivered after the run ended is dropped (Task 4). +7. **`selectFrontend`** removes exactly the `--ui` arguments it used, stops at `--`, and leaves `args` untouched + when it returns an error (Task 4). + +## Revisions against the spec + +| # | Spec on `master` | `69a18881` | This plan | Task | +|---|---|---|---|---| +| R1 | spec 1 §2, §7: the test kit is installed under `morph/testing/` | Tests include `tests/owner_probe_recorder.hpp` | Include `` | 1 | +| R2 | spec 1 §3 "Widget events": a flush due inside a widget event is deferred, not reported | Tests count `site::kFlushInWidgetEvent` | Those counts are removed; the behaviour checks (the handler sees the old value, the widget survives its handler) stay | 1 | +| R3 | spec 1 §1 "Ownership order"; §5 "mount their content into a child scope" | Content, page and row scopes are depth 0 | Each is `Scope(rt, owner.depth() + 1)`; `Mounted` takes the depth of the scope it is placed under | 2 | +| R4 | spec 1 §5: every property is a constant or a slot, "a reactive value with a stable id"; spec 3 §3 `v.s` | `Prop` is a constant or an anonymous `std::function` | `ui::SlotId`; `ui::slot(id, read)` makes a slot with an id, a bare callable a slot without one; `Prop::slotId()` | 2 | +| R5 | spec 1 §5 "Input widgets are controlled"; spec 3 §7 | A user edit stays on the widget whatever the application does | Controlled inputs (Review Focus 4); `RecordingBackend` dismisses a dialog as a real one does (it closes, then the handler runs) and models a text cursor; conformance cases for both rules | 3 | +| R6 | spec 1 §5 "A setter called with the value the widget already shows changes nothing" | "may be called with an unchanged value" | Contract text, `RecordingBackend` cursor, conformance case | 3 | +| R7 | spec 1 §5b: `AppSource`, `Bundle`, `ConnectError`, `Backend`, `FrontendError`, `Frontend::run(AppSource&)`, `selectFrontend(span, std::vector&, env)` returning `std::expected` | `Application`, `ApplicationFactory`, `Frontend::run(factory)`, `selectFrontend(span, argc, argv)` throwing `FrontendSelectionError`, `runApplication` | Rewritten to §5b. `runApp(AppContext&, AppSource&, ShellFactory, loop)` keeps the old helper's job (one place fixes the teardown order), over a source instead of a factory | 4 | +| R8 | spec 1 §7 "frontend seam": `refreshEvery` on a `ManualScheduler` | Not tested here | A `Query` built over `AppContext::scheduler()` refetches when a `ManualScheduler` advances, and not while a fetch is pending | 4 | + +## Spec amendments + +Planning against the specs found three places where the part cannot follow them as written. Each is fixed in the +spec in this pull request and said on #888. + +- **S1 — spec 1 §5b: `AppSource::close()`.** §5b requires that "screens and connections are destroyed before the + runtime", and `run` builds the runtime. But the connections are made by `AppSource::open`, and the source is + `main`'s and outlives `run`, so nothing in §5b's interface lets `run` destroy them. The source gains + `virtual void close() = 0`: it releases what `open` made and drops a `ready` not yet delivered; `run` calls it + after unmounting and before the runtime goes. +- **S2 — spec 1 §5: the palette this part carries.** Spec 5 §9 is the normative palette of the document. The view + tree in this part carries its base kinds (text, button, text input, checkbox, select, menu, column, row, grid, + spacer, panel, scroll, switch, tabs, dialog, busy, forEach, table, date-time input, slider, file picker) with + the properties a widget needs to show and edit them. The further kinds (banner, badge, …, boundary), the extra + common properties (`a11y`, `testId`, `tooltip`, `keys`, `autofocus`, `surface`), the input decorations + (`readonly`, `required`, `errors`, `stale`) and the richer menu items each add a widget setter or a factory, and + join `IViewBackend` in the part whose interpreter maps them (#890) or whose renderer needs them; until then the + interpreter lowers a further kind to its fallback. §5 says so. +- **S3 — spec 1 §2: `host.hpp` lands with hosting.** Spec 6 §4's `ScreenHost` needs `ParamMap = + std::map`, where `Value` is the document's typed value (spec 5 §3), defined with the document + in #890, and its only implementers are the Qt Quick renderer (#891) and the hosting part (#893), whose tests (spec + 6 §12 "Host contract") it ships with. Landing it here would invent `Value`. §2 says `host.hpp` comes with hosting. + +Not an amendment, recorded so the next part does not rediscover it: spec 4's `main` sketch calls +`selectFrontend(built, argc, argv)` and dereferences the result; spec 1 §5b's signature (an argument vector the +call edits, and an `std::expected` result) is the normative one, and the sketch is corrected to it. + +Event sinks stay `std::function`s. Spec 3's `a.fire()` numbers sinks, and the generator (#891) is where a +sink id would be needed; adding one here would be a field nothing reads. + +## Files + +| File | Responsibility | +|---|---| +| `include/morph/ui/view.hpp` | `Action`, `Key`, `SlotId`, `slot()`, `Prop`, the enums, `Sizing`, `LayoutHints`, `Common`, every node aggregate, `NodeData`, `Node`, the builders, `switchOn`, `forEach`, `table`, `detail::ForEachModel`/`ForEachSession`/`RowSlot`/`TypedForEach` | +| `include/morph/ui/backend.hpp` | `Widget`, `ContainerWidget`, one widget interface per node kind, `IViewBackend` | +| `include/morph/ui/mount.hpp` | `detail::site`, `detail::Mounter` (bindings, controlled inputs, content/page/row scopes, keyed reconcile), `Mounted` | +| `include/morph/ui/frontend.hpp` | `Scheduler`/`TimerHandle` aliases, `AppContext`, `Bundle`, `ConnectError`, `Backend`, `AppSource`, `Frontend`, `FrontendOption`, `FrontendError`, `EnvironmentReader`, `processEnvironment`, `selectFrontend`, `MountedShell`, `ShellFactory`, `runApp` | +| `include/morph/ui/testing/recording_backend.hpp` | `testing::RecordingBackend`: fake widgets, operation log, golden dump, interaction helpers, text cursor | +| `include/morph/ui/testing/backend_conformance.hpp` | `testing::ConformanceProbe`, `ConformanceCase`, `conformanceCases()` | +| `tests/test_ui_{view,recording_backend,mount,structure,foreach,table,frontend,conformance}.cpp`, `tests/ui_echoing_backend.hpp`, `tests/ui_test_support.hpp` | The §7 tests | +| `docs/spec/ui/view_tree.md`, `backend_contract.md`, `frontend.md` | Authoritative specs | +| `CMakeLists.txt`, `tests/CMakeLists.txt`, `docs/spec/README.md`, `docs/ARCHITECTURE.md`, `CHANGELOG.md` | Registration, maps, changelog | +| `docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md`, `2026-10-04-examples-migration-design.md` | S1–S3 and the spec 4 sketch | + +## Build and test commands (used by every task) + +```bash +cmake -S . -B build/ui -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_EXAMPLES=OFF # once; prints "-Weverything verified" +cmake --build build/ui --target morph_tests -j 4 +./build/ui/tests/morph_tests "[ui]" +``` + +A mutation is applied by hand, rebuilt with the same command, run against the named test, and reverted (`git diff` +checked) before the commit. + +--- + +### Task 1: The view tree, the backend contract, `Mounted` and `RecordingBackend` (R1, R2) + +**Files:** create `view.hpp`, `backend.hpp`, `mount.hpp`, `testing/recording_backend.hpp`, +`testing/backend_conformance.hpp` from `69a18881`; the tests `test_ui_{view,recording_backend,mount,structure, +foreach,table,conformance}.cpp`, `ui_echoing_backend.hpp`, `ui_test_support.hpp`; register every header in the +`morph` FILE_SET after `reactive/control.hpp` and every test file after `test_reactive_control_bridge.cpp`. + +**Apply:** R1 (includes), R2 (drop the `kFlushInWidgetEvent` counts). `frontend.hpp` and its test wait for Task 4. + +**Tests** (already in the base, each with its mutation in the base's review record): + +| Test | Mutation | +|---|---| +| a constant never binds (no Effect, no Computed) | bind constants too: the runtime's node count grows | +| one setter call per real change | drop the Computed's equality gate (bind the binding straight into the Effect) | +| switch, tabs and dialog remount child-first (log order `destroy` child before parent, binding before widget) | in `Scope::clear`, or in `mountContent`, adopt the content's children before its widget | +| a failed case/page/dialog content mount leaves nothing behind | mount into the live scope instead of a fresh one | +| ForEach identity: kept key keeps its widget id across a refetch and a reorder; minimal `moveChild` count | remount every row on a change; or move every row in `reorderChildren` | +| int64 and string keys; `7` and `"7"` are different rows | compare keys by their text | +| duplicate key refused and reported once per snapshot | keep the later duplicate | +| a throwing row mount leaves the old rows intact | build the new row list in place | +| a hidden or disabled ancestor blocks a helper | check the widget's own flags only | + +- [ ] Steps: copy, apply R1/R2, build, `[ui]` green, run the child-first and ForEach-identity mutations, commit + `ui: view tree, backend contract, mount and RecordingBackend`. + +--- + +### Task 2: Child scopes, `Mounted`'s depth, and slots (R3, R4) + +**Files:** `view.hpp`, `mount.hpp`, `tests/test_ui_view.cpp`, `tests/test_ui_structure.cpp`. + +- `SlotId` (`std::uint32_t`); `template struct SlotBinding { SlotId id; F read; }` made by + `ui::slot(id, read)`; `Prop` holds `std::variant` with `Slot{std::optional id; + std::function read;}`; `slotId()` returns the id or `nullopt`; `binding()` keeps returning the read. +- `Mounter::mountContent`, `mountPage`, `mountRow` make `Scope(rt, owner.depth() + 1)`; Tabs' page scope and the + row list are made by `scope.child()`. `Mounted(rt, backend, root, parent = nullptr, depth = 0)`. + +**Tests:** + +| Test | Mutation | +|---|---| +| a slot keeps its id; a bare callable has none; a constant has none | drop the id in the `SlotBinding` constructor | +| an owner Effect that unmounts content runs before the content's bindings, even when the content is older: a `Mounted` at depth 1 whose Switch case binds a map entry, and an outer depth-0 Effect, made after it, that destroys the `Mounted` when the entry goes; one write removes the entry — nothing throws (no `kEffectThrew`) | make content scopes depth 0; separately, ignore `Mounted`'s depth | + +- [ ] Steps: tests first, implement, mutations, commit `ui: content scopes are child scopes, and properties are + slots with ids`. + +--- + +### Task 3: Controlled inputs, and setters that change nothing (R5, R6) + +**Files:** `mount.hpp`, `backend.hpp` (contract text), `testing/recording_backend.hpp`, +`testing/backend_conformance.hpp`, `tests/test_ui_mount.cpp`, `tests/test_ui_conformance.cpp`. + +- `Mounter::controlled(scope, prop, apply)`: for a slot, the binding as before, plus a re-assert token owned by the + scope; the event wrapper, after `runCallback` returns (after `widgetEvent`, so after the flush is posted), posts + one task to the owner that, if the token is alive, reads the slot untracked and calls the setter when the slot + differs from what the user requested. A constant returns no token: it is not a slot, and the widget keeps the + user's value, as a QML literal does. +- Applied to text input (`onChange`, `onSubmit`), checkbox, select, tabs (bar highlight), panel `collapsed`, + dialog `open` (after a dismissal), slider, date-time input, file picker and table selection. The wrapper is + installed when the slot exists even if the application gave no handler: a bound value without a handler is + read-only. +- `RecordingBackend`: `dismiss` closes the dialog (by the user, unlogged) and then calls the handler; a text input + keeps a cursor: a user edit puts it where the helper says (default: the end), `setText` with the shown text keeps + it, `setText` with another text puts it at the end; `cursor(widgetId)` reads it. +- Conformance probe gains `cursorOf` and `moveCursor`; cases: "a setter with the shown text keeps the cursor", + "a refused edit snaps back after one turn", "a transformed edit shows the raw text for one turn, then the + slot's", "a dismissed dialog the document keeps open is shown again". + +**Tests:** + +| Test | Mutation | +|---|---| +| a refused edit snaps back after `settle`, and not before the flush | skip the posted re-assert | +| an accepted edit costs no setter call | re-assert unconditionally | +| a transformed edit shows the raw text, then the transform | re-assert inside the widget event (before the flush) | +| a constant text input keeps the user's text | re-assert constants too | +| a re-assert posted for a widget that is gone does nothing (ASan clean) | hold the token strongly in the posted task | +| a checkbox, select, slider, tabs bar, panel, dialog and table selection each snap back | per kind: drop its `controlled` call | +| conformance: the setter with the shown text keeps the cursor | `FakeTextInput::setText` always moves the cursor | + +- [ ] Steps: tests first, implement, mutations, commit `ui: input widgets are controlled by their slots`. + +--- + +### Task 4: The frontend seam (R7, R8) + +**Files:** `frontend.hpp`, `tests/test_ui_frontend.cpp`; S1 in spec 1 §5b. + +```cpp +struct Bundle { std::string applicationId; std::string manifestDigest; + std::map> documents; }; // id -> verified JSON text +struct ConnectError { enum class Kind : std::uint8_t { Unreachable, NoUiService, Unsupported, Unverified, Refused }; + Kind kind; std::string message; }; +struct Backend { std::string name; }; +class AppContext { runtime(); executor(); scheduler(); ioLoop(); quit(int = 0); }; +class AppSource { open(AppContext&, std::function)> ready); + switchBackend(Backend, std::function)> done); close(); }; +class Frontend { name() const; run(AppSource&); }; +struct FrontendOption { std::string name; std::function usable; std::function()> make; }; +struct FrontendError { enum class Kind : std::uint8_t { MissingName, UnknownName, NoneUsable, NotMade }; + Kind kind; std::string requested; std::vector built; std::string message() const; }; +using EnvironmentReader = std::function(std::string_view)>; +EnvironmentReader processEnvironment(); +std::expected, FrontendError> + selectFrontend(std::span, std::vector& args, EnvironmentReader const& = processEnvironment()); +class MountedShell { virtual ~MountedShell(); }; +using ShellFactory = std::function(AppContext&, std::expected)>; +int runApp(AppContext&, AppSource&, ShellFactory const&, std::function const& loop); +``` + +- `selectFrontend`: every `--ui=` and `--ui ` before `--` is read, the last wins, and all of them are + removed on success; an argument starting with `--` is never a name; an empty final name is `MissingName`. Then a + non-empty `MORPH_UI`, then the first option whose `usable()` holds (empty counts as usable). On an error `args` + is unchanged. +- `runApp`: opens the source with a `ready` that holds the run's state weakly; `ready` mounts the shell through the + factory (a factory that throws quits the context, and `runApp` rethrows after teardown); a second `ready` is + ignored. The loop runs; then, whether it returned or threw, the shell is destroyed, then `source.close()` runs. + The frontend destroys its runtime after `runApp` returns. + +**Tests:** + +| Test | Mutation | +|---|---| +| `--ui=` beats `MORPH_UI` beats order; `--ui name`; last wins; `--` stops; unknown name lists the built ones; nothing usable; option that makes nothing | per rule: drop it | +| the `--ui` arguments are removed, the rest kept in order, and `args` is untouched on error | erase only the first; or erase before validating | +| destruction order: shell, then `close`, then the runtime, for a returning and for a throwing loop | swap the shell reset and `close()`; or skip teardown on a throw | +| a `ready` after the run ended mounts nothing | capture the state strongly | +| a source that fails to connect hands the factory the `ConnectError` | drop the error branch | +| `refreshEvery` on a `ManualScheduler` through `AppContext::scheduler()`: a tick refetches, a tick while pending does not | build the Query without the scheduler (it throws) — and, for the skip, ignore `pending()` in the reactive core | + +- [ ] Steps: tests first, implement, mutations, commit `ui: the frontend seam — AppContext, AppSource, Frontend and + selectFrontend`. + +--- + +### Task 5: Specs, maps and changelog + +- `docs/spec/ui/view_tree.md`, `backend_contract.md`, `frontend.md` from `69a18881`, updated for R3–R8 and S1–S2. +- `docs/spec/README.md` (a "Declarative UI" group), `docs/ARCHITECTURE.md` (namespace row and header map), + `CHANGELOG.md` `[Unreleased]` → `### Added`. +- Spec 1 §2, §5, §5b for S1–S3; spec 4's `main` sketch. +- Docs build with `WARN_AS_ERROR`. + +Commit `ui: specify the view tree, the backend contract and the frontend seam`. + +--- + +### Task 6: Gates + +- [ ] Full `morph_tests` on the Debug build (`[ui]` and everything else). +- [ ] Docs build (`MORPH_BUILD_DOCUMENTATION=ON`, target `doc`). +- [ ] A `-Wdocumentation` syntax check of the new headers with clang 22, if available. +- [ ] clang-format on every touched file. +- [ ] Push, open the PR: `Closes #888`, what was measured on which configuration, the mutations and their results, + inline review reasoning, spec amendments, what was not verified. From 2fb99d559685657e75f659985d4a9e6a703cbaab Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 10 Oct 2026 17:45:31 +0300 Subject: [PATCH 2/9] ui: view tree, backend contract, mount and RecordingBackend morph::ui, header-only in the base morph target. An immutable view tree of the spec 5 base palette, whose props are constants or reactive bindings; Mounted builds retained widgets through an IViewBackend once and keeps them current through equality-gated bindings, with Switch, Tabs, Dialog and keyed ForEach/Table rows mounted into scopes of their own, so teardown is bindings before widgets and children before parents, and a content mount is all or nothing. ForEach and Table keep one row per key, update kept rows in place and reorder with the fewest moveChild calls. RecordingBackend is the headless reference backend: an operation log, a golden dump and the click/edit/choose/toggle/dismiss/drag helpers, which act only on a widget a user could reach. The conformance cases fix the contract every renderer implements. This is the reviewed implementation from feature/declarative-ui, built against morph::reactive as on master: the tests take the owner probe from morph/testing/, and no longer count a report for a flush deferred inside a widget event, which the runtime now defers silently. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 5 + include/morph/ui/backend.hpp | 478 ++++++ include/morph/ui/mount.hpp | 863 +++++++++++ .../morph/ui/testing/backend_conformance.hpp | 1089 +++++++++++++ .../morph/ui/testing/recording_backend.hpp | 1369 +++++++++++++++++ include/morph/ui/view.hpp | 924 +++++++++++ tests/CMakeLists.txt | 7 + tests/test_ui_conformance.cpp | 173 +++ tests/test_ui_foreach.cpp | 636 ++++++++ tests/test_ui_mount.cpp | 521 +++++++ tests/test_ui_recording_backend.cpp | 502 ++++++ tests/test_ui_structure.cpp | 658 ++++++++ tests/test_ui_table.cpp | 425 +++++ tests/test_ui_view.cpp | 181 +++ tests/ui_echoing_backend.hpp | 147 ++ tests/ui_test_support.hpp | 26 + 16 files changed, 8004 insertions(+) create mode 100644 include/morph/ui/backend.hpp create mode 100644 include/morph/ui/mount.hpp create mode 100644 include/morph/ui/testing/backend_conformance.hpp create mode 100644 include/morph/ui/testing/recording_backend.hpp create mode 100644 include/morph/ui/view.hpp create mode 100644 tests/test_ui_conformance.cpp create mode 100644 tests/test_ui_foreach.cpp create mode 100644 tests/test_ui_mount.cpp create mode 100644 tests/test_ui_recording_backend.cpp create mode 100644 tests/test_ui_structure.cpp create mode 100644 tests/test_ui_table.cpp create mode 100644 tests/test_ui_view.cpp create mode 100644 tests/ui_echoing_backend.hpp create mode 100644 tests/ui_test_support.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 96ab05f67..80e76b194 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -424,6 +424,11 @@ target_sources(morph include/morph/reactive/scheduler.hpp include/morph/reactive/testing/manual_scheduler.hpp include/morph/reactive/control.hpp + include/morph/ui/view.hpp + include/morph/ui/backend.hpp + include/morph/ui/mount.hpp + include/morph/ui/testing/recording_backend.hpp + include/morph/ui/testing/backend_conformance.hpp include/morph/render/i18n.hpp include/morph/render/locale_format.hpp include/morph/core/file_io_ops.hpp diff --git a/include/morph/ui/backend.hpp b/include/morph/ui/backend.hpp new file mode 100644 index 000000000..38cd04904 --- /dev/null +++ b/include/morph/ui/backend.hpp @@ -0,0 +1,478 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../util/datetime.hpp" +#include "view.hpp" + +/// @file +/// @brief The backend contract: the retained widgets a mount drives, and the factory that makes them. +/// +/// Specified in `docs/spec/ui/backend_contract.md`. + +namespace morph::ui { + +/// @brief A retained widget. A new widget is visible, enabled, content-sized, not draggable and not a drop target. +/// +/// Its destructor detaches it from its parent and frees its native resources; no handler of the widget runs after +/// the destructor returns. A handler may destroy its own widget: the backend keeps the handler alive until that call +/// returns, for example by calling a copy. A backend may assume a handler, a drop's `accepts` predicate included, +/// never throws: the mount catches and reports what one throws. Every setter takes UTF-8 and may be called with an +/// unchanged value. +class Widget { +public: + Widget() = default; + virtual ~Widget() = default; + Widget(Widget const&) = delete; + Widget& operator=(Widget const&) = delete; + Widget(Widget&&) = delete; + Widget& operator=(Widget&&) = delete; + + /// @brief Shows or hides the widget and everything inside it. + /// @param visible Whether it is shown. + virtual void setVisible(bool visible) = 0; + + /// @brief Enables or disables input on the widget and everything inside it. + /// @param enabled Whether it accepts input. + virtual void setEnabled(bool enabled) = 0; + + /// @brief Sets the size request. + /// @param hints The request in both dimensions. + virtual void setLayout(LayoutHints const& hints) = 0; + + /// @brief Makes the widget draggable, carrying @p key, or not draggable. + /// @param key Engaged: the key a drop delivers. `nullopt`: not draggable. + virtual void setDragKey(std::optional const& key) = 0; + + /// @brief Makes the widget a drop target. + /// @param accepts Whether a dragged key may be dropped here; the backend asks before highlighting. + /// @param onDrop Called with the key on a drop that `accepts` allowed. + virtual void setDropHandler(std::function accepts, std::function onDrop) = 0; +}; + +/// @brief A widget with children. A factory given this container appends the new widget as its last child. +class ContainerWidget : public Widget { +public: + /// @brief Moves @p child to position @p index: it is removed, then inserted before the child now at @p index. + /// + /// The child is moved, not recreated: it keeps its native state, such as its properties, handlers, focus and + /// selection. + /// @param child One of this container's children. + /// @param index The new position; past the end means last. + virtual void moveChild(Widget& child, std::size_t index) = 0; +}; + +/// @brief Read-only text. +class TextWidget : public Widget { +public: + /// @brief Replaces the text. + /// @param text The text, UTF-8. + virtual void setText(std::string_view text) = 0; + + /// @brief Sets the style. + /// @param role The role, mapped to the backend's theme. + virtual void setRole(TextRole role) = 0; +}; + +/// @brief A push button. +class ButtonWidget : public Widget { +public: + /// @brief Replaces the caption. + /// @param label The caption, UTF-8. + virtual void setLabel(std::string_view label) = 0; + + /// @brief Sets what activation calls (Enter, Space or a click). + /// @param onClick The handler; empty does nothing. + virtual void setOnClick(Action onClick) = 0; +}; + +/// @brief An editable text field. +class TextInputWidget : public Widget { +public: + /// @brief Replaces the text. Never calls the `onChange` handler. + /// @param text The text, UTF-8. + virtual void setText(std::string_view text) = 0; + + /// @brief Sets the text shown while the field is empty. + /// @param placeholder The text, UTF-8. + virtual void setPlaceholder(std::string_view placeholder) = 0; + + /// @brief Sets what a user edit calls, with the whole new text. + /// @param onChange The handler; empty does nothing. + virtual void setOnChange(std::function onChange) = 0; + + /// @brief Sets what a submit (Enter in single-line mode) calls, with the text. + /// @param onSubmit The handler; empty does nothing. + virtual void setOnSubmit(std::function onSubmit) = 0; +}; + +/// @brief A labelled check box. +class CheckboxWidget : public Widget { +public: + /// @brief Replaces the caption. + /// @param label The caption, UTF-8. + virtual void setLabel(std::string_view label) = 0; + + /// @brief Sets the state. Never calls the `onToggle` handler. + /// @param checked Whether it is checked. + virtual void setChecked(bool checked) = 0; + + /// @brief Sets what a user toggle calls, with the new state. + /// @param onToggle The handler; empty does nothing. + virtual void setOnToggle(std::function onToggle) = 0; +}; + +/// @brief A choice of one keyed option. +/// +/// The widget keeps the key last requested by `setSelected` or chosen by the user, and marks the option with that key +/// whenever the current options contain one. A key outside the options marks none, and stays requested: options that +/// bring it back mark it again. Options and selection may therefore arrive in either order. +class SelectWidget : public Widget { +public: + /// @brief Replaces the options, and marks the requested key's option if they contain it. + /// @param options The options, in order. + virtual void setOptions(std::vector const& options) = 0; + + /// @brief Requests the option with @p key. It is marked while the options contain it; otherwise none is marked. + /// Never calls `onSelect`. + /// @param key The key, or `nullopt` for no selection. + virtual void setSelected(std::optional const& key) = 0; + + /// @brief Sets what a user choice calls, with the option's key. + /// @param onSelect The handler; empty does nothing. + virtual void setOnSelect(std::function onSelect) = 0; +}; + +/// @brief A vertical list of commands. +class MenuWidget : public Widget { +public: + /// @brief Replaces the entries. + /// @param labels One caption per entry, in order. + virtual void setItems(std::vector const& labels) = 0; + + /// @brief Sets what choosing an entry calls, with its index. + /// @param onActivate The handler; empty does nothing. + virtual void setOnActivate(std::function onActivate) = 0; +}; + +/// @brief Children stacked along one axis, fixed at creation. +class StackWidget : public ContainerWidget { +public: + /// @brief Sets the space between children. + /// @param gap Backend units. + virtual void setGap(int gap) = 0; +}; + +/// @brief Children laid out row-major in columns. +class GridWidget : public ContainerWidget { +public: + /// @brief Sets the column count. + /// @param columns At least one. + virtual void setColumns(int columns) = 0; + + /// @brief Sets the space between cells. + /// @param gap Backend units. + virtual void setGap(int gap) = 0; + + /// @brief Sets how many columns a child spans; a child spans one until this is called. + /// @param child One of this grid's children. + /// @param span The column count it occupies. + virtual void setSpan(Widget& child, int span) = 0; +}; + +/// @brief Empty space. +class SpacerWidget : public Widget {}; + +/// @brief A titled frame around its children, optionally collapsible. +class PanelWidget : public ContainerWidget { +public: + /// @brief Replaces the title. + /// @param title The title, UTF-8. + virtual void setTitle(std::string_view title) = 0; + + /// @brief Sets the space between frame and content. + /// @param padding Backend units. + virtual void setPadding(int padding) = 0; + + /// @brief Sets whether the user can collapse the panel. + /// @param collapsible Whether a collapse control is shown. + virtual void setCollapsible(bool collapsible) = 0; + + /// @brief Collapses or expands the panel. A collapsed panel hides its content, which takes no input; its own + /// collapse control still works. Never calls the `onToggle` handler. + /// @param collapsed Whether the content is hidden. + virtual void setCollapsed(bool collapsed) = 0; + + /// @brief Sets what a user collapse or expand calls, with the requested state. + /// @param onToggle The handler; empty does nothing. + virtual void setOnToggle(std::function onToggle) = 0; +}; + +/// @brief A scrollable viewport, on the axis fixed at creation, that keeps the focused child visible. +class ScrollWidget : public ContainerWidget {}; + +/// @brief A container that shows its children and nothing else: the host of a Switch's case and of a Tabs page. +class SlotWidget : public ContainerWidget {}; + +/// @brief A tab bar over page slots. +/// +/// Its children are page slots in the order they were first shown; the mount keeps exactly the selected page's +/// slot visible. `setSelected` moves the bar's highlight only. +class TabsWidget : public ContainerWidget { +public: + /// @brief Replaces the tab captions. + /// @param labels One caption per tab, in order. + virtual void setTabs(std::vector const& labels) = 0; + + /// @brief Highlights a tab. Never calls the `onSelect` handler. + /// @param index The tab; past the last one highlights none. + virtual void setSelected(std::size_t index) = 0; + + /// @brief Sets what a user's tab choice calls, with its index. + /// @param onSelect The handler; empty does nothing. + virtual void setOnSelect(std::function onSelect) = 0; +}; + +/// @brief A modal overlay around its children, with a focus trap while open. A new dialog is closed. +/// +/// A closed dialog shows nothing and takes no input, its children included. +class DialogWidget : public ContainerWidget { +public: + /// @brief Shows or hides the overlay. + /// @param open Whether it is shown. + virtual void setOpen(bool open) = 0; + + /// @brief Replaces the title. + /// @param title The title, UTF-8. + virtual void setTitle(std::string_view title) = 0; + + /// @brief Sets what a user dismissal (Esc on the TUI) of the open dialog calls. + /// @param onDismiss The handler; empty does nothing. + virtual void setOnDismiss(Action onDismiss) = 0; +}; + +/// @brief A progress indicator. +class BusyWidget : public Widget { +public: + /// @brief Starts or stops the animation. + /// @param active Whether it animates. + virtual void setActive(bool active) = 0; + + /// @brief Replaces the caption. + /// @param label The caption, UTF-8. + virtual void setLabel(std::string_view label) = 0; +}; + +/// @brief Keyed rows under column headers, with selection and activation by key. +/// +/// Its children are the rows, each a horizontal stack whose children are the cells in column order. A hidden cell +/// keeps its column: the cells after it stay under their own headers. +/// +/// Selection is kept by key. The widget keeps the keys last requested by `setSelection` or selected by the user, and +/// marks the current rows that have one of them: a key with no row marks nothing and stays requested, so a row that +/// arrives with it later is marked, a row that goes is unmarked, and a reorder keeps the marks. Rows and selection +/// may therefore arrive in either order. The selection mode limits what the user can select, not what `setSelection` +/// marks. +/// +/// A user's selection replaces the requested keys, and the widget shows it whether or not the application binds the +/// selection. `onSelectionChange` then gets exactly the keys of the existing rows now selected, in any mode: a key +/// still waiting for its row is dropped by a user's change, while `setSelection` keeps it waiting. It reports the +/// user's selections only: no setter, and no row arriving or going, calls it. +class TableWidget : public ContainerWidget { +public: + /// @brief Sets the column headers and widths. + /// @param columns The columns, in order. + virtual void setColumns(std::vector const& columns) = 0; + + /// @brief Sets how many rows the user can select. + /// @param mode None, one, or any number. + virtual void setSelectionMode(SelectionMode mode) = 0; + + /// @brief Tells the table which key a row stands for; called once per row, right after it is built. Marks the row + /// when its key is requested. + /// @param row One of this table's rows. + /// @param key The row's key. + virtual void setRowKey(Widget& row, Key const& key) = 0; + + /// @brief Requests the rows with these keys, and marks those it has. Never calls the `onSelectionChange` handler. + /// @param keys The selected keys. + virtual void setSelection(std::vector const& keys) = 0; + + /// @brief Sets what a user's selection change calls, with the keys of every row then selected. + /// @param onSelectionChange The handler; empty does nothing. + virtual void setOnSelectionChange(std::function)> onSelectionChange) = 0; + + /// @brief Sets what activating a row (Enter or a double click) calls, with its key. + /// @param onActivate The handler; empty does nothing. + virtual void setOnActivate(std::function onActivate) = 0; +}; + +/// @brief A date or date-and-time field, in the mode and display zone fixed at creation. +class DateTimeInputWidget : public Widget { +public: + /// @brief Shows an instant. Never calls the `onChange` handler. + /// @param value The instant; `nullopt` or an empty `Timestamp` shows an empty field. + virtual void setValue(std::optional const& value) = 0; + + /// @brief Sets what a user edit calls, with the instant entered or `nullopt` for a cleared field. + /// @param onChange The handler; empty does nothing. + virtual void setOnChange(std::function)> onChange) = 0; +}; + +/// @brief An integer on a range. +class SliderWidget : public Widget { +public: + /// @brief Sets the range and the increment. + /// @param minimum The smallest value. + /// @param maximum The largest value. + /// @param step The increment between neighbouring values. + virtual void setRange(std::int64_t minimum, std::int64_t maximum, std::int64_t step) = 0; + + /// @brief Shows a value. Never calls the `onChange` handler. + /// @param value The value. + virtual void setValue(std::int64_t value) = 0; + + /// @brief Sets what a user move calls, with the new value. + /// @param onChange The handler; empty does nothing. + virtual void setOnChange(std::function onChange) = 0; +}; + +/// @brief A file path field, in the mode fixed at creation. +class FilePickerWidget : public Widget { +public: + /// @brief Shows a path. Never calls the `onPicked` handler. + /// @param path The path, UTF-8. + virtual void setPath(std::string_view path) = 0; + + /// @brief Sets what a user pick calls, with the path. + /// @param onPicked The handler; empty does nothing. + virtual void setOnPicked(std::function onPicked) = 0; +}; + +/// @brief Makes widgets. Each factory appends the new widget to @p parent, or makes a root when it is null, and never +/// returns null. +class IViewBackend { +public: + IViewBackend() = default; + virtual ~IViewBackend() = default; + IViewBackend(IViewBackend const&) = delete; + IViewBackend& operator=(IViewBackend const&) = delete; + IViewBackend(IViewBackend&&) = delete; + IViewBackend& operator=(IViewBackend&&) = delete; + + /// @brief Makes read-only text. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createText(ContainerWidget* parent) = 0; + + /// @brief Makes a push button. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createButton(ContainerWidget* parent) = 0; + + /// @brief Makes a text field. + /// @param parent The container to append to, or null for a root. + /// @param mode Single-line, multiline or password. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createTextInput(ContainerWidget* parent, + TextInputMode mode) = 0; + + /// @brief Makes a check box. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createCheckbox(ContainerWidget* parent) = 0; + + /// @brief Makes a choice of one option. + /// @param parent The container to append to, or null for a root. + /// @param style Dropdown or radio. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createSelect(ContainerWidget* parent, SelectStyle style) = 0; + + /// @brief Makes a command list. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createMenu(ContainerWidget* parent) = 0; + + /// @brief Makes a stack. + /// @param parent The container to append to, or null for a root. + /// @param axis Vertical (a column) or horizontal (a row). + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createStack(ContainerWidget* parent, Axis axis) = 0; + + /// @brief Makes a grid. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createGrid(ContainerWidget* parent) = 0; + + /// @brief Makes empty space. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createSpacer(ContainerWidget* parent) = 0; + + /// @brief Makes a titled frame. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createPanel(ContainerWidget* parent) = 0; + + /// @brief Makes a scroll area. + /// @param parent The container to append to, or null for a root. + /// @param axis The scroll direction. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createScroll(ContainerWidget* parent, Axis axis) = 0; + + /// @brief Makes a slot. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createSlot(ContainerWidget* parent) = 0; + + /// @brief Makes a tab bar. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createTabs(ContainerWidget* parent) = 0; + + /// @brief Makes a modal overlay. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createDialog(ContainerWidget* parent) = 0; + + /// @brief Makes a progress indicator. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createBusy(ContainerWidget* parent) = 0; + + /// @brief Makes a table. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createTable(ContainerWidget* parent) = 0; + + /// @brief Makes a date or date-and-time field. + /// @param parent The container to append to, or null for a root. + /// @param mode Date, or date and time. + /// @param offsetMinutes The display zone's offset from UTC. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createDateTimeInput(ContainerWidget* parent, + DateMode mode, + int offsetMinutes) = 0; + + /// @brief Makes a slider. + /// @param parent The container to append to, or null for a root. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createSlider(ContainerWidget* parent) = 0; + + /// @brief Makes a file path field. + /// @param parent The container to append to, or null for a root. + /// @param mode Open or save. + /// @return The widget. + [[nodiscard]] virtual std::unique_ptr createFilePicker(ContainerWidget* parent, + FilePickerMode mode) = 0; +}; + +} // namespace morph::ui diff --git a/include/morph/ui/mount.hpp b/include/morph/ui/mount.hpp new file mode 100644 index 000000000..49c48c2fd --- /dev/null +++ b/include/morph/ui/mount.hpp @@ -0,0 +1,863 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../attributes.hpp" +#include "../reactive/runtime.hpp" +#include "../reactive/scope.hpp" +#include "../reactive/signal.hpp" +#include "backend.hpp" +#include "view.hpp" + +/// @file +/// @brief `morph::ui::Mounted`: builds a view tree's widgets through a backend once, and keeps them current through +/// reactive bindings. +/// +/// Specified in `docs/spec/ui/view_tree.md`, "Mount". + +namespace morph::ui { + +namespace detail { + +/// @brief The site names a misuse in this layer is reported under, through the runtime's owner probe. +namespace site { +/// @brief An exception escaped a widget callback; the mount caught it, so it never reaches the backend. +inline constexpr char const* kCallbackThrew = "morph::ui: an exception escaped a widget callback"; +/// @brief A ForEach or Table snapshot repeated a key; the later row is refused and the first one kept. +inline constexpr char const* kDuplicateKey = "morph::ui: duplicate ForEach key"; +} // namespace site + +/// @brief Called for each newly mounted row with its widget and key; a Table tells its widget the row's key. +using RowHook = std::function; + +/// @brief One mounted row of a ForEach or Table. +struct KeyedRow { + /// @brief The row's key. + Key key; + /// @brief Owns the row's slot (adopted first) and its widgets and bindings. + std::unique_ptr scope; + /// @brief The row's slot, owned by `scope`. + RowSlot* slot = nullptr; + /// @brief The row's widget, owned by `scope`; null when the row view was a null node. + Widget* widget = nullptr; +}; + +/// @brief The mounted rows of one ForEach or Table, in their container's order. +struct KeyedRows { + KeyedRows() = default; + /// @brief Unmounts the rows last first, as a container's children are; a vector's own destruction order differs + /// between standard libraries. + ~KeyedRows() { + while (!list.empty()) { + list.pop_back(); + } + } + KeyedRows(KeyedRows const&) = delete; + KeyedRows& operator=(KeyedRows const&) = delete; + KeyedRows(KeyedRows&&) = delete; + KeyedRows& operator=(KeyedRows&&) = delete; + + /// @brief The rows; those with a widget are in the container's order. + std::vector list; +}; + +/// @brief Which entries of a permutation lie on one longest increasing run. +/// +/// @p sequence holds, in current order, each widget's target position; the widgets on the run are already in +/// order relative to each other and stay where they are, and every other one is moved once. +/// @param sequence A permutation of `0 .. n-1`. +/// @return Indexed by target position: whether that widget stays. +[[nodiscard]] inline std::vector stableTargets(std::vector const& sequence) { + constexpr auto kNone = static_cast(-1); + std::vector tails; // tails.at(n - 1): where the smallest tail of a run of length n sits + std::vector previous(sequence.size(), kNone); + for (std::size_t i = 0; i < sequence.size(); ++i) { + auto const place = std::ranges::lower_bound(tails, sequence.at(i), {}, + [&sequence](std::size_t entry) { return sequence.at(entry); }); + auto const length = static_cast(place - tails.begin()); + if (length > 0) { + previous.at(i) = tails.at(length - 1); + } + if (length == tails.size()) { + tails.push_back(i); + } else { + tails.at(length) = i; + } + } + std::vector stable(sequence.size(), false); + for (std::size_t cursor = tails.empty() ? kNone : tails.back(); cursor != kNone; cursor = previous.at(cursor)) { + stable.at(sequence.at(cursor)) = true; + } + return stable; +} + +/// @brief Brings a container's children from their current order into the target order with `moveChild`, +/// moving only the children outside a longest run already in order. +/// @param container The container. +/// @param current Its children, in their current order. Kept equal to the container's order after every move, so +/// it is still that order when a move throws. +/// @param target The same children, in the order wanted. +inline void reorderChildren(ContainerWidget& container, std::vector& current, + std::vector const& target) { + std::unordered_map targetOf; + for (std::size_t i = 0; i < target.size(); ++i) { + targetOf.emplace(target.at(i), i); + } + std::vector sequence; + sequence.reserve(current.size()); + for (Widget const* const child : current) { + sequence.push_back(targetOf.at(child)); + } + std::vector const stable = stableTargets(sequence); + auto const indexOf = [¤t](Widget const* child) { + return static_cast(std::ranges::find(current, child) - current.begin()); + }; + // From the back: each moved child goes directly before its successor in the target order, which is already + // in place, so one move per unstable child suffices. + for (std::size_t position = target.size(); position-- > 0;) { + if (stable.at(position)) { + continue; + } + Widget* const moving = target.at(position); + std::size_t const from = indexOf(moving); + std::size_t destination = current.size() - 1; + if (position + 1 < target.size()) { + std::size_t const before = indexOf(target.at(position + 1)); + destination = from < before ? before - 1 : before; + } + if (destination == from) { + continue; + } + container.moveChild(*moving, destination); + current.erase(current.begin() + static_cast(from)); + current.insert(current.begin() + static_cast(destination), moving); + } +} + +/// @brief Where one Tabs keeps its pages: a slot per tab, made the first time the tab is selected and kept, hidden, +/// after. +struct Pages { + /// @brief Each page's slot; null until the tab is first selected. + std::vector slots; + /// @brief The page shown now, if any; may be past the last page, and then none is shown. + std::optional shown; + /// @brief The Tabs' `onSelect`, run as a widget callback; empty when the Tabs has none. + std::function onSelect; +}; + +/// @brief Builds widgets for nodes and wires their props and callbacks. +/// +/// One per `Mounted`. It must outlive every scope it mounted into: a Switch, Tabs, Dialog, ForEach or Table binding +/// calls back into it to mount content later. +class Mounter { +public: + /// @param runtime The runtime bindings are made in. Borrowed. + /// @param backend Makes the widgets. Borrowed. + Mounter(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, IViewBackend& backend MORPH_LIFETIMEBOUND) noexcept + : _rt{&runtime}, _backend{&backend} {} + + /// @brief Mounts @p node under @p parent; @p scope owns its widgets and bindings. + /// + /// A node's widget is adopted first, then its bindings, then its children, so destroying the scope destroys + /// bindings before their widget and children before their parent. + /// @param scope Owns everything mounted. + /// @param node The node; null mounts nothing. + /// @param parent The container to append to, or null for a root. + /// @return The node's widget, or null for a null node. + Widget* mount(reactive::Scope& scope, Node const& node, ContainerWidget* parent) { + if (!node) { + return nullptr; + } + return std::visit([&](auto const& spec) -> Widget* { return &this->mountKind(scope, spec, parent); }, + node->kind); + } + +private: + // A constant is applied once; a binding becomes an equality-gated Computed plus an Effect, made in that order + // after the widget, so the Effect dies first. Returns the Computed, or null for a constant. + template + reactive::Computed* bind(reactive::Scope& scope, Prop const& prop, Apply apply) { + if (!prop.isBound()) { + apply(prop.constant()); + return nullptr; + } + auto& value = scope.make>(*_rt, prop.binding()); + scope.effect([&value, apply = std::move(apply)] { apply(value.get()); }); + return &value; + } + + // Runs a widget callback, and returns whether it completed. + // - Inside widgetEvent: one batch, and a flush that would start meanwhile is re-posted, so a remount never + // destroys a widget whose native handler is on the stack. + // - Untracked: a backend may call a handler from inside a setter, and so from inside the binding Effect that + // called it; that Effect must not subscribe to what the handler reads. + // - An exception is reported and stops here: the backend that called the handler need not be exception-safe. + // Writes made before it still flush when the batch closes. + template + static bool runCallback(reactive::Runtime& runtime, F const& body) { + return runtime.widgetEvent([&] { + return runtime.untracked([&] { + try { + body(); + return true; + } catch (...) { + runtime.core()->report(site::kCallbackThrew); + return false; + } + }); + }); + } + + template + [[nodiscard]] std::function event(std::function const& handler) const { + if (!handler) { + return {}; + } + // The closure owns a copy of the handler and frees it when it dies; the analyzer loses the copy's heap block + // in libc++'s std::function and reports it as leaked. + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) + return [runtime = _rt, handler](Args... args) { + static_cast(runCallback(*runtime, [&] { handler(std::move(args)...); })); + }; + } + + void applyCommon(reactive::Scope& scope, Widget& widget, Common const& common) { + if (common.visible.isBound() || !common.visible.constant()) { + bind(scope, common.visible, [&widget](bool shown) { widget.setVisible(shown); }); + } + if (common.enabled.isBound() || !common.enabled.constant()) { + bind(scope, common.enabled, [&widget](bool enabled) { widget.setEnabled(enabled); }); + } + if (common.layout != LayoutHints{}) { + widget.setLayout(common.layout); + } + if (common.dragKey.isBound() || common.dragKey.constant().has_value()) { + bind(scope, common.dragKey, [&widget](std::optional const& key) { widget.setDragKey(key); }); + } + if (common.onDrop) { + // No predicate accepts every key; a predicate that throws refuses the drop. + std::function accepts = [](Key const&) { return true; }; + if (common.accepts) { + accepts = [runtime = _rt, predicate = common.accepts](Key const& key) { + bool accepted = false; + static_cast(runCallback(*runtime, [&] { accepted = predicate(key); })); + return accepted; + }; + } + widget.setDropHandler(std::move(accepts), event(common.onDrop)); + } + } + + void mountChildren(reactive::Scope& scope, std::vector const& children, ContainerWidget& container) { + for (Node const& child : children) { + static_cast(mount(scope, child, &container)); + } + } + + Widget& mountStack(reactive::Scope& scope, Axis axis, std::vector const& children, int gap, + Common const& common, ContainerWidget* parent) { + StackWidget& widget = scope.adopt(_backend->createStack(parent, axis)); + applyCommon(scope, widget, common); + widget.setGap(gap); + mountChildren(scope, children, widget); + return widget; + } + + [[nodiscard]] static std::vector labelsOf(std::vector const& items) { + std::vector labels; + labels.reserve(items.size()); + for (MenuItem const& item : items) { + labels.push_back(item.label.evaluate()); + } + return labels; + } + + Widget& mountKind(reactive::Scope& scope, Text const& spec, ContainerWidget* parent) { + TextWidget& widget = scope.adopt(_backend->createText(parent)); + applyCommon(scope, widget, spec.common); + bind(scope, spec.text, [&widget](std::string const& text) { widget.setText(text); }); + bind(scope, spec.role, [&widget](TextRole role) { widget.setRole(role); }); + return widget; + } + + Widget& mountKind(reactive::Scope& scope, Button const& spec, ContainerWidget* parent) { + ButtonWidget& widget = scope.adopt(_backend->createButton(parent)); + applyCommon(scope, widget, spec.common); + bind(scope, spec.label, [&widget](std::string const& label) { widget.setLabel(label); }); + widget.setOnClick(event(spec.onClick)); + return widget; + } + + Widget& mountKind(reactive::Scope& scope, TextInput const& spec, ContainerWidget* parent) { + TextInputWidget& widget = scope.adopt(_backend->createTextInput(parent, spec.mode)); + applyCommon(scope, widget, spec.common); + bind(scope, spec.value, [&widget](std::string const& value) { widget.setText(value); }); + bind(scope, spec.placeholder, [&widget](std::string const& text) { widget.setPlaceholder(text); }); + widget.setOnChange(event(spec.onChange)); + widget.setOnSubmit(event(spec.onSubmit)); + return widget; + } + + Widget& mountKind(reactive::Scope& scope, Checkbox const& spec, ContainerWidget* parent) { + CheckboxWidget& widget = scope.adopt(_backend->createCheckbox(parent)); + applyCommon(scope, widget, spec.common); + bind(scope, spec.label, [&widget](std::string const& label) { widget.setLabel(label); }); + bind(scope, spec.checked, [&widget](bool checked) { widget.setChecked(checked); }); + widget.setOnToggle(event(spec.onToggle)); + return widget; + } + + Widget& mountKind(reactive::Scope& scope, Select const& spec, ContainerWidget* parent) { + SelectWidget& widget = scope.adopt(_backend->createSelect(parent, spec.style)); + applyCommon(scope, widget, spec.common); + bind(scope, spec.options, [&widget](std::vector const& options) { widget.setOptions(options); }); + bind(scope, spec.selected, [&widget](std::optional const& key) { widget.setSelected(key); }); + widget.setOnSelect(event(spec.onSelect)); + return widget; + } + + Widget& mountKind(reactive::Scope& scope, Menu const& spec, ContainerWidget* parent) { + MenuWidget& widget = scope.adopt(_backend->createMenu(parent)); + applyCommon(scope, widget, spec.common); + std::vector const& items = spec.items; + bool const anyBound = std::ranges::any_of(items, [](MenuItem const& item) { return item.label.isBound(); }); + // One prop for the whole list: a bound label re-sends every label, which keeps setItems the one setter. + Prop> const labels = + anyBound ? Prop>([items = items] { return labelsOf(items); }) + : Prop>(labelsOf(items)); + bind(scope, labels, [&widget](std::vector const& texts) { widget.setItems(texts); }); + std::vector actions; + actions.reserve(items.size()); + for (MenuItem const& item : items) { + actions.push_back(item.onSelect); + } + widget.setOnActivate(event(std::function{[actions = std::move(actions)](std::size_t index) { + if (index < actions.size() && actions.at(index)) { + actions.at(index)(); + } + }})); + return widget; + } + + Widget& mountKind(reactive::Scope& scope, Column const& spec, ContainerWidget* parent) { + return mountStack(scope, Axis::Vertical, spec.children, spec.gap, spec.common, parent); + } + + Widget& mountKind(reactive::Scope& scope, Row const& spec, ContainerWidget* parent) { + return mountStack(scope, Axis::Horizontal, spec.children, spec.gap, spec.common, parent); + } + + Widget& mountKind(reactive::Scope& scope, Grid const& spec, ContainerWidget* parent) { + GridWidget& widget = scope.adopt(_backend->createGrid(parent)); + applyCommon(scope, widget, spec.common); + widget.setColumns(spec.columns); + widget.setGap(spec.gap); + for (GridCell const& cell : spec.cells) { + Widget* const child = mount(scope, cell.node, &widget); + if (child != nullptr && cell.span != 1) { + widget.setSpan(*child, cell.span); + } + } + return widget; + } + + Widget& mountKind(reactive::Scope& scope, Spacer const& spec, ContainerWidget* parent) { + SpacerWidget& widget = scope.adopt(_backend->createSpacer(parent)); + applyCommon(scope, widget, spec.common); + return widget; + } + + Widget& mountKind(reactive::Scope& scope, Panel const& spec, ContainerWidget* parent) { + PanelWidget& widget = scope.adopt(_backend->createPanel(parent)); + applyCommon(scope, widget, spec.common); + bind(scope, spec.title, [&widget](std::string const& title) { widget.setTitle(title); }); + widget.setPadding(spec.padding); + widget.setCollapsible(spec.collapsible); + if (spec.collapsible) { + bind(scope, spec.collapsed, [&widget](bool collapsed) { widget.setCollapsed(collapsed); }); + widget.setOnToggle(event(spec.onToggle)); + } + static_cast(mount(scope, spec.child, &widget)); + return widget; + } + + Widget& mountKind(reactive::Scope& scope, Scroll const& spec, ContainerWidget* parent) { + ScrollWidget& widget = scope.adopt(_backend->createScroll(parent, spec.axis)); + applyCommon(scope, widget, spec.common); + static_cast(mount(scope, spec.child, &widget)); + return widget; + } + + // Content a binding mounts (a Switch case, Dialog content), all or nothing: it is built in a scope of its own, + // which the caller takes only once the mount completed, so content that throws partway leaves nothing behind. + // Built untracked, so the binding's Effect depends on its own prop alone. + [[nodiscard]] std::unique_ptr mountContent(Node const& node, ContainerWidget& host) { + auto content = std::make_unique(*_rt); + _rt->untracked([&] { static_cast(mount(*content, node, &host)); }); + return content; + } + + // reset() tears the old case down, newest first, before the new one is built. The Computed behind a bound + // selector is equality-gated, so an unchanged key never gets here. A case that fails to mount leaves nothing for + // its key; the next change of the key mounts again. + Widget& mountKind(reactive::Scope& scope, Switch const& spec, ContainerWidget* parent) { + SlotWidget& widget = scope.adopt(_backend->createSlot(parent)); + applyCommon(scope, widget, spec.common); + auto& content = scope.make>(); + bind(scope, spec.selector, + [this, &widget, &content, cases = spec.cases, fallback = spec.fallback](Key const& key) { + content.reset(); + auto const chosen = std::ranges::find(cases, key, &SwitchCase::key); + Node const& node = chosen == cases.end() ? fallback : chosen->node; + if (node) { + content = mountContent(node, widget); + } + }); + return widget; + } + + // Every page is owned by `pageScope`, made before the binding, so pages are destroyed newest first, after the + // binding that shows them and before the tab bar. A scope fixes that order; a vector's element destruction order + // differs between standard libraries. + // + // A bound index is not equality-gated: the Effect reads the binding itself, not a Computed. After a failed page, + // the application's onSelect writes the index back inside this Effect's own run; a Computed would keep the failed + // index and find the user's next pick of that tab unchanged, while the Effect's dependency on the binding's own + // signals sees it as the change it is. The cost is that showPage also runs when something the binding reads + // changes but the index does not: for a page that is mounted it only re-sends the highlight, and for a page that + // failed it tries the mount again, once per run. + Widget& mountKind(reactive::Scope& scope, Tabs const& spec, ContainerWidget* parent) { + TabsWidget& widget = scope.adopt(_backend->createTabs(parent)); + applyCommon(scope, widget, spec.common); + std::vector labels; + labels.reserve(spec.tabs.size()); + for (Tab const& tab : spec.tabs) { + labels.push_back(tab.label); + } + widget.setTabs(labels); + widget.setOnSelect(event(spec.onSelect)); + auto& pageScope = scope.make(*_rt); + auto& pages = scope.make(); + pages.slots.resize(spec.tabs.size(), nullptr); + pages.onSelect = event(spec.onSelect); + auto show = [this, &widget, &pageScope, &pages, pageSpecs = spec.tabs](std::size_t index) { + _rt->untracked([&] { showPage(widget, pageScope, pages, pageSpecs, index); }); + }; + if (!spec.selected.isBound()) { + show(spec.selected.constant()); + } else { + scope.effect([binding = spec.selected.binding(), show = std::move(show)] { show(binding()); }); + } + return widget; + } + + // A page is mounted the first time its tab is selected and only hidden afterwards, so its widgets keep their + // state (focus, scroll position, half-typed text) across tab switches. A new page is complete before anything + // else changes: one that fails to mount leaves the previous page shown and selected, and the bar's highlight, + // which the user's pick may already have moved, goes back to it. The next change of the index mounts again. + void showPage(TabsWidget& widget, reactive::Scope& pageScope, Pages& pages, std::vector const& pageSpecs, + std::size_t index) { + bool const exists = index < pageSpecs.size(); + bool const isNew = exists && pages.slots.at(index) == nullptr; + if (isNew) { + try { + mountPage(widget, pageScope, pages, pageSpecs.at(index).node, index); + } catch (...) { + if (pages.shown.has_value()) { + restoreSelection(widget, pages, *pages.shown); + } + throw; + } + } + if (pages.shown != index) { + if (pages.shown.has_value() && *pages.shown < pages.slots.size()) { + if (SlotWidget* const previous = pages.slots.at(*pages.shown); previous != nullptr) { + previous->setVisible(false); + } + } + if (exists && !isNew) { + pages.slots.at(index)->setVisible(true); + } + } + pages.shown = index; + widget.setSelected(index); + } + + // After a page failed to mount: puts the bar's highlight back on the page still shown, and tells the application + // through `onSelect`, so its index matches the widget and selecting the failed tab again is a change that mounts + // again. + // + // onSelect runs last, from a copy: it is a widget callback, which may destroy the Mounted and with it `pages`, + // the widget and the Effect that called this. Nothing after it touches any of them; the caller only rethrows. + // + // Nothing here may replace the failure being reported: the callback's wrapper reports what the callback throws, + // and anything else thrown here is dropped. + static void restoreSelection(TabsWidget& widget, Pages const& pages, std::size_t shown) noexcept { + try { + widget.setSelected(shown); + } catch (...) { // NOLINT(bugprone-empty-catch) + // The page's failure is the one reported; the bar keeps whatever highlight it had. + } + try { + std::function const onSelect = pages.onSelect; + if (onSelect) { + onSelect(shown); + } + } catch (...) { // NOLINT(bugprone-empty-catch) + // Only copying the wrapper can throw here; the application then keeps the failed index. + } + } + + // The page is built in a scope of its own and handed to `pageScope` once complete, so one that fails to mount + // leaves nothing behind. + void mountPage(TabsWidget& widget, reactive::Scope& pageScope, Pages& pages, Node const& node, std::size_t index) { + auto page = std::make_unique(*_rt); + SlotWidget& slot = page->adopt(_backend->createSlot(&widget)); + static_cast(mount(*page, node, &slot)); + pageScope.adopt(std::move(page)); + pages.slots.at(index) = &slot; + } + + // The content is built before the overlay opens and torn down before it closes. Content that fails to mount + // under a bound `open` leaves the dialog closed and calls `onDismiss`, so the application's `open` follows the + // widget and opening it again is a change that mounts again. + // + // A bound `open` is not equality-gated, for the reason a Tabs index is not: onDismiss writes `open` inside this + // Effect's own run, which a Computed would miss, and then find the next opening unchanged. An Effect that runs + // with `open` unchanged re-sends `setOpen`, and mounts content only if none is mounted: once per run while `open` + // stays true over content that failed. + Widget& mountKind(reactive::Scope& scope, Dialog const& spec, ContainerWidget* parent) { + DialogWidget& widget = scope.adopt(_backend->createDialog(parent)); + applyCommon(scope, widget, spec.common); + bind(scope, spec.title, [&widget](std::string const& title) { widget.setTitle(title); }); + std::function onDismiss = event(spec.onDismiss); + widget.setOnDismiss(onDismiss); + auto& content = scope.make>(); + auto show = [this, &widget, &content, child = spec.child](bool open, std::function const& dismiss) { + if (!open) { + content.reset(); + } else if (content == nullptr) { + try { + content = mountContent(child, widget); + } catch (...) { + dismissFailed(dismiss); + throw; + } + } + widget.setOpen(open); + }; + if (!spec.open.isBound()) { + // A constant has no application state to bring back in line: content that fails throws out of the mount. + show(spec.open.constant(), {}); + } else { + scope.effect([binding = spec.open.binding(), show = std::move(show), onDismiss = std::move(onDismiss)] { + show(binding(), onDismiss); + }); + } + return widget; + } + + // After a Dialog's content failed to mount: tells the application through `onDismiss` that the dialog is closed. + // It runs last, from a copy: it is a widget callback, which may destroy the Mounted and with it the dialog and + // the Effect that called this; the caller only rethrows after it. Nothing thrown here may replace the failure + // being reported. + static void dismissFailed(std::function const& onDismiss) noexcept { + try { + std::function const dismiss = onDismiss; + if (dismiss) { + dismiss(); + } + } catch (...) { // NOLINT(bugprone-empty-catch) + // Only copying the wrapper can throw here; the application then keeps `open` true. + } + } + + Widget& mountKind(reactive::Scope& scope, ForEach const& spec, ContainerWidget* parent) { + StackWidget& widget = scope.adopt(_backend->createStack(parent, spec.axis)); + applyCommon(scope, widget, spec.common); + widget.setGap(spec.gap); + mountRows(scope, spec.model, widget, {}); + return widget; + } + + // Rows first, then the selection. The widget marks a selected key whenever its row arrives, so the order only + // shapes the log: the first setSelection comes after the rows and already marks them. + Widget& mountKind(reactive::Scope& scope, Table const& spec, ContainerWidget* parent) { + TableWidget& widget = scope.adopt(_backend->createTable(parent)); + applyCommon(scope, widget, spec.common); + widget.setColumns(spec.columns); + widget.setSelectionMode(spec.selectionMode); + widget.setOnSelectionChange(event(spec.onSelectionChange)); + widget.setOnActivate(event(spec.onActivate)); + mountRows(scope, spec.rows, widget, + [&widget](Widget& rowWidget, Key const& key) { widget.setRowKey(rowWidget, key); }); + bind(scope, spec.selection, [&widget](std::vector const& keys) { widget.setSelection(keys); }); + return widget; + } + + // The model, then the session, then the rows, then the Effect: destroyed in reverse, so the Effect stops + // first, the rows (whose slots point into the session's snapshot) go next, and the model outlives its session. + void mountRows(reactive::Scope& scope, std::shared_ptr const& model, + ContainerWidget& container, RowHook onRow) { + if (!model) { + return; + } + scope.make>(model); + ForEachSession& session = scope.adopt(model->open(*_rt)); + auto& rows = scope.make(); + scope.effect([this, &session, &rows, &container, onRow = std::move(onRow)] { + std::vector const keys = session.pull(); // tracked: the rows are this Effect's only source + // Untracked: what a row view reads while it is built, or what a kept row's update touches, must not + // become a dependency of the whole list. + _rt->untracked([&] { reconcile(session, rows.list, container, keys, onRow); }); + }); + } + + // The snapshot index of each key's first occurrence, in order; every later duplicate is reported. + [[nodiscard]] std::vector firstOccurrences(std::vector const& keys) { + std::unordered_set seen; + std::vector order; + order.reserve(keys.size()); + for (std::size_t index = 0; index < keys.size(); ++index) { + if (seen.insert(keys.at(index)).second) { + order.push_back(index); + } else { + _rt->core()->report(site::kDuplicateKey); + } + } + return order; + } + + // All or nothing: the row is built in a scope of its own, which the caller takes only once the mount completed. + [[nodiscard]] KeyedRow mountRow(ForEachSession& session, ContainerWidget& container, Key const& key, + std::size_t index, RowHook const& onRow) { + KeyedRow row{.key = key, .scope = std::make_unique(*_rt)}; + row.slot = &row.scope->adopt(session.makeRow(index)); + row.widget = mount(*row.scope, row.slot->view(), &container); + if (row.widget != nullptr && onRow) { + onRow(*row.widget, key); + } + return row; + } + + // Moves the widgets into the snapshot's order, and `rows` with them. A move that throws is recorded in `failure` + // when it is the first failure, and leaves `rows` mirroring the container as it stands. + static void reorder(std::vector& rows, ContainerWidget& container, std::vector const& keys, + std::vector const& order, std::exception_ptr& failure) { + std::unordered_map position; // key -> index in `rows` + std::unordered_map rowOf; + std::vector present; + for (std::size_t i = 0; i < rows.size(); ++i) { + KeyedRow const& row = rows.at(i); + position.emplace(row.key, i); + if (row.widget != nullptr) { + rowOf.emplace(row.widget, i); + present.push_back(row.widget); + } + } + std::vector target; + target.reserve(present.size()); + for (std::size_t const index : order) { + if (auto const found = position.find(keys.at(index)); found != position.end()) { + if (KeyedRow const& row = rows.at(found->second); row.widget != nullptr) { + target.push_back(row.widget); + } + } + } + std::vector ordered; + ordered.reserve(rows.size()); + try { + reorderChildren(container, present, target); + } catch (...) { + if (!failure) { + failure = std::current_exception(); + } + } + // `present` is the container's order now: the snapshot's, unless a move threw. Rows without a widget have + // no place in the container and go last. + for (Widget const* const widget : present) { + ordered.push_back(std::move(rows.at(rowOf.at(widget)))); + } + for (KeyedRow& row : rows) { + if (row.widget == nullptr) { + ordered.push_back(std::move(row)); + } + } + rows = std::move(ordered); + } + + // Brings `rows` to the snapshot `keys`. `rows` mirrors the container's order on entry and on exit, also when + // something throws, so a failure costs this run's remaining work and never the bookkeeping of the next one. + // A row that fails to mount leaves nothing behind and does not stop the other rows; the first failure is + // rethrown at the end, for the Effect to report, and the next run mounts the row again. + void reconcile(ForEachSession& session, std::vector& rows, ContainerWidget& container, + std::vector const& keys, RowHook const& onRow) { + std::vector const order = firstOccurrences(keys); + std::unordered_map wanted; // key -> snapshot index + for (std::size_t const index : order) { + wanted.emplace(keys.at(index), index); + } + + // Unmount the gone keys, last first as KeyedRows does, then update the kept ones in place, which keeps their + // widgets. + for (KeyedRow& row : std::views::reverse(rows)) { + if (!wanted.contains(row.key)) { + row.scope.reset(); + } + } + std::erase_if(rows, [](KeyedRow const& row) { return row.scope == nullptr; }); + std::unordered_set kept; + for (KeyedRow const& row : rows) { + kept.insert(row.key); + row.slot->assign(wanted.at(row.key)); + } + + // Mount the new keys; each widget is appended to the container, and its row to `rows`. + std::exception_ptr failure; + rows.reserve(order.size()); + for (std::size_t const index : order) { + if (kept.contains(keys.at(index))) { + continue; + } + try { + rows.push_back(mountRow(session, container, keys.at(index), index, onRow)); + } catch (...) { + if (!failure) { + failure = std::current_exception(); + } + } + } + + reorder(rows, container, keys, order, failure); + if (failure) { + std::rethrow_exception(failure); + } + } + + Widget& mountKind(reactive::Scope& scope, Busy const& spec, ContainerWidget* parent) { + BusyWidget& widget = scope.adopt(_backend->createBusy(parent)); + applyCommon(scope, widget, spec.common); + bind(scope, spec.active, [&widget](bool active) { widget.setActive(active); }); + bind(scope, spec.label, [&widget](std::string const& label) { widget.setLabel(label); }); + return widget; + } + + Widget& mountKind(reactive::Scope& scope, DateTimeInput const& spec, ContainerWidget* parent) { + DateTimeInputWidget& widget = + scope.adopt(_backend->createDateTimeInput(parent, spec.mode, spec.offsetMinutes)); + applyCommon(scope, widget, spec.common); + bind(scope, spec.value, + [&widget](std::optional const& value) { widget.setValue(value); }); + widget.setOnChange(event(spec.onChange)); + return widget; + } + + Widget& mountKind(reactive::Scope& scope, Slider const& spec, ContainerWidget* parent) { + SliderWidget& widget = scope.adopt(_backend->createSlider(parent)); + applyCommon(scope, widget, spec.common); + widget.setRange(spec.minimum, spec.maximum, spec.step); + bind(scope, spec.value, [&widget](std::int64_t value) { widget.setValue(value); }); + widget.setOnChange(event(spec.onChange)); + return widget; + } + + Widget& mountKind(reactive::Scope& scope, FilePicker const& spec, ContainerWidget* parent) { + FilePickerWidget& widget = scope.adopt(_backend->createFilePicker(parent, spec.mode)); + applyCommon(scope, widget, spec.common); + bind(scope, spec.path, [&widget](std::string const& path) { widget.setPath(path); }); + widget.setOnPicked(event(spec.onPicked)); + return widget; + } + + reactive::Runtime* _rt; + IViewBackend* _backend; +}; + +} // namespace detail + +/// @brief A view tree mounted on a backend: its widgets, built once, and the bindings that keep them current. +/// +/// Destroying it destroys every binding before the widget it drives and every child before its parent. The +/// runtime, the backend and every signal a binding reads must outlive it; the owner of the runtime constructs and +/// destroys it. Non-copyable and non-movable: bindings point into it. +/// +/// Every widget callback, a drop's `accepts` predicate included, runs as a `reactive::Runtime::widgetEvent` and +/// untracked, so a binding whose setter makes the backend call a handler does not subscribe to what the handler +/// reads. An exception a callback throws is reported (`detail::site::kCallbackThrew`) and goes no further: the +/// backend's call returns normally, a predicate that threw refuses the drop, and what the callback wrote before +/// throwing still flushes. +/// +/// A mount that throws goes no further than the content it was building, and leaves none of it behind. A node mounted +/// directly, a constant Switch selector, Tabs index or Dialog `open` included, throws out of the constructor. Content +/// a binding mounts, which a bound selector, index or `open` does in an Effect even during the constructor, is +/// reported (`reactive::detail::site::kEffectThrew`) instead: a Switch shows nothing for that key, having torn the old +/// case down first; a Tabs keeps the previous page shown and selected; a Dialog stays closed. A Tabs then calls its +/// `onSelect` with the page still shown, and a Dialog its `onDismiss`, each as a widget callback and as the last thing +/// the mount does, so the application's state follows the widget and its next pick of the failed tab, or opening of +/// the dialog, mounts again. A Switch selector is equality-gated, so its next change of key mounts again. A Tabs +/// index and a Dialog `open` are not: while one still names content that failed — no `onSelect` or `onDismiss` moved +/// it — every run of its binding, a change to any signal the binding reads included, tries that mount again, once +/// per run, reporting each failure and calling `onSelect` or `onDismiss` again. +/// +/// A ForEach or a Table mounts its rows in an Effect too, one row at a time: a row that fails is reported, leaves +/// nothing behind and is left out, the other rows are mounted and ordered all the same, and the next change of the +/// rows mounts it again. A key repeated in one snapshot is reported (`detail::site::kDuplicateKey`), and only its +/// first row is shown. +/// +/// A mount must run to completion: nothing it calls, a backend factory or setter or a binding's evaluation, may +/// destroy this object, its runtime or its backend. Widget callbacks are events, not part of a mount, and may. +class Mounted { +public: + /// @param runtime The runtime the bindings are made in. Borrowed. + /// @param backend Makes the widgets. Borrowed. + /// @param root The tree; kept alive as long as the mount. + /// @param parent The container the root widget is appended to, or null for a backend root. + /// @throws std::invalid_argument when @p root is null. + Mounted(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, IViewBackend& backend MORPH_LIFETIMEBOUND, Node root, + ContainerWidget* parent = nullptr) + : _root{nonNull(std::move(root))}, + _mounter{runtime, backend}, + _scope{runtime}, + // Untracked, so mounting from inside an Effect does not subscribe that Effect to anything read here. + _widget{runtime.untracked([this, parent] { return _mounter.mount(_scope, _root, parent); })} {} + + ~Mounted() = default; + Mounted(Mounted const&) = delete; + Mounted& operator=(Mounted const&) = delete; + Mounted(Mounted&&) = delete; + Mounted& operator=(Mounted&&) = delete; + + /// @brief The root node's widget. + /// @return The widget; valid as long as this object. + [[nodiscard]] Widget& root() const noexcept { return *_widget; } + +private: + [[nodiscard]] static Node nonNull(Node root) { + if (!root) { + throw std::invalid_argument{"morph::ui::Mounted: the root node is null"}; + } + return root; + } + + Node _root; + detail::Mounter _mounter; + reactive::Scope _scope; // after _mounter: destroyed first, while the mounter its bindings call is alive + Widget* _widget; +}; + +} // namespace morph::ui diff --git a/include/morph/ui/testing/backend_conformance.hpp b/include/morph/ui/testing/backend_conformance.hpp new file mode 100644 index 000000000..32b5eb7b2 --- /dev/null +++ b/include/morph/ui/testing/backend_conformance.hpp @@ -0,0 +1,1089 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../reactive/runtime.hpp" +#include "../../reactive/signal.hpp" +#include "../../util/datetime.hpp" +#include "../backend.hpp" +#include "../mount.hpp" +#include "../view.hpp" + +/// @file +/// @brief The scripted cases every `IViewBackend` must pass, and the probe a backend author implements to run them. +/// +/// Specified in `docs/spec/ui/backend_contract.md`, "The conformance suite". + +namespace morph::ui::testing { + +/// @brief How the conformance cases read and drive one backend under test. +/// +/// The driving operations act as a user would, through the backend's own input path where it has one; each may run +/// pending work before and after it. Reading operations report what the widget shows now. +/// +/// Whether a handler can run after its widget was destroyed is observable only for a backend whose input can be +/// delivered later than the driving call, such as a queued connection; a probe whose `click` delivers before it +/// returns passes that case without exercising it. +class ConformanceProbe { +public: + ConformanceProbe() = default; + virtual ~ConformanceProbe() = default; + ConformanceProbe(ConformanceProbe const&) = delete; + ConformanceProbe& operator=(ConformanceProbe const&) = delete; + ConformanceProbe(ConformanceProbe&&) = delete; + ConformanceProbe& operator=(ConformanceProbe&&) = delete; + + /// @brief The backend under test. + /// @return The backend every case mounts on. + virtual IViewBackend& backend() = 0; + + /// @brief The runtime the cases' signals and mounts belong to. + /// @return The runtime, whose owner `settle` drives. + virtual reactive::Runtime& runtime() = 0; + + /// @brief Runs posted flushes and pending native events until nothing is left. + virtual void settle() = 0; + + /// @brief The text a widget shows: a Text's or TextInput's text, a Button's or Checkbox's label, and the label of + /// the option a Select marks. + /// @param widget The widget. + /// @return The text, UTF-8; empty for a Select that marks no option. + [[nodiscard]] virtual std::string textOf(Widget const& widget) = 0; + + /// @brief The widget's own visibility flag, as its last `setVisible` left it. + /// + /// Only the widget's own flag counts: a hidden ancestor, a collapsed panel or a closed dialog around it does + /// not make this false. That is what `RecordingBackend` records, and it lets a case tell which Tabs page slot + /// the mount hid. + /// @param widget The widget. + /// @return True for a new widget; false once `setVisible(false)` reached it and until `setVisible(true)` does. + [[nodiscard]] virtual bool visibleOf(Widget const& widget) = 0; + + /// @brief Whether a widget accepts input, by its own flag: a disabled ancestor does not make this false, which + /// is what `RecordingBackend` records. + /// @param widget The widget. + /// @return True for a new widget; false once `setEnabled(false)` reached it and until `setEnabled(true)` does. + [[nodiscard]] virtual bool enabledOf(Widget const& widget) = 0; + + /// @brief How many children a container has. + /// @param container The container. + /// @return The count. + [[nodiscard]] virtual std::size_t childCount(ContainerWidget const& container) = 0; + + /// @brief A container's child. + /// @param container The container. + /// @param index The position. + /// @return The child, or null past the end. A case drives it as it drives a root. + [[nodiscard]] virtual Widget* childAt(ContainerWidget const& container, std::size_t index) = 0; + + /// @brief Activates a button as a user would. + /// @param widget The button. + virtual void click(Widget& widget) = 0; + + /// @brief Replaces a text field's text as a user typing would: whatever the field held is cleared first (select + /// all, or delete), so it ends up holding exactly @p text. `onChange` may see intermediate texts. + /// @param widget The field. + /// @param text The text to end up with. + virtual void type(Widget& widget, std::string_view text) = 0; + + /// @brief Drags one widget onto another as a user would. + /// @param source The dragged widget. + /// @param target The widget it is dropped on. + virtual void drag(Widget& source, Widget& target) = 0; + + /// @brief Dismisses a dialog as a user would (Esc on the TUI). + /// @param dialog The dialog. + virtual void dismiss(Widget& dialog) = 0; + + /// @brief Selects rows of a table as a user would, so that the user's selection is exactly these rows. + /// @param table The table. + /// @param rows Positions among the table's current rows, in the order the user picks them: at least one, and + /// one only for a `Single` table. + virtual void selectRows(Widget& table, std::vector const& rows) = 0; + + /// @brief The rows a table shows as selected. + /// @param table The table. + /// @return Their positions among the table's current rows, ascending. + [[nodiscard]] virtual std::vector selectedRows(Widget const& table) = 0; +}; + +/// @brief One scripted case. +struct ConformanceCase { + /// @brief What the case checks; unique among the cases. + std::string_view name; + /// @brief Runs the case on a fresh probe. + std::function(ConformanceProbe&)> run; +}; + +namespace detail { + +/// @brief Collects a case's checks and keeps the first failure, so a case reads top to bottom. +class Checks { +public: + /// @brief Checks that a text is what it should be. + /// @param what What is checked, for the message. + /// @param expected The text it should be. + /// @param actual The text it is. + void text(std::string_view what, std::string_view expected, std::string_view actual) { + if (expected != actual) { + fail(std::string{what} + ": expected '" + std::string{expected} + "', got '" + std::string{actual} + "'"); + } + } + + /// @brief Checks a condition. + /// @param holds The condition. + /// @param what The failure message when it does not hold. + void that(bool holds, std::string_view what) { + if (!holds) { + fail(std::string{what}); + } + } + + /// @brief The case's verdict. + /// @return `nullopt` when every check held, else the first failure. + [[nodiscard]] std::optional result() const { return _failure; } + +private: + void fail(std::string message) { + if (!_failure.has_value()) { + _failure = std::move(message); + } + } + + std::optional _failure; +}; + +/// @brief A row of the ForEach and Table cases. +struct Entry { + /// @brief The row's key. + std::int64_t id = 0; + /// @brief The text the row shows. + std::string label; + + /// @brief Memberwise equality. + /// @return Whether both fields match. + bool operator==(Entry const&) const = default; +}; + +/// @brief A ForEach of Texts, one per entry, keyed by id. +/// @param rows The entries; must outlive the node's mounts. +/// @param builds Counts the row views built, when not null; must outlive the node's mounts. +/// @return The node. +[[nodiscard]] inline Node entryList(reactive::Signal> const& rows, int* builds = nullptr) { + return ui::forEach( + rows, [](Entry const& entry) { return Key{entry.id}; }, + [builds](reactive::Signal const& entry) { + if (builds != nullptr) { + ++*builds; + } + return ui::text({.text = [&entry] { return entry.get().label; }}); + }); +} + +/// @brief A one-column Table of entries, keyed by id, whose cell is a Text of the label. +/// @param rows The entries; must outlive the node's mounts. +/// @param options Selection, activation and `Common`. +/// @return The node. +[[nodiscard]] inline Node entryTable(reactive::Signal> const& rows, TableOptions options) { + return ui::table( + {{.label = "Name"}}, [&rows] { return rows.get(); }, [](Entry const& entry) { return Key{entry.id}; }, + [](reactive::Signal const& entry) { + return std::vector{ui::text({.text = [&entry] { return entry.get().label; }})}; + }, + std::move(options)); +} + +/// @brief A widget as a container. +/// @param widget The widget. +/// @return It, or null when it is not a `ContainerWidget`. +[[nodiscard]] inline ContainerWidget const* asContainer(Widget const& widget) { + return dynamic_cast(&widget); +} + +/// @brief A child of a widget that should be a container. +/// @param probe The backend under test. +/// @param parent The widget; null, or a widget that is no container, has no children. +/// @param index The position. +/// @return The child, or null. +[[nodiscard]] inline Widget* childOf(ConformanceProbe& probe, Widget const* parent, std::size_t index) { + ContainerWidget const* const container = parent == nullptr ? nullptr : asContainer(*parent); + return container == nullptr ? nullptr : probe.childAt(*container, index); +} + +/// @brief The texts of a container's children, joined by commas. +/// @param probe The backend under test. +/// @param container The container. +/// @return For example `a,b,c`; a missing child reads ``. +[[nodiscard]] inline std::string childTexts(ConformanceProbe& probe, ContainerWidget const& container) { + std::string texts; + for (std::size_t i = 0; i < probe.childCount(container); ++i) { + if (i != 0) { + texts += ','; + } + Widget const* const child = probe.childAt(container, i); + texts += child == nullptr ? std::string{""} : probe.textOf(*child); + } + return texts; +} + +/// @brief The texts of the rows an `entryTable` shows as selected, joined by commas. +/// @param probe The backend under test. +/// @param table The table. +/// @return For example `a,c`; a row without a first cell reads ``. +[[nodiscard]] inline std::string selectedTexts(ConformanceProbe& probe, Widget const& table) { + std::string texts; + for (std::size_t const row : probe.selectedRows(table)) { + if (!texts.empty()) { + texts += ','; + } + Widget const* const cell = childOf(probe, childOf(probe, &table, row), 0); + texts += cell == nullptr ? std::string{""} : probe.textOf(*cell); + } + return texts; +} + +/// @brief Keys as text, for a failure message. +/// @param keys The keys. +/// @return For example `1,"b"`. +[[nodiscard]] inline std::string keyTexts(std::vector const& keys) { + std::string texts; + for (Key const& key : keys) { + if (!texts.empty()) { + texts += ','; + } + if (auto const* const number = std::get_if(&key); number != nullptr) { + texts += std::to_string(*number); + } else { + texts += '"' + std::get(key) + '"'; + } + } + return texts; +} + +/// @brief Case: a constant Text shows its text. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional textShowsItsText(ConformanceProbe& probe) { + Mounted const view{probe.runtime(), probe.backend(), ui::text({.text = "hello"})}; + probe.settle(); + Checks checks; + checks.text("a constant Text", "hello", probe.textOf(view.root())); + return checks.result(); +} + +/// @brief Case: a bound Text follows its signal, once per batch, and not for an equal write. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional boundTextUpdatesOncePerChange(ConformanceProbe& probe) { + reactive::Runtime& runtime = probe.runtime(); + reactive::Signal word{runtime, "one"}; + int evaluations = 0; + Mounted const view{runtime, probe.backend(), ui::text({.text = [&word, &evaluations] { + ++evaluations; + return word.get(); + }})}; + probe.settle(); + Checks checks; + checks.text("a bound Text", "one", probe.textOf(view.root())); + runtime.batch([&word] { + word.set("two"); + word.set("three"); + }); + probe.settle(); + checks.text("a bound Text after a batch of two writes", "three", probe.textOf(view.root())); + word.set("three"); + probe.settle(); + checks.that(evaluations == 2, "the binding ran " + std::to_string(evaluations) + + " times; expected once at mount and once for the batch"); + return checks.result(); +} + +/// @brief Case: `visible` and `enabled` follow their bindings. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional visibleAndEnabledFollowBindings(ConformanceProbe& probe) { + reactive::Signal shown{probe.runtime(), true}; + Mounted const view{probe.runtime(), probe.backend(), + ui::button({.label = "Go", + .common = {.visible = [&shown] { return shown.get(); }, + .enabled = [&shown] { return shown.get(); }}})}; + probe.settle(); + Checks checks; + checks.that(probe.visibleOf(view.root()), "a Button bound visible=true is hidden"); + checks.that(probe.enabledOf(view.root()), "a Button bound enabled=true is disabled"); + shown.set(false); + probe.settle(); + checks.that(!probe.visibleOf(view.root()), "a Button bound visible=false is shown"); + checks.that(!probe.enabledOf(view.root()), "a Button bound enabled=false is enabled"); + return checks.result(); +} + +/// @brief Case: a click runs the button's action once, and what it wrote reaches the screen. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional clickReachesAction(ConformanceProbe& probe) { + reactive::Signal clicks{probe.runtime(), 0}; + Mounted const view{probe.runtime(), probe.backend(), + ui::button({.label = [&clicks] { return "clicked " + std::to_string(clicks.get()); }, + .onClick = [&clicks] { clicks.set(clicks.peek() + 1); }})}; + probe.settle(); + probe.click(view.root()); + probe.settle(); + Checks checks; + checks.that(clicks.peek() == 1, "one click ran the action " + std::to_string(clicks.peek()) + " times"); + checks.text("the label bound to the click count", "clicked 1", probe.textOf(view.root())); + return checks.result(); +} + +/// @brief Case: typing reaches `onChange`; a value set by the application is shown and not reported back. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional typingWithoutEcho(ConformanceProbe& probe) { + reactive::Signal name{probe.runtime(), ""}; + int changes = 0; + Mounted const view{probe.runtime(), probe.backend(), + ui::textInput({.value = [&name] { return name.get(); }, + .onChange = + [&name, &changes](std::string text) { + ++changes; + name.set(std::move(text)); + }})}; + probe.settle(); + probe.type(view.root(), "abc"); + probe.settle(); + Checks checks; + checks.that(changes >= 1, "typing did not reach onChange"); + checks.text("the value after typing", "abc", name.peek()); + checks.text("the field after typing", "abc", probe.textOf(view.root())); + int const typed = changes; + name.set("xyz"); + probe.settle(); + checks.text("the field after the application set a value", "xyz", probe.textOf(view.root())); + checks.that(changes == typed, "a value set by the application was reported through onChange"); + return checks.result(); +} + +/// @brief Case: a Switch shows the selected case, nothing for a key without one, and remounts on a change. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional switchShowsSelectedCase(ConformanceProbe& probe) { + reactive::Signal which{probe.runtime(), 0}; + Mounted const view{probe.runtime(), probe.backend(), + ui::switchOf({.selector = [&which] { return Key{which.get()}; }, + .cases = {{.key = Key{std::int64_t{0}}, .node = ui::text({.text = "zero"})}, + {.key = Key{std::int64_t{1}}, .node = ui::text({.text = "one"})}}})}; + probe.settle(); + ContainerWidget const* const slot = asContainer(view.root()); + if (slot == nullptr) { + return "a Switch's widget is not a ContainerWidget"; + } + Checks checks; + checks.text("the case for key 0", "zero", childTexts(probe, *slot)); + which.set(1); + probe.settle(); + checks.text("the case for key 1", "one", childTexts(probe, *slot)); + which.set(7); + probe.settle(); + checks.that(probe.childCount(*slot) == 0, "a key with no case and no fallback still shows a child"); + which.set(0); + probe.settle(); + checks.text("the case for key 0 again", "zero", childTexts(probe, *slot)); + return checks.result(); +} + +/// @brief Case: Tabs mount a page on its first selection, keep it, and show only the selected one. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional tabsMountPagesLazily(ConformanceProbe& probe) { + reactive::Signal selected{probe.runtime(), 0}; + Mounted const view{probe.runtime(), probe.backend(), + ui::tabs({.tabs = {{.label = "A", .node = ui::text({.text = "first"})}, + {.label = "B", .node = ui::text({.text = "second"})}}, + .selected = [&selected] { return selected.get(); }, + .onSelect = [&selected](std::size_t index) { selected.set(index); }})}; + probe.settle(); + ContainerWidget const* const bar = asContainer(view.root()); + if (bar == nullptr) { + return "a Tabs' widget is not a ContainerWidget"; + } + Checks checks; + checks.that(probe.childCount(*bar) == 1, "Tabs mounted a page that was never selected"); + selected.set(1); + probe.settle(); + checks.that(probe.childCount(*bar) == 2, "selecting a tab did not mount its page"); + Widget const* const first = probe.childAt(*bar, 0); + Widget const* const second = probe.childAt(*bar, 1); + checks.that(first != nullptr && !probe.visibleOf(*first), "the unselected tab's page is shown"); + checks.that(second != nullptr && probe.visibleOf(*second), "the selected tab's page is hidden"); + selected.set(0); + probe.settle(); + // Read again rather than through `first`: a backend that destroyed the page would leave that pointer dangling. + checks.that(probe.childCount(*bar) == 2, "re-selecting a tab mounted its page again"); + Widget const* const reselected = probe.childAt(*bar, 0); + checks.that(reselected != nullptr && probe.visibleOf(*reselected), "the re-selected tab's page is hidden"); + return checks.result(); +} + +/// @brief Case: a ForEach updates a row whose key stays in place, keeping its widget and not building its view +/// again. +/// +/// The build count is the deciding check: a widget address alone cannot tell a kept widget from a new one that the +/// allocator placed where the old one was. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional forEachUpdatesInPlace(ConformanceProbe& probe) { + reactive::Signal> rows{probe.runtime(), {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}}; + int builds = 0; + Mounted const view{probe.runtime(), probe.backend(), entryList(rows, &builds)}; + probe.settle(); + ContainerWidget const* const list = asContainer(view.root()); + if (list == nullptr) { + return "a ForEach's widget is not a ContainerWidget"; + } + Widget const* const before = probe.childAt(*list, 0); + rows.set({{.id = 1, .label = "a2"}, {.id = 2, .label = "b"}}); + probe.settle(); + Checks checks; + checks.that(builds == 2, "updating a kept row built its view again: " + std::to_string(builds) + + " row views built for two keys"); + checks.that(probe.childAt(*list, 0) == before, "updating a kept row replaced its widget"); + checks.text("the rows after an update", "a2,b", childTexts(probe, *list)); + return checks.result(); +} + +/// @brief Case: a ForEach follows inserts, removals and reorders. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional forEachInsertsRemovesReorders(ConformanceProbe& probe) { + reactive::Signal> rows{ + probe.runtime(), {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}, {.id = 3, .label = "c"}}}; + Mounted const view{probe.runtime(), probe.backend(), entryList(rows)}; + probe.settle(); + ContainerWidget const* const list = asContainer(view.root()); + if (list == nullptr) { + return "a ForEach's widget is not a ContainerWidget"; + } + Checks checks; + checks.text("the rows as mounted", "a,b,c", childTexts(probe, *list)); + rows.set({{.id = 3, .label = "c"}, {.id = 1, .label = "a"}}); + probe.settle(); + checks.text("the rows after removing b and moving c first", "c,a", childTexts(probe, *list)); + rows.set({{.id = 3, .label = "c"}, {.id = 4, .label = "d"}, {.id = 1, .label = "a"}}); + probe.settle(); + checks.text("the rows after inserting d in the middle", "c,d,a", childTexts(probe, *list)); + return checks.result(); +} + +/// @brief Case: a widget moved among its siblings keeps its native state; here, the text a user typed into a field. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional movedRowKeepsNativeState(ConformanceProbe& probe) { + reactive::Signal> rows{ + probe.runtime(), {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}, {.id = 3, .label = "c"}}}; + // The field's text is the widget's own: no binding sets it after the mount, so only the user's typing does. + Mounted const view{probe.runtime(), probe.backend(), + ui::forEach( + rows, [](Entry const& entry) { return Key{entry.id}; }, + [](reactive::Signal const& entry) { + return ui::textInput({.placeholder = [&entry] { return entry.get().label; }}); + })}; + probe.settle(); + ContainerWidget const* const list = asContainer(view.root()); + Widget* const field = list == nullptr ? nullptr : probe.childAt(*list, 0); + if (field == nullptr) { + return "a ForEach of text fields shows no first field"; + } + probe.type(*field, "edited"); + probe.settle(); + Checks checks; + checks.text("the fields after typing into the first", "edited,,", childTexts(probe, *list)); + rows.set({{.id = 2, .label = "b"}, {.id = 3, .label = "c"}, {.id = 1, .label = "a"}}); + probe.settle(); + checks.that(probe.childAt(*list, 2) == field, "moving a row replaced its widget"); + checks.text("the fields after moving the edited one last; moveChild must keep a widget's native state", ",,edited", + childTexts(probe, *list)); + return checks.result(); +} + +/// @brief Case: a Dialog holds its content only while it is open. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional dialogHoldsContentWhileOpen(ConformanceProbe& probe) { + reactive::Signal open{probe.runtime(), false}; + Mounted const view{ + probe.runtime(), probe.backend(), + ui::dialog( + {.open = [&open] { return open.get(); }, .title = "Confirm", .child = ui::text({.text = "inside"})})}; + probe.settle(); + ContainerWidget const* const dialog = asContainer(view.root()); + if (dialog == nullptr) { + return "a Dialog's widget is not a ContainerWidget"; + } + Checks checks; + checks.that(probe.childCount(*dialog) == 0, "a closed Dialog holds content"); + open.set(true); + probe.settle(); + checks.text("an open Dialog's content", "inside", childTexts(probe, *dialog)); + open.set(false); + probe.settle(); + checks.that(probe.childCount(*dialog) == 0, "a Dialog closed again still holds content"); + return checks.result(); +} + +/// @brief Case: a new dialog is closed, and a closed dialog takes no input, neither a dismissal nor a click on a +/// child; an open one takes both. +/// +/// Built through the backend directly: a mount opens or closes every dialog it makes, and mounts a dialog's content +/// only while it is open, so neither a new dialog nor a closed one with children can be reached through it. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional closedDialogTakesNoInput(ConformanceProbe& probe) { + int clicks = 0; + int dismissals = 0; + std::unique_ptr const dialog = probe.backend().createDialog(nullptr); + dialog->setTitle("Confirm"); + dialog->setOnDismiss([&dismissals] { ++dismissals; }); + std::unique_ptr const button = probe.backend().createButton(dialog.get()); + button->setLabel("OK"); + button->setOnClick([&clicks] { ++clicks; }); + probe.settle(); + probe.click(*button); + probe.dismiss(*dialog); + probe.settle(); + Checks checks; + checks.that(clicks == 0, "a click reached a button inside a new dialog; a new dialog is closed"); + checks.that(dismissals == 0, "a new dialog was dismissed; a new dialog is closed"); + dialog->setOpen(true); + probe.settle(); + probe.click(*button); + probe.settle(); + checks.that(clicks == 1, + "a click on a button inside an open dialog ran its action " + std::to_string(clicks) + " times"); + probe.dismiss(*dialog); + probe.settle(); + checks.that(dismissals == 1, + "dismissing an open dialog called onDismiss " + std::to_string(dismissals) + " times"); + dialog->setOpen(false); + probe.settle(); + probe.click(*button); + probe.dismiss(*dialog); + probe.settle(); + checks.that(clicks == 1, "a click reached a button inside a closed dialog"); + checks.that(dismissals == 1, "a closed dialog was dismissed"); + return checks.result(); +} + +/// @brief Case: a widget inside a hidden, disabled or collapsed container takes no input — no click, edit, drag or +/// row selection — and takes it again once the container is shown, enabled and expanded. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional unreachableWidgetsTakeNoInput(ConformanceProbe& probe) { + reactive::Runtime& runtime = probe.runtime(); + reactive::Signal hidden{runtime, false}; + reactive::Signal disabled{runtime, false}; + reactive::Signal collapsed{runtime, false}; + reactive::Signal> const rows{runtime, {{.id = 1, .label = "r1"}, {.id = 2, .label = "r2"}}}; + int clicks = 0; + int edits = 0; + int drops = 0; + int selections = 0; + // Every gate sits at least two levels above the widgets it gates, so a backend that consults only a widget's + // direct parent fails: an outer Column hides or disables, a Panel inside it collapses, and an inner Column + // holds the widgets. + Node const widgets = ui::column( + {.children = { + ui::button({.label = "Go", .onClick = [&clicks] { ++clicks; }}), + ui::textInput({.onChange = [&edits](std::string const&) { ++edits; }}), + ui::text({.text = "card", .common = {.dragKey = std::optional{Key{std::int64_t{7}}}}}), + entryTable(rows, {.selectionMode = SelectionMode::Single, + .onSelectionChange = [&selections](std::vector const&) { ++selections; }})}}); + Mounted const gated{runtime, probe.backend(), + ui::column({.children = {ui::panel({.title = "Outer", + .child = widgets, + .collapsible = true, + .collapsed = [&collapsed] { return collapsed.get(); }})}, + .common = {.visible = [&hidden] { return !hidden.get(); }, + .enabled = [&disabled] { return !disabled.get(); }}})}; + Mounted const target{runtime, probe.backend(), + ui::panel({.title = "Target", .common = {.onDrop = [&drops](Key const&) { ++drops; }}})}; + probe.settle(); + Widget const* const column = childOf(probe, childOf(probe, &gated.root(), 0), 0); + Widget* const button = childOf(probe, column, 0); + Widget* const field = childOf(probe, column, 1); + Widget* const card = childOf(probe, column, 2); + Widget* const table = childOf(probe, column, 3); + if (button == nullptr || field == nullptr || card == nullptr || table == nullptr) { + return "a Column in a Panel in a Column does not show the inner Column's four children"; + } + + struct Gate { + std::string_view name; + reactive::Signal* flag; + }; + std::array const gates{{{.name = "hidden", .flag = &hidden}, + {.name = "disabled", .flag = &disabled}, + {.name = "collapsed", .flag = &collapsed}}}; + std::size_t row = 1; // each round selects the row not selected yet, so a reachable table reports a change + std::string text = "a"; + Checks checks; + for (Gate const& gate : gates) { + std::string const where = " inside a " + std::string{gate.name} + " container"; + auto const act = [&] { + probe.click(*button); + probe.type(*field, text); + probe.drag(*card, target.root()); + probe.selectRows(*table, {row}); + probe.settle(); + }; + gate.flag->set(true); + probe.settle(); + std::array const before{clicks, edits, drops, selections}; + act(); + checks.that(clicks == before.at(0), "a click reached a button" + where); + checks.that(edits == before.at(1), "typing reached a text field" + where); + checks.that(drops == before.at(2), "a widget" + where + " was dragged and dropped"); + checks.that(selections == before.at(3), "a row selection reached a table" + where); + gate.flag->set(false); + probe.settle(); + act(); + std::string const again = " once its container was no longer " + std::string{gate.name}; + checks.that(clicks == before.at(0) + 1, "a click did not reach a button" + again); + checks.that(edits > before.at(1), "typing did not reach a text field" + again); + checks.that(drops == before.at(2) + 1, "a drag did not deliver its key" + again); + checks.that(selections == before.at(3) + 1, "a row selection did not reach a table" + again); + row = 1 - row; + text += "b"; + } + return checks.result(); +} + +/// @brief Case: no setter calls a handler. Setting a value, replacing options, items or rows, requesting a selection, +/// opening or closing — none of it is a user's doing, so none of it reaches `onChange`, `onToggle`, +/// `onSelect`, `onSelectionChange` and the like. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional settersCallNoHandler(ConformanceProbe& probe) { + using Options = std::vector; + using std::chrono::milliseconds; + reactive::Runtime& runtime = probe.runtime(); + reactive::Signal phase{runtime, 0}; + reactive::Signal> rows{runtime, {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}}; + std::vector fired; + auto const note = [&fired](std::string_view handler) { + return [&fired, handler](auto const&...) { fired.emplace_back(handler); }; + }; + auto const odd = [&phase] { return phase.get() % 2 == 1; }; + auto const instant = [odd, &phase] { + using Instant = std::chrono::sys_time; + return odd() ? std::optional{morph::time::DateTime{Instant{milliseconds{phase.get()}}}} + : std::nullopt; + }; + Mounted const view{ + runtime, probe.backend(), + ui::column( + {.children = { + ui::textInput({.value = [&phase] { return "v" + std::to_string(phase.get()); }, + .onChange = note("TextInput onChange"), + .onSubmit = note("TextInput onSubmit")}), + ui::checkbox({.label = "c", .checked = odd, .onToggle = note("Checkbox onToggle")}), + // The requested key is 2, then 1; the options hold 2 in odd phases only, so setOptions + // re-resolves the request, which is not a user's choice either. + ui::select( + {.options = + [odd] { + return odd() ? Options{{.key = Key{std::int64_t{1}}, .label = "One"}, + {.key = Key{std::int64_t{2}}, .label = "Two"}} + : Options{{.key = Key{std::int64_t{1}}, .label = "One"}}; + }, + .selected = [&phase] { return std::optional{Key{std::int64_t{phase.get() < 2 ? 2 : 1}}}; }, + .onSelect = note("Select onSelect")}), + ui::menu({.items = {{.label = [&phase] { return "m" + std::to_string(phase.get()); }, + .onSelect = note("Menu item onSelect")}}}), + ui::slider( + {.value = [&phase] { return std::int64_t{phase.get()}; }, .onChange = note("Slider onChange")}), + ui::dateTimeInput({.value = instant, .onChange = note("DateTimeInput onChange")}), + ui::filePicker({.path = [&phase] { return "/p" + std::to_string(phase.get()); }, + .onPicked = note("FilePicker onPicked")}), + ui::panel({.title = "p", .collapsible = true, .collapsed = odd, .onToggle = note("Panel onToggle")}), + ui::tabs({.tabs = {{.label = "A", .node = ui::text({.text = "first"})}, + {.label = "B", .node = ui::text({.text = "second"})}}, + .selected = [odd] { return odd() ? std::size_t{1} : std::size_t{0}; }, + .onSelect = note("Tabs onSelect")}), + ui::dialog({.open = odd, .title = "d", .onDismiss = note("Dialog onDismiss")}), + entryTable(rows, {.selectionMode = SelectionMode::Multiple, + .selection = + [&phase] { + return phase.get() < 2 + ? std::vector{Key{std::int64_t{3}}} + : std::vector{Key{std::int64_t{1}}, Key{std::int64_t{2}}}; + }, + .onSelectionChange = note("Table onSelectionChange"), + .onActivate = note("Table onActivate")}), + }})}; + probe.settle(); + for (int next = 1; next <= 3; ++next) { + phase.set(next); + // Rows arriving with a requested key, and rows going away, re-mark the table without a user's doing. + rows.set(next % 2 == 1 ? std::vector{{.id = 2, .label = "b"}, {.id = 3, .label = "c"}} + : std::vector{{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}); + probe.settle(); + } + std::string list; + for (std::string const& handler : fired) { + list += (list.empty() ? "" : ", ") + handler; + } + Checks checks; + checks.that(fired.empty(), "a setter called a handler; only a user's action may: " + list); + return checks.result(); +} + +/// @brief Case: a Select marks the requested key whenever its options contain it, whichever arrives first, and +/// remembers the request across options that lack it. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional selectReResolvesRequestedKey(ConformanceProbe& probe) { + using Options = std::vector; + SelectOption const one{.key = Key{std::int64_t{1}}, .label = "One"}; + SelectOption const two{.key = Key{std::int64_t{2}}, .label = "Two"}; + SelectOption const three{.key = Key{std::int64_t{3}}, .label = "Three"}; + reactive::Signal options{probe.runtime(), Options{}}; + int choices = 0; + Mounted const view{probe.runtime(), probe.backend(), + ui::select({.options = [&options] { return options.get(); }, + .selected = std::optional{two.key}, + .onSelect = [&choices](Key const&) { ++choices; }})}; + probe.settle(); + Checks checks; + checks.text("a Select whose requested key came before any options", "", probe.textOf(view.root())); + options.set({one, three}); + probe.settle(); + checks.text("a Select whose options lack the requested key", "", probe.textOf(view.root())); + options.set({one, two}); + probe.settle(); + checks.text("a Select once options with the requested key arrived", "Two", probe.textOf(view.root())); + options.set({one}); + probe.settle(); + checks.text("a Select whose new options lack the requested key", "", probe.textOf(view.root())); + options.set({two, one}); + probe.settle(); + checks.text("a Select whose options bring the requested key back", "Two", probe.textOf(view.root())); + checks.that(choices == 0, "re-marking the requested key called onSelect " + std::to_string(choices) + " times"); + return checks.result(); +} + +/// @brief Case: a Table in `Multiple` mode reports a user's change as exactly the existing rows now selected. +/// +/// A requested key without a row stays requested through row changes and is marked when its row arrives, and a +/// row that goes is unmarked, all without a handler call; a user's change drops the requested keys without a row. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional tableReportsExistingSelectedRows(ConformanceProbe& probe) { + reactive::Runtime& runtime = probe.runtime(); + std::vector const three{{.id = 1, .label = "a"}, {.id = 2, .label = "b"}, {.id = 3, .label = "c"}}; + std::vector withNine = three; + withNine.push_back({.id = 9, .label = "i"}); + reactive::Signal> rows{runtime, three}; + reactive::Signal> const selection{runtime, {Key{std::int64_t{2}}, Key{std::int64_t{9}}}}; + std::vector> reports; + Mounted const view{ + runtime, probe.backend(), + entryTable(rows, + {.selectionMode = SelectionMode::Multiple, + .selection = [&selection] { return selection.get(); }, + .onSelectionChange = [&reports](std::vector keys) { reports.push_back(std::move(keys)); }})}; + probe.settle(); + Checks checks; + checks.text("the rows marked for requested keys 2 and 9, with no row 9", "b", selectedTexts(probe, view.root())); + rows.set(withNine); + probe.settle(); + checks.text("the rows marked once row 9 arrived", "b,i", selectedTexts(probe, view.root())); + rows.set(three); + probe.settle(); + checks.text("the rows marked once row 9 went", "b", selectedTexts(probe, view.root())); + rows.set(withNine); + probe.settle(); + checks.text("the rows marked once row 9 returned", "b,i", selectedTexts(probe, view.root())); + checks.that(reports.empty(), "rows arriving or going called onSelectionChange"); + rows.set(three); + probe.settle(); + probe.selectRows(view.root(), {0, 2}); + probe.settle(); + checks.text("the rows marked after the user selected a and c", "a,c", selectedTexts(probe, view.root())); + // A backend may report each step of the user's picking (a click, then a Ctrl-click), so only the last report + // must be the whole selection; but no report may name a key without a row. + bool const rowlessReported = std::ranges::any_of(reports, [](std::vector const& keys) { + return std::ranges::any_of(keys, [](Key const& key) { + return key != Key{std::int64_t{1}} && key != Key{std::int64_t{2}} && key != Key{std::int64_t{3}}; + }); + }); + std::vector last = reports.empty() ? std::vector{} : reports.back(); + std::ranges::sort(last); + checks.that(!reports.empty(), "a user's selection of rows 1 and 3 did not call onSelectionChange"); + checks.that(!rowlessReported, + "a user's selection, with key 9 requested and no row 9, reported a key without a row"); + 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", + keyTexts(last)); + rows.set(withNine); + probe.settle(); + checks.text("the rows marked once row 9 arrived after the user's selection", "a,c", + selectedTexts(probe, view.root())); + return checks.result(); +} + +/// @brief Case: a Table shows the user's selection even when the application binds no selection. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional tableShowsUnboundUserSelection(ConformanceProbe& probe) { + reactive::Signal> rows{probe.runtime(), {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}}; + int reports = 0; + Mounted const view{probe.runtime(), probe.backend(), + entryTable(rows, {.selectionMode = SelectionMode::Single, + .onSelectionChange = [&reports](std::vector const&) { ++reports; }})}; + probe.settle(); + probe.selectRows(view.root(), {1}); + probe.settle(); + Checks checks; + checks.that(reports == 1, "a user's selection called onSelectionChange " + std::to_string(reports) + " times"); + checks.text("the rows marked after the user selected b, with no selection bound", "b", + selectedTexts(probe, view.root())); + rows.set({{.id = 1, .label = "a"}, {.id = 2, .label = "b2"}}); + probe.settle(); + checks.text("the rows marked after the selected row was updated", "b2", selectedTexts(probe, view.root())); + return checks.result(); +} + +/// @brief Case: a hidden Table cell keeps its column: it stays in its row, so the cells after it keep their places. +/// +/// This checks the row's structure only; that the cells after it are drawn under their own headers is the +/// backend's own test to make. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional hiddenCellKeepsItsColumn(ConformanceProbe& probe) { + reactive::Signal> const rows{probe.runtime(), {{.id = 1, .label = "a"}}}; + Mounted const view{probe.runtime(), probe.backend(), + ui::table( + {{.label = "Note"}, {.label = "Name"}}, [&rows] { return rows.get(); }, + [](Entry const& entry) { return Key{entry.id}; }, + [](reactive::Signal const& entry) { + return std::vector{ui::text({.text = "note", .common = {.visible = false}}), + ui::text({.text = [&entry] { return entry.get().label; }})}; + })}; + probe.settle(); + Widget const* const row = childOf(probe, &view.root(), 0); + ContainerWidget const* const cells = row == nullptr ? nullptr : asContainer(*row); + if (cells == nullptr) { + return "a Table with one row shows no row container"; + } + Checks checks; + checks.text("the cells of a row whose first cell is hidden", "note,a", childTexts(probe, *cells)); + Widget const* const first = probe.childAt(*cells, 0); + checks.that(first != nullptr && !probe.visibleOf(*first), "the hidden cell is shown"); + return checks.result(); +} + +/// @brief Case: no handler runs after its widget's destructor returned, even for input that arrived before. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional noHandlerAfterDestruction(ConformanceProbe& probe) { + int clicks = 0; + std::optional view; + view.emplace(probe.runtime(), probe.backend(), ui::button({.label = "Go", .onClick = [&clicks] { ++clicks; }})); + probe.settle(); + probe.click(view->root()); + probe.settle(); + Checks checks; + checks.that(clicks == 1, "a click ran the action " + std::to_string(clicks) + " times"); + probe.click(view->root()); + int const before = clicks; + view.reset(); + probe.settle(); + checks.that(clicks == before, "a click's handler ran after the button was destroyed"); + return checks.result(); +} + +/// @brief What `LifeSentinel` reports about the handler that holds it. +struct HandlerLife { + /// @brief The sentinel inside the handler while that handler runs; null otherwise. + void const* running = nullptr; + /// @brief Set when the running handler's sentinel was destroyed before the handler returned. + bool freedWhileRunning = false; +}; + +/// @brief Captured by value in a handler, tells whether the backend destroyed the handler while it ran. +/// +/// Every copy of the handler holds its own sentinel; only the destruction of the copy that is running counts. +class LifeSentinel { +public: + /// @param life Where to report; must outlive every copy. + explicit LifeSentinel(HandlerLife& life) noexcept : _life{&life} {} + + /// @brief Reports its own destruction when it belongs to the running handler. + ~LifeSentinel() { + if (_life->running == this) { + _life->freedWhileRunning = true; + } + } + + /// @brief A copy, for another copy of the handler. + LifeSentinel(LifeSentinel const&) = default; + /// @brief Copies the report target. + /// @return This sentinel. + LifeSentinel& operator=(LifeSentinel const&) = default; + /// @brief A copy, for a moved handler. + LifeSentinel(LifeSentinel&&) = default; + /// @brief Copies the report target. + /// @return This sentinel. + LifeSentinel& operator=(LifeSentinel&&) = default; + + /// @brief Where it reports. + /// @return The report. + [[nodiscard]] HandlerLife* life() const noexcept { return _life; } + +private: + HandlerLife* _life; +}; + +/// @brief Case: a handler may destroy its own widget; the backend keeps the running handler alive until it returns, +/// and stays usable after. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional handlerMayDestroyItsWidget(ConformanceProbe& probe) { + HandlerLife life; + int clicks = 0; + std::optional view; + // After the reset the handler touches nothing it captured: its captures are gone if the backend let them go. + view.emplace(probe.runtime(), probe.backend(), + ui::button({.label = "Close", .onClick = [sentinel = LifeSentinel{life}, &view, &clicks] { + HandlerLife* const report = sentinel.life(); + std::optional* const own = &view; + ++clicks; + report->running = &sentinel; + own->reset(); + report->running = nullptr; + }})); + probe.settle(); + probe.click(view->root()); + probe.settle(); + Checks checks; + checks.that(clicks == 1, + "the click that destroys its own button ran the action " + std::to_string(clicks) + " times"); + checks.that(!view.has_value(), "the click did not destroy its own button"); + checks.that(!life.freedWhileRunning, + "destroying a button from its own handler destroyed the running handler; the backend must keep it " + "alive until it returns"); + Mounted const after{probe.runtime(), probe.backend(), ui::text({.text = "after"})}; + probe.settle(); + checks.text("a Text mounted after a handler destroyed its own widget", "after", probe.textOf(after.root())); + checks.that(clicks == 1, "a handler ran again after it destroyed its own widget"); + return checks.result(); +} + +/// @brief Drags a card carrying key 7 onto a panel built with @p accepts, and returns what the panel received. +/// @param probe The backend under test. +/// @param accepts The panel's predicate; empty leaves `Common::accepts` unset. +/// @return The keys the panel's `onDrop` received. +[[nodiscard]] inline std::vector dropOntoPanel(ConformanceProbe& probe, std::function accepts) { + std::vector dropped; + Mounted const card{probe.runtime(), probe.backend(), + ui::text({.text = "card", .common = {.dragKey = std::optional{Key{std::int64_t{7}}}}})}; + Mounted const target{ + probe.runtime(), probe.backend(), + ui::panel({.title = "Done", .common = {.accepts = std::move(accepts), .onDrop = [&dropped](Key key) { + dropped.push_back(std::move(key)); + }}})}; + probe.settle(); + probe.drag(card.root(), target.root()); + probe.settle(); + return dropped; +} + +/// @brief Case: a drag onto a target that accepts the key delivers it once. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional dragOntoAcceptingTarget(ConformanceProbe& probe) { + Checks checks; + checks.that(dropOntoPanel(probe, [](Key const& key) { return key == Key{std::int64_t{7}}; }) == + std::vector{Key{std::int64_t{7}}}, + "a drop the target accepts did not deliver the dragged key exactly once"); + return checks.result(); +} + +/// @brief Case: a drag onto a target that refuses the key delivers nothing. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional dragOntoRefusingTarget(ConformanceProbe& probe) { + Checks checks; + checks.that(dropOntoPanel(probe, [](Key const&) { return false; }).empty(), + "a drop the target refuses reached its onDrop"); + return checks.result(); +} + +/// @brief Case: a drop target without `accepts` takes every key. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional dropTargetWithoutAcceptsTakesAll(ConformanceProbe& probe) { + Checks checks; + checks.that(dropOntoPanel(probe, {}) == std::vector{Key{std::int64_t{7}}}, + "a drop target without accepts refused a key"); + return checks.result(); +} + +} // namespace detail + +/// @brief Every conformance case, in a fixed order. +/// +/// Run each on a fresh probe; `nullopt` is a pass, anything else is the failure message. A backend's test suite +/// loops over these with its own `ConformanceProbe`. +/// @return The cases. +[[nodiscard]] inline std::span conformanceCases() { + static std::array const cases{{ + {.name = "a Text shows its constant text", .run = detail::textShowsItsText}, + {.name = "a bound Text updates once per batch and not for an equal write", + .run = detail::boundTextUpdatesOncePerChange}, + {.name = "visible and enabled follow their bindings", .run = detail::visibleAndEnabledFollowBindings}, + {.name = "a click runs the button's action once", .run = detail::clickReachesAction}, + {.name = "typing reaches onChange and a value set by the application is not echoed", + .run = detail::typingWithoutEcho}, + {.name = "a Switch shows the selected case and nothing for a key without one", + .run = detail::switchShowsSelectedCase}, + {.name = "Tabs mount a page on first selection and show only the selected one", + .run = detail::tabsMountPagesLazily}, + {.name = "a ForEach updates a kept row in place", .run = detail::forEachUpdatesInPlace}, + {.name = "a ForEach follows inserts, removals and reorders", .run = detail::forEachInsertsRemovesReorders}, + {.name = "a moved widget keeps its native state", .run = detail::movedRowKeepsNativeState}, + {.name = "a Dialog holds its content only while open", .run = detail::dialogHoldsContentWhileOpen}, + {.name = "a new Dialog is closed and a closed Dialog takes no input", .run = detail::closedDialogTakesNoInput}, + {.name = "a widget inside a hidden, disabled or collapsed container takes no input", + .run = detail::unreachableWidgetsTakeNoInput}, + {.name = "no setter calls a handler", .run = detail::settersCallNoHandler}, + {.name = "a Select marks the requested key whenever its options contain it", + .run = detail::selectReResolvesRequestedKey}, + {.name = "a Table reports a user's selection as exactly the existing rows selected", + .run = detail::tableReportsExistingSelectedRows}, + {.name = "a Table shows a user's selection with no selection bound", + .run = detail::tableShowsUnboundUserSelection}, + {.name = "a hidden Table cell keeps its column", .run = detail::hiddenCellKeepsItsColumn}, + {.name = "no handler runs after its widget was destroyed", .run = detail::noHandlerAfterDestruction}, + {.name = "a handler may destroy its own widget", .run = detail::handlerMayDestroyItsWidget}, + {.name = "a drag onto an accepting target delivers the key", .run = detail::dragOntoAcceptingTarget}, + {.name = "a drag onto a refusing target delivers nothing", .run = detail::dragOntoRefusingTarget}, + {.name = "a drop target without accepts takes every key", .run = detail::dropTargetWithoutAcceptsTakesAll}, + }}; + return cases; +} + +} // namespace morph::ui::testing diff --git a/include/morph/ui/testing/recording_backend.hpp b/include/morph/ui/testing/recording_backend.hpp new file mode 100644 index 000000000..14dbbaa50 --- /dev/null +++ b/include/morph/ui/testing/recording_backend.hpp @@ -0,0 +1,1369 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../util/datetime.hpp" +#include "../backend.hpp" +#include "../view.hpp" + +/// @file +/// @brief `morph::ui::testing::RecordingBackend`: a headless widget tree with an operation log, a golden dump and +/// user-interaction helpers — the test double and the reference for every real backend. +/// +/// Specified in `docs/spec/ui/backend_contract.md`, "RecordingBackend". + +namespace morph::ui::testing { + +namespace detail { + +/// @brief Properties fixed when a widget is created, as name and formatted value. +using PropList = std::vector>; + +/// @brief Formats a flag. +/// @param value The flag. +/// @return `true` or `false`. +[[nodiscard]] inline std::string formatBool(bool value) { return value ? "true" : "false"; } + +/// @brief Escapes text with a backslash: a line break becomes backslash and `n`, a carriage return backslash and `r`, +/// and a backslash or a character @p isSpecial selects is preceded by a backslash. +/// @tparam Special A callable taking a `char` and returning whether to escape it. +/// @param text The text. +/// @param isSpecial Selects the characters to escape besides the backslash and the two line-ending characters. +/// @return The escaped text. +template +[[nodiscard]] std::string escape(std::string_view text, Special const& isSpecial) { + std::string escaped; + escaped.reserve(text.size()); + for (char const character : text) { + if (character == '\n') { + escaped += "\\n"; + } else if (character == '\r') { + escaped += "\\r"; + } else { + if (character == '\\' || isSpecial(character)) { + escaped += '\\'; + } + escaped += character; + } + } + return escaped; +} + +/// @brief Formats free text as a property value. Line breaks and `=` are escaped, so the value stays on its own line +/// and no part of it reads as another `name=value`. +/// @param text The text, UTF-8. +/// @return The escaped text. +[[nodiscard]] inline std::string formatText(std::string_view text) { + return escape(text, [](char character) { return character == '='; }); +} + +/// @brief Formats free text as an element of a `formatList` list. Also escapes `,`, `[` and `]`, so the text reads as +/// one element. +/// @param text The text, UTF-8. +/// @return The escaped text. +[[nodiscard]] inline std::string formatItem(std::string_view text) { + return escape(text, [](char character) { return std::string_view{"=,[]"}.contains(character); }); +} + +/// @brief Formats a key: an integer in decimal, a string in double quotes, so `7` and `"7"` differ. +/// @param key The key. +/// @return Its text. A string key is escaped as `formatItem` escapes, and a double quote in it is preceded by a +/// backslash. +[[nodiscard]] inline std::string formatKey(Key const& key) { + if (auto const* const number = std::get_if(&key); number != nullptr) { + return std::to_string(*number); + } + return '"' + + escape(std::get(key), + [](char character) { return std::string_view{"=,[]\""}.contains(character); }) + + '"'; +} + +/// @brief Formats an optional key. +/// @param key The key, or none. +/// @return `formatKey(*key)`, or `none`. +[[nodiscard]] inline std::string formatOptionalKey(std::optional const& key) { + return key.has_value() ? formatKey(*key) : std::string{"none"}; +} + +/// @brief Formats one dimension's sizing. +/// @param sizing The sizing. +/// @return `content`, `fixed(n)` or `stretch(n)`. +[[nodiscard]] inline std::string formatSizing(Sizing sizing) { + switch (sizing.kind) { + case Sizing::Kind::Content: + return "content"; + case Sizing::Kind::Fixed: + return "fixed(" + std::to_string(sizing.amount) + ")"; + case Sizing::Kind::Stretch: + return "stretch(" + std::to_string(sizing.amount) + ")"; + default: + return "unknown"; + } +} + +/// @brief Formats a size request. +/// @param hints The request. +/// @return `width/height`, each as `formatSizing` gives it. +[[nodiscard]] inline std::string formatLayout(LayoutHints const& hints) { + return formatSizing(hints.width) + "/" + formatSizing(hints.height); +} + +/// @brief Formats a date-time value. +/// @param value The value. +/// @return `none` for `nullopt`, `empty` for an empty `Timestamp`, else the ISO-8601 UTC instant. +[[nodiscard]] inline std::string formatTimestamp(std::optional const& value) { + if (!value.has_value()) { + return "none"; + } + if (!value->hasValue()) { + return "empty"; + } + return (**value).toIso8601(); +} + +/// @brief Formats a list as `[a,b,c]`. +/// @tparam T The element type. +/// @tparam F A callable turning a `T const&` into a `std::string`. +/// @param items The elements. +/// @param each Formats one element. +/// @return The list's text. +template +[[nodiscard]] std::string formatList(std::vector const& items, F const& each) { + std::string text = "["; + bool first = true; + for (T const& item : items) { + if (!first) { + text += ','; + } + first = false; + text += each(item); + } + return text + "]"; +} + +/// @brief A text role's name. +/// @param role The role. +/// @return The enumerator's name. +[[nodiscard]] inline std::string enumName(TextRole role) { + switch (role) { + case TextRole::Normal: + return "Normal"; + case TextRole::Muted: + return "Muted"; + case TextRole::Heading: + return "Heading"; + case TextRole::Error: + return "Error"; + case TextRole::Success: + return "Success"; + default: + return "unknown"; + } +} + +/// @brief A text input mode's name. +/// @param mode The mode. +/// @return The enumerator's name. +[[nodiscard]] inline std::string enumName(TextInputMode mode) { + switch (mode) { + case TextInputMode::SingleLine: + return "SingleLine"; + case TextInputMode::Multiline: + return "Multiline"; + case TextInputMode::Password: + return "Password"; + default: + return "unknown"; + } +} + +/// @brief A select style's name. +/// @param style The style. +/// @return The enumerator's name. +[[nodiscard]] inline std::string enumName(SelectStyle style) { + switch (style) { + case SelectStyle::Dropdown: + return "Dropdown"; + case SelectStyle::Radio: + return "Radio"; + default: + return "unknown"; + } +} + +/// @brief An axis's name. +/// @param axis The axis. +/// @return The enumerator's name. +[[nodiscard]] inline std::string enumName(Axis axis) { + switch (axis) { + case Axis::Vertical: + return "Vertical"; + case Axis::Horizontal: + return "Horizontal"; + default: + return "unknown"; + } +} + +/// @brief A date mode's name. +/// @param mode The mode. +/// @return The enumerator's name. +[[nodiscard]] inline std::string enumName(DateMode mode) { + switch (mode) { + case DateMode::Date: + return "Date"; + case DateMode::DateTime: + return "DateTime"; + default: + return "unknown"; + } +} + +/// @brief A file picker mode's name. +/// @param mode The mode. +/// @return The enumerator's name. +[[nodiscard]] inline std::string enumName(FilePickerMode mode) { + switch (mode) { + case FilePickerMode::Open: + return "Open"; + case FilePickerMode::Save: + return "Save"; + default: + return "unknown"; + } +} + +/// @brief A selection mode's name. +/// @param mode The mode. +/// @return The enumerator's name. +[[nodiscard]] inline std::string enumName(SelectionMode mode) { + switch (mode) { + case SelectionMode::None: + return "None"; + case SelectionMode::Single: + return "Single"; + case SelectionMode::Multiple: + return "Multiple"; + default: + return "unknown"; + } +} + +/// @brief The handlers a widget was given, which the interaction helpers call. +struct Callbacks { + /// @brief From `ButtonWidget::setOnClick`. + Action click; + /// @brief From `TextInputWidget::setOnChange`. + std::function change; + /// @brief From `TextInputWidget::setOnSubmit`. + std::function submit; + /// @brief From `CheckboxWidget::setOnToggle` or `PanelWidget::setOnToggle`. + std::function toggle; + /// @brief From `SelectWidget::setOnSelect`. + std::function select; + /// @brief From `MenuWidget::setOnActivate` or `TabsWidget::setOnSelect`. + std::function index; + /// @brief From `DateTimeInputWidget::setOnChange`. + std::function)> dateTime; + /// @brief From `SliderWidget::setOnChange`. + std::function slide; + /// @brief From `FilePickerWidget::setOnPicked`. + std::function picked; + /// @brief From `DialogWidget::setOnDismiss`. + Action dismiss; + /// @brief From `TableWidget::setOnSelectionChange`. + std::function)> selection; + /// @brief From `TableWidget::setOnActivate`. + std::function activate; + /// @brief The drop handler's predicate, from `Widget::setDropHandler`. + std::function accepts; + /// @brief The drop handler, from `Widget::setDropHandler`. + std::function drop; +}; + +/// @brief One fake widget's state. +struct Record { + /// @brief The widget's id: its creation number, from 1. + int id = 0; + /// @brief The kind shown in the log and the dump. + std::string kind; + /// @brief The parent's id; 0 for a root. + int parent = 0; + /// @brief The children's ids, in order. + std::vector children; + /// @brief Every property set so far, formatted, in name order. + std::map> props; + /// @brief The handlers set so far. + Callbacks callbacks; + /// @brief The drag key, as `setDragKey` last set it. + std::optional dragKey; + /// @brief The fake widget object. + Widget* widget = nullptr; + /// @brief Called with a child's id once that child is destroyed; a fake table re-marks its selection with it. + std::function onChildDestroyed; +}; + +/// @brief The records, the roots and the operation log that every fake widget of one backend writes to. +/// +/// Held by `shared_ptr` from the backend and from every fake, so a fake that outlives its backend still has +/// somewhere to record its destruction. +class RecordStore { +public: + /// @brief Records a new widget appended to @p parent and logs its creation. + /// @param kind The kind shown in the log and the dump. + /// @param parent The container it is appended to, or null for a root. + /// @param widget The fake widget. + /// @param initial Properties fixed at creation; recorded without a log line. + /// @return The new widget's id. + int create(std::string kind, ContainerWidget const* parent, Widget& widget, PropList initial) { + int const widgetId = ++_nextId; + int const parentId = parent == nullptr ? 0 : idOf(*parent); + Record record{.id = widgetId, .kind = std::move(kind), .parent = parentId, .widget = &widget}; + for (auto& [name, value] : initial) { + record.props.insert_or_assign(std::move(name), std::move(value)); + } + _log.push_back("create " + label(record) + " in " + + (parentId == 0 ? std::string{"root"} : label(at(parentId)))); + siblingsOf(parentId).push_back(widgetId); + _ids.insert_or_assign(&widget, widgetId); + _records.insert_or_assign(widgetId, std::move(record)); + return widgetId; + } + + /// @brief Sets a property through a widget setter, and logs it. + /// @param widgetId The widget. + /// @param name The property. + /// @param value Its formatted value. + void set(int widgetId, std::string_view name, std::string value) { + Record& record = at(widgetId); + _log.push_back("set " + label(record) + " " + std::string{name} + "=" + value); + record.props.insert_or_assign(std::string{name}, std::move(value)); + } + + /// @brief Sets a property the user changed through the widget itself; no setter ran, so nothing is logged. + /// @param widgetId The widget. + /// @param name The property. + /// @param value Its formatted value. + void setByUser(int widgetId, std::string_view name, std::string value) { + at(widgetId).props.insert_or_assign(std::string{name}, std::move(value)); + } + + /// @brief Moves a widget among its siblings, as `ContainerWidget::moveChild` defines it, and logs it. + /// @param widgetId The widget. + /// @param index The new position; past the end means last. + // An id and a position: an `int` and a `std::size_t`, which convert into each other. + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) + void move(int widgetId, std::size_t index) { + Record const& record = at(widgetId); + std::vector& siblings = siblingsOf(record.parent); + std::erase(siblings, widgetId); + auto const position = static_cast(std::min(index, siblings.size())); + siblings.insert(siblings.begin() + position, widgetId); + _log.push_back("move " + label(record) + " to " + std::to_string(index)); + } + + /// @brief Forgets a destroyed widget and logs it. Its remaining children become roots. + /// @param widgetId The widget; an unknown id does nothing. + void destroy(int widgetId) { + auto const found = _records.find(widgetId); + if (found == _records.end()) { + return; + } + Record const& record = found->second; + int const parentId = record.parent; + _log.push_back("destroy " + label(record)); + std::erase(siblingsOf(parentId), widgetId); + for (int const child : record.children) { + if (auto const orphan = _records.find(child); orphan != _records.end()) { + orphan->second.parent = 0; + _roots.push_back(child); + } + } + _ids.erase(record.widget); + _records.erase(found); + if (auto const parent = _records.find(parentId); parent != _records.end()) { + if (auto const notify = parent->second.onChildDestroyed; notify) { + notify(widgetId); + } + } + } + + /// @brief A widget's record. + /// @param widgetId The widget. + /// @return The record. + /// @throws std::out_of_range for an id that names no live widget. + [[nodiscard]] Record& at(int widgetId) { + auto const found = _records.find(widgetId); + if (found == _records.end()) { + throw std::out_of_range{"RecordingBackend: no widget #" + std::to_string(widgetId)}; + } + return found->second; + } + + /// @brief A widget's record. + /// @param widgetId The widget. + /// @return The record. + /// @throws std::out_of_range for an id that names no live widget. + [[nodiscard]] Record const& at(int widgetId) const { + auto const found = _records.find(widgetId); + if (found == _records.end()) { + throw std::out_of_range{"RecordingBackend: no widget #" + std::to_string(widgetId)}; + } + return found->second; + } + + /// @brief The id of a live widget made by this store's backend. + /// @param widget The widget. + /// @return Its id. + /// @throws std::out_of_range when @p widget is not one of them. + [[nodiscard]] int idOf(Widget const& widget) const { + auto const found = _ids.find(&widget); + if (found == _ids.end()) { + throw std::out_of_range{"RecordingBackend: not a live widget of this backend"}; + } + return found->second; + } + + /// @brief Every live widget's record. + /// @return The records, by id. + [[nodiscard]] std::map const& records() const noexcept { return _records; } + + /// @brief The operation log. + /// @return One line per operation, oldest first. + [[nodiscard]] std::vector const& log() const noexcept { return _log; } + + /// @brief Empties the operation log. + void clearLog() noexcept { _log.clear(); } + + /// @brief The golden tree. + /// @return One line per live widget, children indented two spaces per depth. + [[nodiscard]] std::string dump() const { + std::string text; + for (int const root : _roots) { + dumpInto(text, root, 0); + } + return text; + } + +private: + [[nodiscard]] static std::string label(Record const& record) { + return record.kind + "#" + std::to_string(record.id); + } + + std::vector& siblingsOf(int parentId) { return parentId == 0 ? _roots : at(parentId).children; } + + // A widget tree is dumped by descending it. The id and the depth are an `int` and a `std::size_t`, which convert + // into each other. + // NOLINTNEXTLINE(misc-no-recursion,bugprone-easily-swappable-parameters) + void dumpInto(std::string& text, int widgetId, std::size_t depth) const { + Record const& record = at(widgetId); + text.append(depth * 2, ' '); + text += label(record); + for (auto const& [name, value] : record.props) { + text += ' '; + text += name; + text += '='; + text += value; + } + text += '\n'; + for (int const child : record.children) { + dumpInto(text, child, depth + 1); + } + } + + std::map _records; + std::unordered_map _ids; + std::vector _roots; + std::vector _log; + int _nextId = 0; +}; + +/// @brief The `Widget` half of every fake: records itself on construction, records `Common`'s setters, and records +/// its destruction. +/// @tparam Interface The widget interface the fake implements. +template +class FakeLeaf : public Interface { +public: + /// @brief Records the new widget, appended to @p parent, and logs its creation. + /// @param store Where the widget records itself. + /// @param kind The kind shown in the log and the dump. + /// @param parent The container it is appended to, or null for a root. + /// @param initial Properties fixed at creation. + FakeLeaf(std::shared_ptr store, std::string kind, ContainerWidget const* parent, + PropList initial = {}) + : _store{std::move(store)}, _id{_store->create(std::move(kind), parent, *this, std::move(initial))} {} + + /// @brief Forgets the widget and logs its destruction; its remaining children become roots. + ~FakeLeaf() override { + try { + _store->destroy(_id); + } catch (...) { // NOLINT(bugprone-empty-catch) + // Logging the destruction failed to allocate; the record stays, and a test reads it as a leak. + } + } + + FakeLeaf(FakeLeaf const&) = delete; + FakeLeaf& operator=(FakeLeaf const&) = delete; + FakeLeaf(FakeLeaf&&) = delete; + FakeLeaf& operator=(FakeLeaf&&) = delete; + + /// @brief Records property `visible`. + /// @param visible Whether it is shown. + void setVisible(bool visible) override { set("visible", formatBool(visible)); } + + /// @brief Records property `enabled`. + /// @param enabled Whether it accepts input. + void setEnabled(bool enabled) override { set("enabled", formatBool(enabled)); } + + /// @brief Records property `layout`, as `formatLayout` gives it. + /// @param hints The request in both dimensions. + void setLayout(LayoutHints const& hints) override { set("layout", formatLayout(hints)); } + + /// @brief Records property `dragKey` and keeps the key for `RecordingBackend::drag`. + /// @param key Engaged: the key a drop delivers. `nullopt`: not draggable. + void setDragKey(std::optional const& key) override { + _store->at(_id).dragKey = key; + set("dragKey", formatOptionalKey(key)); + } + + /// @brief Records property `drop=handler` and keeps both handlers for `RecordingBackend::drag`. + /// @param accepts Whether a dragged key may be dropped here. + /// @param onDrop Called with the key on a drop that `accepts` allowed. + void setDropHandler(std::function accepts, std::function onDrop) override { + Callbacks& handlers = callbacks(); + handlers.accepts = std::move(accepts); + handlers.drop = std::move(onDrop); + set("drop", "handler"); + } + +protected: + /// @brief Records a property set through a setter. + /// @param name The property. + /// @param value Its formatted value. + void set(std::string_view name, std::string value) { _store->set(_id, name, std::move(value)); } + + /// @brief This widget's handlers. + /// @return The handlers, for a setter to store into. + [[nodiscard]] Callbacks& callbacks() { return _store->at(_id).callbacks; } + + /// @brief The store this widget records into. + /// @return The store. + [[nodiscard]] RecordStore& store() const noexcept { return *_store; } + + /// @brief This widget's id. + /// @return The id. + [[nodiscard]] int id() const noexcept { return _id; } + +private: + std::shared_ptr _store; + int _id; +}; + +/// @brief The `ContainerWidget` half of every fake container. +/// @tparam Interface The container interface the fake implements. +template +class FakeContainer : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + + /// @brief Moves a child among its siblings and logs it. + /// @param child One of this container's children. + /// @param index The new position; past the end means last. + /// @throws std::logic_error when @p child is not this container's child. + void moveChild(Widget& child, std::size_t index) override { + RecordStore& records = this->store(); + int const childId = records.idOf(child); + if (records.at(childId).parent != this->id()) { + throw std::logic_error{"RecordingBackend: moveChild of a widget that is not this container's child"}; + } + records.move(childId, index); + } +}; + +/// @brief A fake `TextWidget`: properties `text`, `role`. +class FakeText final : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + void setText(std::string_view text) override { set("text", formatText(text)); } + void setRole(TextRole role) override { set("role", enumName(role)); } +}; + +/// @brief A fake `ButtonWidget`: property `label`. +class FakeButton final : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + void setLabel(std::string_view label) override { set("label", formatText(label)); } + void setOnClick(Action onClick) override { callbacks().click = std::move(onClick); } +}; + +/// @brief A fake `TextInputWidget`: properties `mode`, `text`, `placeholder`. +class FakeTextInput final : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + void setText(std::string_view text) override { set("text", formatText(text)); } + void setPlaceholder(std::string_view placeholder) override { set("placeholder", formatText(placeholder)); } + void setOnChange(std::function onChange) override { callbacks().change = std::move(onChange); } + void setOnSubmit(std::function onSubmit) override { callbacks().submit = std::move(onSubmit); } +}; + +/// @brief A fake `CheckboxWidget`: properties `label`, `checked`. +class FakeCheckbox final : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + void setLabel(std::string_view label) override { set("label", formatText(label)); } + void setChecked(bool checked) override { set("checked", formatBool(checked)); } + void setOnToggle(std::function onToggle) override { callbacks().toggle = std::move(onToggle); } +}; + +/// @brief A fake `SelectWidget`: properties `style`, `options`, `selected`. +/// +/// `selected` shows the option the widget marks: the requested key while the options contain it, else `none`. The +/// requested key is kept, so options that bring it back mark it again; that re-marking is the widget's own doing and +/// is not logged. +class FakeSelect final : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + + /// @brief Records property `options`, and re-marks the requested key against them. + /// @param options The options, in order. + void setOptions(std::vector const& options) override { + _options = options; + set("options", formatList(options, [](SelectOption const& option) { + return formatKey(option.key) + ":" + formatItem(option.label); + })); + if (_requested.has_value()) { + store().setByUser(id(), "selected", marked()); + } + } + + /// @brief Requests the option with @p key, and records property `selected` as the option it marks. + /// @param key The key, or `nullopt` for no selection. + void setSelected(std::optional const& key) override { + _requested.emplace(key); + set("selected", marked()); + } + + /// @brief Stores the handler `RecordingBackend::choose` calls. + /// @param onSelect The handler. + void setOnSelect(std::function onSelect) override { callbacks().select = std::move(onSelect); } + + /// @brief Whether one of the current options has @p key. + /// @param key The key. + /// @return True when an option has it. + [[nodiscard]] bool offers(Key const& key) const { + return std::ranges::any_of(_options, [&key](SelectOption const& option) { return option.key == key; }); + } + + /// @brief Marks the option the user chose, as the requested key; nothing is logged. + /// @param key The key of one of the current options. + void chooseByUser(Key const& key) { + _requested.emplace(key); + store().setByUser(id(), "selected", marked()); + } + +private: + [[nodiscard]] std::string marked() const { + if (!_requested.has_value() || !_requested->has_value() || !offers(**_requested)) { + return "none"; + } + return formatKey(**_requested); + } + + std::vector _options; + // Engaged once `setSelected` ran or the user chose; the inner value is the requested key, or none. + std::optional> _requested; +}; + +/// @brief A fake `MenuWidget`: property `items`. +class FakeMenu final : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + void setItems(std::vector const& labels) override { + set("items", formatList(labels, [](std::string const& label) { return formatItem(label); })); + } + void setOnActivate(std::function onActivate) override { + callbacks().index = std::move(onActivate); + } +}; + +/// @brief A fake `StackWidget`, of kind `Column` or `Row`: property `gap`. +class FakeStack final : public FakeContainer { +public: + using FakeContainer::FakeContainer; + void setGap(int gap) override { set("gap", std::to_string(gap)); } +}; + +/// @brief A fake `GridWidget`: properties `columns`, `gap`, and `span` on a child. +class FakeGrid final : public FakeContainer { +public: + using FakeContainer::FakeContainer; + void setColumns(int columns) override { set("columns", std::to_string(columns)); } + void setGap(int gap) override { set("gap", std::to_string(gap)); } + + /// @brief Records property `span` on @p child. + /// @param child One of this grid's children. + /// @param span The column count it occupies. + /// @throws std::logic_error when @p child is not this grid's child. + void setSpan(Widget& child, int span) override { + int const childId = store().idOf(child); + if (store().at(childId).parent != id()) { + throw std::logic_error{"RecordingBackend: setSpan of a widget that is not this grid's child"}; + } + store().set(childId, "span", std::to_string(span)); + } +}; + +/// @brief A fake `PanelWidget`: properties `title`, `padding`, `collapsible`, `collapsed`. +class FakePanel final : public FakeContainer { +public: + using FakeContainer::FakeContainer; + void setTitle(std::string_view title) override { set("title", formatText(title)); } + void setPadding(int padding) override { set("padding", std::to_string(padding)); } + void setCollapsible(bool collapsible) override { set("collapsible", formatBool(collapsible)); } + void setCollapsed(bool collapsed) override { set("collapsed", formatBool(collapsed)); } + void setOnToggle(std::function onToggle) override { callbacks().toggle = std::move(onToggle); } +}; + +/// @brief A fake `BusyWidget`: properties `active`, `label`. +class FakeBusy final : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + void setActive(bool active) override { set("active", formatBool(active)); } + void setLabel(std::string_view label) override { set("label", formatText(label)); } +}; + +/// @brief A fake `DateTimeInputWidget`: properties `mode`, `offset`, `value`. +class FakeDateTimeInput final : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + void setValue(std::optional const& value) override { + set("value", formatTimestamp(value)); + } + void setOnChange(std::function)> onChange) override { + callbacks().dateTime = std::move(onChange); + } +}; + +/// @brief A fake `SliderWidget`: properties `range` (`minimum..maximum/step`), `value`. +class FakeSlider final : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + void setRange(std::int64_t minimum, std::int64_t maximum, std::int64_t step) override { + set("range", std::to_string(minimum) + ".." + std::to_string(maximum) + "/" + std::to_string(step)); + } + void setValue(std::int64_t value) override { set("value", std::to_string(value)); } + void setOnChange(std::function onChange) override { callbacks().slide = std::move(onChange); } +}; + +/// @brief A fake `FilePickerWidget`: properties `mode`, `path`. +class FakeFilePicker final : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + void setPath(std::string_view path) override { set("path", formatText(path)); } + void setOnPicked(std::function onPicked) override { callbacks().picked = std::move(onPicked); } +}; + +/// @brief A fake `TabsWidget`: properties `tabs`, `selected`. +class FakeTabs final : public FakeContainer { +public: + using FakeContainer::FakeContainer; + void setTabs(std::vector const& labels) override { + set("tabs", formatList(labels, [](std::string const& label) { return formatItem(label); })); + } + void setSelected(std::size_t index) override { set("selected", std::to_string(index)); } + void setOnSelect(std::function onSelect) override { callbacks().index = std::move(onSelect); } +}; + +/// @brief A fake `DialogWidget`: properties `open`, `title`. +class FakeDialog final : public FakeContainer { +public: + using FakeContainer::FakeContainer; + void setOpen(bool open) override { set("open", formatBool(open)); } + void setTitle(std::string_view title) override { set("title", formatText(title)); } + void setOnDismiss(Action onDismiss) override { callbacks().dismiss = std::move(onDismiss); } +}; + +/// @brief A fake `TableWidget`: properties `columns`, `selectionMode`, `selection`, and `rowKey` on a row. +/// +/// `selection` shows the rows the widget marks: the requested keys, in the order requested, that one of its current +/// rows has. The requested keys are kept, so a row that arrives with one of them is marked and a row that goes is +/// unmarked; that re-marking is the widget's own doing and is not logged. +class FakeTable final : public FakeContainer { +public: + /// @brief Records the new table, appended to @p parent, and logs its creation. + /// @param store Where the widget records itself. + /// @param kind The kind shown in the log and the dump. + /// @param parent The container it is appended to, or null for a root. + FakeTable(std::shared_ptr store, std::string kind, ContainerWidget const* parent) + : FakeContainer{std::move(store), std::move(kind), parent} { + this->store().at(id()).onChildDestroyed = [this](int childId) { + _rowKeys.erase(childId); + remark(); + }; + } + + /// @brief Removes the hook that points at this table, so nothing calls it once the table is gone, even if the + /// record outlives it because recording the destruction failed. + ~FakeTable() override { + try { + store().at(id()).onChildDestroyed = nullptr; + } catch (...) { // NOLINT(bugprone-empty-catch) + // No record left, so no hook to remove. + } + } + + FakeTable(FakeTable const&) = delete; + FakeTable& operator=(FakeTable const&) = delete; + FakeTable(FakeTable&&) = delete; + FakeTable& operator=(FakeTable&&) = delete; + + /// @brief Records property `columns`, each as its escaped caption, a colon and its width. + /// @param columns The columns, in order. + void setColumns(std::vector const& columns) override { + set("columns", formatList(columns, [](TableColumn const& column) { + return formatItem(column.label) + ":" + formatSizing(column.width); + })); + } + + /// @brief Records property `selectionMode`, which `RecordingBackend::selectRows` enforces. + /// @param mode None, one, or any number. + void setSelectionMode(SelectionMode mode) override { + _mode = mode; + set("selectionMode", enumName(mode)); + } + + /// @brief Records property `rowKey` on @p row, and marks the row when its key is requested. + /// @param row One of this table's rows. + /// @param key The row's key. + /// @throws std::logic_error when @p row is not this table's child. + void setRowKey(Widget& row, Key const& key) override { + int const rowId = store().idOf(row); + if (store().at(rowId).parent != id()) { + throw std::logic_error{"RecordingBackend: setRowKey of a widget that is not this table's row"}; + } + store().set(rowId, "rowKey", formatKey(key)); + _rowKeys.insert_or_assign(rowId, key); + remark(); + } + + /// @brief Requests the rows with @p keys, and records property `selection` as the rows it marks. + /// @param keys The selected keys. + void setSelection(std::vector const& keys) override { + _requested = keys; + set("selection", marked()); + } + + /// @brief Stores the handler `RecordingBackend::selectRows` calls. + /// @param onSelectionChange The handler. + void setOnSelectionChange(std::function)> onSelectionChange) override { + callbacks().selection = std::move(onSelectionChange); + } + + /// @brief Stores the handler `RecordingBackend::activateRow` calls. + /// @param onActivate The handler. + void setOnActivate(std::function onActivate) override { callbacks().activate = std::move(onActivate); } + + /// @brief The selection mode last set. + /// @return The mode; `None` until `setSelectionMode` runs. + [[nodiscard]] SelectionMode mode() const noexcept { return _mode; } + + /// @brief The current row with @p key. + /// @param key The key. + /// @return The row's id, or `nullopt` when no current row has @p key. + [[nodiscard]] std::optional rowOf(Key const& key) const { + for (int const child : store().at(id()).children) { + if (auto const found = _rowKeys.find(child); found != _rowKeys.end() && found->second == key) { + return child; + } + } + return std::nullopt; + } + + /// @brief Marks the rows the user selected, as the requested keys; nothing is logged. + /// @param keys Keys of current rows. + void selectByUser(std::vector const& keys) { + _requested = keys; + remark(); + } + +private: + [[nodiscard]] std::string marked() const { + std::vector keys; + for (Key const& key : _requested.value_or(std::vector{})) { + if (rowOf(key).has_value()) { + keys.push_back(key); + } + } + return formatList(keys, [](Key const& key) { return formatKey(key); }); + } + + void remark() { + if (_requested.has_value()) { + store().setByUser(id(), "selection", marked()); + } + } + + SelectionMode _mode = SelectionMode::None; + std::map _rowKeys; // row id -> key, as setRowKey told it + // Engaged once `setSelection` ran or the user selected. + std::optional> _requested; +}; + +} // namespace detail + +/// @brief A headless `IViewBackend`: fake widgets that record every operation, a golden dump of the live tree, and +/// helpers that act as the user would. +/// +/// Widget ids are creation numbers from 1. A widget ignores every interaction helper, as a real one would, while it +/// is hidden or disabled, or while a container around it is hidden, disabled or collapsed. A dialog, and everything +/// inside it, ignores them until it is opened. A helper used on a kind of widget it does not apply to throws +/// `std::logic_error`: that is a mistake in the test, not something a user can do. A helper copies the handler before +/// calling it, so a handler may destroy its own widget. +/// +/// Free text in a property value is escaped as `detail::formatText` and `detail::formatItem` describe, so every +/// value stays on its line and reads as one field, or one list element. +class RecordingBackend final : public IViewBackend { +public: + [[nodiscard]] std::unique_ptr createText(ContainerWidget* parent) override { + return std::make_unique(_store, "Text", parent); + } + [[nodiscard]] std::unique_ptr createButton(ContainerWidget* parent) override { + return std::make_unique(_store, "Button", parent); + } + [[nodiscard]] std::unique_ptr createTextInput(ContainerWidget* parent, + TextInputMode mode) override { + return std::make_unique(_store, "TextInput", parent, + detail::PropList{{"mode", detail::enumName(mode)}}); + } + [[nodiscard]] std::unique_ptr createCheckbox(ContainerWidget* parent) override { + return std::make_unique(_store, "Checkbox", parent); + } + [[nodiscard]] std::unique_ptr createSelect(ContainerWidget* parent, SelectStyle style) override { + return std::make_unique(_store, "Select", parent, + detail::PropList{{"style", detail::enumName(style)}}); + } + [[nodiscard]] std::unique_ptr createMenu(ContainerWidget* parent) override { + return std::make_unique(_store, "Menu", parent); + } + [[nodiscard]] std::unique_ptr createStack(ContainerWidget* parent, Axis axis) override { + return std::make_unique(_store, axis == Axis::Vertical ? "Column" : "Row", parent); + } + [[nodiscard]] std::unique_ptr createGrid(ContainerWidget* parent) override { + return std::make_unique(_store, "Grid", parent); + } + [[nodiscard]] std::unique_ptr createSpacer(ContainerWidget* parent) override { + return std::make_unique>(_store, "Spacer", parent); + } + [[nodiscard]] std::unique_ptr createPanel(ContainerWidget* parent) override { + return std::make_unique(_store, "Panel", parent); + } + [[nodiscard]] std::unique_ptr createScroll(ContainerWidget* parent, Axis axis) override { + return std::make_unique>( + _store, "Scroll", parent, detail::PropList{{"axis", detail::enumName(axis)}}); + } + [[nodiscard]] std::unique_ptr createSlot(ContainerWidget* parent) override { + return std::make_unique>(_store, "Slot", parent); + } + [[nodiscard]] std::unique_ptr createTabs(ContainerWidget* parent) override { + return std::make_unique(_store, "Tabs", parent); + } + [[nodiscard]] std::unique_ptr createDialog(ContainerWidget* parent) override { + return std::make_unique(_store, "Dialog", parent); + } + [[nodiscard]] std::unique_ptr createBusy(ContainerWidget* parent) override { + return std::make_unique(_store, "Busy", parent); + } + [[nodiscard]] std::unique_ptr createTable(ContainerWidget* parent) override { + return std::make_unique(_store, "Table", parent); + } + [[nodiscard]] std::unique_ptr createDateTimeInput(ContainerWidget* parent, DateMode mode, + int offsetMinutes) override { + return std::make_unique( + _store, "DateTimeInput", parent, + detail::PropList{{"mode", detail::enumName(mode)}, {"offset", std::to_string(offsetMinutes)}}); + } + [[nodiscard]] std::unique_ptr createSlider(ContainerWidget* parent) override { + return std::make_unique(_store, "Slider", parent); + } + [[nodiscard]] std::unique_ptr createFilePicker(ContainerWidget* parent, + FilePickerMode mode) override { + return std::make_unique(_store, "FilePicker", parent, + detail::PropList{{"mode", detail::enumName(mode)}}); + } + + /// @brief The golden tree of the live widgets. + /// @return One line per widget, `Kind#id name=value …` in property-name order, children indented two spaces + /// per depth, each line ending in a newline. + [[nodiscard]] std::string dump() const { return _store->dump(); } + + /// @brief The operation log since construction or the last `clearLog()`. + /// @return `create Kind#id in Parent#id` (`in root` for a root), `set Kind#id name=value`, + /// `move Kind#id to n`, `destroy Kind#id`, oldest first. Interaction helpers add nothing. + [[nodiscard]] std::vector const& log() const { return _store->log(); } + + /// @brief Empties the operation log. + void clearLog() { _store->clearLog(); } + + /// @brief The first live widget, by id, of a kind whose property has a value. + /// @param kind The kind, as the dump shows it. + /// @param name The property. + /// @param value Its formatted value. + /// @return The widget's id, or `nullopt`. + // The contract's lookup: kind, property name and value, all text. + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) + [[nodiscard]] std::optional find(std::string_view kind, std::string_view name, std::string_view value) const { + for (auto const& [widgetId, record] : _store->records()) { + if (record.kind != kind) { + continue; + } + if (auto const found = record.props.find(name); found != record.props.end() && found->second == value) { + return widgetId; + } + } + return std::nullopt; + } + + /// @brief Every live widget of a kind. + /// @param kind The kind, as the dump shows it. + /// @return Their ids, in creation order. + [[nodiscard]] std::vector all(std::string_view kind) const { + std::vector ids; + for (auto const& [widgetId, record] : _store->records()) { + if (record.kind == kind) { + ids.push_back(widgetId); + } + } + return ids; + } + + /// @brief A property's formatted value, escaped as the dump shows it. + /// @param widgetId The widget. + /// @param name The property. + /// @return The value, or an empty string when it was never set; `hasProp` tells the two apart. + /// @throws std::out_of_range for an id that names no live widget. + [[nodiscard]] std::string prop(int widgetId, std::string_view name) const { + auto const& props = _store->at(widgetId).props; + auto const found = props.find(name); + return found == props.end() ? std::string{} : found->second; + } + + /// @brief Whether a property was ever set, by a setter, at creation or by an interaction helper. + /// @param widgetId The widget. + /// @param name The property. + /// @return True once it was set, even to an empty value. + /// @throws std::out_of_range for an id that names no live widget. + [[nodiscard]] bool hasProp(int widgetId, std::string_view name) const { + return _store->at(widgetId).props.contains(name); + } + + /// @brief Whether a widget is alive. + /// @param widgetId The widget. + /// @return False once its destructor ran. + [[nodiscard]] bool exists(int widgetId) const { return _store->records().contains(widgetId); } + + /// @brief The id of one of this backend's live widgets. + /// @param widget The widget. + /// @return Its id. + /// @throws std::out_of_range when @p widget is not one. + [[nodiscard]] int idOf(Widget const& widget) const { return _store->idOf(widget); } + + /// @brief A live widget's kind. + /// @param widgetId The widget. + /// @return The kind, as the dump shows it. + /// @throws std::out_of_range for an id that names no live widget. + [[nodiscard]] std::string kindOf(int widgetId) const { return _store->at(widgetId).kind; } + + /// @brief A live widget's children. + /// @param widgetId The widget. + /// @return Their ids, in order. + /// @throws std::out_of_range for an id that names no live widget. + [[nodiscard]] std::vector children(int widgetId) const { return _store->at(widgetId).children; } + + /// @brief A live widget object. + /// @param widgetId The widget. + /// @return The widget. + /// @throws std::out_of_range for an id that names no live widget. + [[nodiscard]] Widget* widget(int widgetId) const { return _store->at(widgetId).widget; } + + /// @brief Activates a button. + /// @param widgetId The button. + /// @throws std::logic_error when the widget is not a button. + void click(int widgetId) { + if (detail::Record const* const record = actionable(widgetId, "click", {"Button"}); record != nullptr) { + invoke(record->callbacks.click); + } + } + + /// @brief Types into a text field: the field shows @p text, then `onChange` gets it. + /// @param widgetId The field. + /// @param text The whole new text. + /// @throws std::logic_error when the widget is not a text field. + void edit(int widgetId, std::string text) { + if (detail::Record const* const record = actionable(widgetId, "edit", {"TextInput"}); record != nullptr) { + _store->setByUser(widgetId, "text", detail::formatText(text)); + invoke(record->callbacks.change, std::move(text)); + } + } + + /// @brief Submits a text field with @p text. + /// @param widgetId The field. + /// @param text The text submitted. + /// @throws std::logic_error when the widget is not a text field. + void submit(int widgetId, std::string text) { + if (detail::Record const* const record = actionable(widgetId, "submit", {"TextInput"}); record != nullptr) { + _store->setByUser(widgetId, "text", detail::formatText(text)); + invoke(record->callbacks.submit, std::move(text)); + } + } + + /// @brief Toggles a check box: it flips its own `checked`, then `onToggle` gets the new state. + /// @param widgetId The check box. + /// @throws std::logic_error when the widget is not a check box. + void toggle(int widgetId) { + if (detail::Record const* const record = actionable(widgetId, "toggle", {"Checkbox"}); record != nullptr) { + bool const checked = prop(widgetId, "checked") != "true"; + _store->setByUser(widgetId, "checked", detail::formatBool(checked)); + invoke(record->callbacks.toggle, checked); + } + } + + /// @brief Chooses an option of a select: it marks the option, then `onSelect` gets the key. + /// @param widgetId The select. + /// @param key The key of one of its current options. + /// @throws std::logic_error when the widget is not a select, or none of its options has @p key: a user can only + /// choose what is offered. + void choose(int widgetId, Key key) { + detail::Record const& record = expectKind(widgetId, "choose", {"Select"}); + auto* const select = dynamic_cast(record.widget); + if (select == nullptr || !select->offers(key)) { + throw std::logic_error{"RecordingBackend: choose of " + detail::formatKey(key) + ", which Select#" + + std::to_string(widgetId) + " does not offer"}; + } + if (reachable(record)) { + select->chooseByUser(key); + invoke(record.callbacks.select, std::move(key)); + } + } + + /// @brief Chooses an entry of a menu, or a tab of a tab bar (which marks it selected). + /// @param widgetId The menu or tab bar. + /// @param index The entry or tab. + /// @throws std::logic_error when the widget is neither a menu nor a tab bar. + void chooseIndex(int widgetId, std::size_t index) { + if (detail::Record const* const record = actionable(widgetId, "chooseIndex", {"Menu", "Tabs"}); + record != nullptr) { + if (record->kind == "Tabs") { + _store->setByUser(widgetId, "selected", std::to_string(index)); + } + invoke(record->callbacks.index, index); + } + } + + /// @brief Collapses or expands a collapsible panel with its own collapse control, which works while the panel is + /// collapsed. + /// @param widgetId The panel. + /// @param collapsed The requested state. + /// @throws std::logic_error when the widget is not a panel, or the panel is not collapsible and so has no + /// collapse control. + void collapse(int widgetId, bool collapsed) { + detail::Record const& record = expectKind(widgetId, "collapse", {"Panel"}); + if (prop(widgetId, "collapsible") != "true") { + throw std::logic_error{"RecordingBackend: collapse of Panel#" + std::to_string(widgetId) + + ", which is not collapsible"}; + } + if (reachable(record)) { + _store->setByUser(widgetId, "collapsed", detail::formatBool(collapsed)); + invoke(record.callbacks.toggle, collapsed); + } + } + + /// @brief Dismisses an open dialog, as Esc does on the TUI. + /// @param widgetId The dialog. + /// @throws std::logic_error when the widget is not a dialog. + void dismiss(int widgetId) { + if (detail::Record const* const record = actionable(widgetId, "dismiss", {"Dialog"}); record != nullptr) { + invoke(record->callbacks.dismiss); + } + } + + /// @brief Selects rows of a table by key: the table marks them, then `onSelectionChange` gets the keys. + /// @param widgetId The table. + /// @param keys The keys of every row the user now selects, in the order selected. + /// @throws std::logic_error when the widget is not a table, or the selection is not one a user could make: a key + /// that names no current row, a row twice, any row in a `None` table, or more than one in a `Single` one. + void selectRows(int widgetId, std::vector keys) { + detail::Record const& record = expectKind(widgetId, "selectRows", {"Table"}); + auto* const table = dynamic_cast(record.widget); + std::string const where = " of Table#" + std::to_string(widgetId); + if (table == nullptr || table->mode() == SelectionMode::None) { + throw std::logic_error{"RecordingBackend: selectRows" + where + ", which selects nothing"}; + } + if (table->mode() == SelectionMode::Single && keys.size() > 1) { + throw std::logic_error{"RecordingBackend: selectRows of several rows" + where + ", which selects one"}; + } + std::unordered_set seen; + for (Key const& key : keys) { + if (!table->rowOf(key).has_value() || !seen.insert(key).second) { + throw std::logic_error{"RecordingBackend: selectRows of " + detail::formatKey(key) + where + + ", which has no such row or selects it twice"}; + } + } + if (reachable(record)) { + table->selectByUser(keys); + invoke(record.callbacks.selection, std::move(keys)); + } + } + + /// @brief Activates a row of a table (Enter or a double click). + /// @param widgetId The table. + /// @param key The row's key. + /// @throws std::logic_error when the widget is not a table, or none of its current rows has @p key. + void activateRow(int widgetId, Key key) { + detail::Record const& record = expectKind(widgetId, "activateRow", {"Table"}); + auto const* const table = dynamic_cast(record.widget); + std::optional const rowId = table == nullptr ? std::nullopt : table->rowOf(key); + if (!rowId.has_value()) { + throw std::logic_error{"RecordingBackend: activateRow of " + detail::formatKey(key) + ", which Table#" + + std::to_string(widgetId) + " has no row for"}; + } + if (reachable(_store->at(*rowId))) { + invoke(record.callbacks.activate, std::move(key)); + } + } + + /// @brief Enters a date-time, or clears the field with `nullopt`. + /// @param widgetId The field. + /// @param value The instant entered. + /// @throws std::logic_error when the widget is not a date-time field. + void setDateTime(int widgetId, std::optional value) { + if (detail::Record const* const record = actionable(widgetId, "setDateTime", {"DateTimeInput"}); + record != nullptr) { + _store->setByUser(widgetId, "value", detail::formatTimestamp(value)); + invoke(record->callbacks.dateTime, value); + } + } + + /// @brief Moves a slider. + /// @param widgetId The slider. + /// @param value The value moved to. + /// @throws std::logic_error when the widget is not a slider. + void slide(int widgetId, std::int64_t value) { + if (detail::Record const* const record = actionable(widgetId, "slide", {"Slider"}); record != nullptr) { + _store->setByUser(widgetId, "value", std::to_string(value)); + invoke(record->callbacks.slide, value); + } + } + + /// @brief Picks a path in a file picker. + /// @param widgetId The picker. + /// @param path The path picked. + /// @throws std::logic_error when the widget is not a file picker. + void pick(int widgetId, std::string path) { + if (detail::Record const* const record = actionable(widgetId, "pick", {"FilePicker"}); record != nullptr) { + _store->setByUser(widgetId, "path", detail::formatText(path)); + invoke(record->callbacks.picked, std::move(path)); + } + } + + /// @brief Drags one widget onto another. Any kind of widget can be a source or a target. + /// + /// An empty `accepts` refuses every key: the mount always passes a predicate, so a drop that lands without + /// one shows the mount's accept-everything default at work rather than this backend's leniency. + /// @param sourceId The dragged widget; it must have a drag key. + /// @param targetId The widget dropped on; it must be a drop target whose `accepts` holds for the key. + /// @return True when the drop was accepted and `onDrop` ran. + /// @throws std::logic_error when @p sourceId and @p targetId are the same widget. + bool drag(int sourceId, int targetId) { + if (sourceId == targetId) { + throw std::logic_error{"RecordingBackend: drag of a widget onto itself"}; + } + detail::Record const& source = _store->at(sourceId); + detail::Record const& target = _store->at(targetId); + if (!reachable(source) || !reachable(target)) { + return false; + } + std::optional const key = source.dragKey; + auto const accepts = target.callbacks.accepts; + auto const drop = target.callbacks.drop; + if (!key.has_value() || !drop || !accepts || !accepts(*key)) { + return false; + } + drop(*key); + return true; + } + +private: + // A helper applies to some kinds only; using it on another kind is a mistake in the test, not a user action. + detail::Record& expectKind(int widgetId, std::string_view helper, std::initializer_list kinds) { + detail::Record& record = _store->at(widgetId); + if (std::ranges::find(kinds, std::string_view{record.kind}) == kinds.end()) { + throw std::logic_error{"RecordingBackend: " + std::string{helper} + " does not apply to " + record.kind + + "#" + std::to_string(widgetId)}; + } + return record; + } + + // The widget's record when a user could reach it now, else null. + [[nodiscard]] detail::Record* actionable(int widgetId, std::string_view helper, + std::initializer_list kinds) { + detail::Record& record = expectKind(widgetId, helper, kinds); + return reachable(record) ? &record : nullptr; + } + + // A user reaches a widget that is shown and enabled, inside containers that are all shown, enabled and not + // collapsed. A collapsed panel's own collapse control stays reachable, so only an outer panel's state counts. A + // dialog shows nothing, itself included, until it is opened. + [[nodiscard]] bool reachable(detail::Record const& record) const { + if (flagIs(record, "visible", false) || flagIs(record, "enabled", false) || isClosedDialog(record)) { + return false; + } + for (int outerId = record.parent; outerId != 0;) { + detail::Record const& outer = _store->at(outerId); + if (flagIs(outer, "visible", false) || flagIs(outer, "enabled", false) || + flagIs(outer, "collapsed", true) || isClosedDialog(outer)) { + return false; + } + outerId = outer.parent; + } + return true; + } + + [[nodiscard]] static bool isClosedDialog(detail::Record const& record) { + return record.kind == "Dialog" && !flagIs(record, "open", true); + } + + [[nodiscard]] static bool flagIs(detail::Record const& record, std::string_view name, bool value) { + auto const found = record.props.find(name); + return found != record.props.end() && found->second == detail::formatBool(value); + } + + // The copy keeps the handler alive while it runs, even when it destroys the widget it was stored on. + template + static void invoke(std::function const& handler, Values&&... values) { + auto const copy = handler; + if (copy) { + copy(std::forward(values)...); + } + } + + std::shared_ptr _store = std::make_shared(); +}; + +} // namespace morph::ui::testing diff --git a/include/morph/ui/view.hpp b/include/morph/ui/view.hpp new file mode 100644 index 000000000..1a3c62e84 --- /dev/null +++ b/include/morph/ui/view.hpp @@ -0,0 +1,924 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../reactive/runtime.hpp" +#include "../reactive/signal.hpp" +#include "../util/datetime.hpp" + +/// @file +/// @brief The toolkit-agnostic view tree: props, node kinds and their builders. +/// +/// Specified in `docs/spec/ui/view_tree.md`. + +namespace morph::ui { + +/// @brief A widget event handler taking no arguments. +using Action = std::function; + +/// @brief The identity of a Switch case, a Select option, a ForEach or Table row and a drag payload. +/// +/// Never a floating-point number: morph ids can exceed 2^53. +using Key = std::variant; + +namespace detail { + +/// @brief Whether `From` may become a constant `Prop`: an implicit conversion that does not silently change +/// the kind of value. +/// +/// Two implicit conversions are refused because they compile and then show something else. A pointer, a string +/// literal included, converts to `bool` as "not null", so `Prop{"false"}` would be `true`. A floating-point +/// value converts to an integral type by truncation, so `Prop{2.9}` would be `2`. Other narrowing, +/// such as `int` to `std::size_t`, is kept: `Prop count = 0` is the ordinary way to write a count. +/// +/// `From` is the source type as deduced for a forwarding reference; `To` is the property's value type. +template +concept ConstantConvertible = std::convertible_to && + !(std::is_same_v, bool> && std::is_pointer_v>) && + !(std::is_integral_v && std::is_floating_point_v>); + +} // namespace detail + +/// @brief A node property: a constant, or a binding the mount re-evaluates reactively. +/// +/// A constant is set on the widget once and creates no reactive node. A binding becomes an equality-gated +/// `reactive::Computed` plus an `reactive::Effect` calling one widget setter. +/// +/// A braced list cannot initialise a `Prop`, because the constant constructor deduces its argument's type: name +/// the type, as in `.options = std::vector{{...}, {...}}`. +/// @tparam T The property's value type. +template +class Prop { +public: + /// @brief The value-initialised constant. + Prop() + requires std::default_initializable + : _value{std::in_place_index<0>} {} + + /// @brief A constant. + /// + /// A callable is never a constant, even when it converts to `T`: a captureless lambda converts to a function + /// pointer and that to `bool`, so `Prop{[] { return false; }}` would otherwise be the constant `true`. + /// + /// A pointer does not become a `bool` constant and a floating-point value does not become an integral one; see + /// `detail::ConstantConvertible`. + /// @tparam U A non-callable type that converts to `T` without changing the kind of value. + /// @param value The constant. + template + requires(!std::invocable) && detail::ConstantConvertible + Prop(U&& value) + // A string literal converting to `T` decays here; that is the conversion. + // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-array-to-pointer-decay) + : _value{std::in_place_index<0>, static_cast(std::forward(value))} {} + + /// @brief A binding. + /// + /// The binding must be callable: an empty `std::function` or a null function pointer is refused here, where + /// the mistake is made, rather than when a mount first calls it. + /// @tparam F A callable taking no arguments and returning something convertible to `T`. + /// @param binding Evaluated by the mount inside a `Computed`; every signal it reads is a dependency. + /// @throws std::invalid_argument when `binding` is an empty `std::function` or a null function pointer. + template + requires std::invocable && std::convertible_to, T> + Prop(F binding) : _value{std::in_place_index<1>, std::function{std::move(binding)}} { + if (!std::get<1>(_value)) { + throw std::invalid_argument{"ui::Prop: a binding must not be empty"}; + } + } + + /// @brief Whether this is a binding. + /// @return True for a binding, false for a constant. + [[nodiscard]] bool isBound() const noexcept { return _value.index() == 1; } + + /// @brief The constant. Only valid when `!isBound()`. + /// @return The constant. + /// @throws std::bad_variant_access when this is a binding. + [[nodiscard]] T const& constant() const { return std::get<0>(_value); } + + /// @brief The binding. Only valid when `isBound()`. + /// @return The binding. + /// @throws std::bad_variant_access when this is a constant. + [[nodiscard]] std::function const& binding() const { return std::get<1>(_value); } + + /// @brief The current value: the constant, or the binding called once. + /// @return The value. + [[nodiscard]] T evaluate() const { return isBound() ? std::get<1>(_value)() : std::get<0>(_value); } + +private: + std::variant> _value; +}; + +/// @brief How a `Text` is styled; each backend maps a role to its theme. +enum class TextRole : std::uint8_t { + Normal, ///< Body text. + Muted, ///< Secondary text. + Heading, ///< A section heading. + Error, ///< An error message. + Success, ///< A confirmation. +}; + +/// @brief How a `TextInput` edits. +enum class TextInputMode : std::uint8_t { + SingleLine, ///< One line; Enter submits. + Multiline, ///< Several lines. + Password, ///< One line, masked. +}; + +/// @brief How a `Select` presents its options. +enum class SelectStyle : std::uint8_t { + Dropdown, ///< A closed field that opens a list. + Radio, ///< Every option visible, one marked. +}; + +/// @brief The direction a stack arranges, or a scroll area scrolls, its content. +enum class Axis : std::uint8_t { + Vertical, ///< Top to bottom. + Horizontal, ///< Left to right. +}; + +/// @brief What a `DateTimeInput` edits. +enum class DateMode : std::uint8_t { + Date, ///< A calendar date; the time of day is midnight in the display zone. + DateTime, ///< A date and a time of day. +}; + +/// @brief Whether a `FilePicker` chooses an existing file or a file to write. +enum class FilePickerMode : std::uint8_t { + Open, ///< An existing file. + Save, ///< A file to create or overwrite. +}; + +/// @brief How many rows of a `Table` the user can select. +enum class SelectionMode : std::uint8_t { + None, ///< Rows can be activated, not selected. + Single, ///< At most one row. + Multiple, ///< Any number of rows. +}; + +/// @brief How much space a widget asks for along one dimension, in backend units (a character cell on the TUI). +struct Sizing { + /// @brief The sizing rule. + enum class Kind : std::uint8_t { + Content, ///< As much as the content needs. + Fixed, ///< Exactly `amount` units. + Stretch, ///< A share of the leftover space, weighted by `amount`. + }; + /// @brief The rule. + Kind kind = Kind::Content; + /// @brief Units for `Fixed`, weight for `Stretch`, unused for `Content`. + int amount = 0; + + /// @brief Size to the content. + /// @return The sizing. + [[nodiscard]] static constexpr Sizing content() noexcept { return {}; } + + /// @brief A fixed size. + /// @param units The size in backend units. + /// @return The sizing. + [[nodiscard]] static constexpr Sizing fixed(int units) noexcept { return {.kind = Kind::Fixed, .amount = units}; } + + /// @brief A share of the leftover space. + /// @param weight The share's weight. + /// @return The sizing. + [[nodiscard]] static constexpr Sizing stretch(int weight = 1) noexcept { + return {.kind = Kind::Stretch, .amount = weight}; + } + + /// @brief Memberwise equality. + /// @return Whether both rules and amounts match. + bool operator==(Sizing const&) const = default; +}; + +/// @brief A widget's size request in both dimensions. +struct LayoutHints { + /// @brief Along the horizontal axis. + Sizing width{}; + /// @brief Along the vertical axis. + Sizing height{}; + + /// @brief Memberwise equality. + /// @return Whether both dimensions match. + bool operator==(LayoutHints const&) const = default; +}; + +/// @brief The part every node carries: visibility, enablement, layout, and drag-and-drop. +struct Common { + /// @brief Whether the widget is shown. + Prop visible = true; + /// @brief Whether the widget accepts input. + Prop enabled = true; + /// @brief The size request; set once. + LayoutHints layout{}; + /// @brief Engaged: the widget can be dragged, and a drop delivers this key. + Prop> dragKey; + /// @brief Whether a drop of a key is accepted; empty accepts every key. Used only when `onDrop` is set. + std::function accepts; + /// @brief Set: the widget is a drop target, called with the dropped key. + std::function onDrop; +}; + +struct NodeData; + +/// @brief A view-tree node: immutable, shared data. +using Node = std::shared_ptr; + +/// @brief Static or bound text. +struct Text { + /// @brief The text, UTF-8. + Prop text; + /// @brief The style. + Prop role = TextRole::Normal; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief A push button. +struct Button { + /// @brief The caption. + Prop label; + /// @brief Called on activation. + Action onClick; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief An editable text field. The backend never reports its own `setText` back through `onChange`. +struct TextInput { + /// @brief The text the field shows. + Prop value; + /// @brief Called with the new text after every user edit. + std::function onChange; + /// @brief Called with the text when the user submits (Enter in single-line mode). + std::function onSubmit; + /// @brief Shown while the field is empty. + Prop placeholder; + /// @brief Single-line, multiline or password; set once. + TextInputMode mode = TextInputMode::SingleLine; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief A labelled check box. +struct Checkbox { + /// @brief The caption. + Prop label; + /// @brief Whether it is checked. + Prop checked; + /// @brief Called with the new state when the user toggles it. + std::function onToggle; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief One option of a `Select`. +struct SelectOption { + /// @brief The option's identity. + Key key; + /// @brief The caption. + std::string label; + + /// @brief Memberwise equality. + /// @return Whether key and label match. + bool operator==(SelectOption const&) const = default; +}; + +/// @brief A choice of one option. +struct Select { + /// @brief The options, in order. + Prop> options; + /// @brief The selected key, or none. A key outside `options` shows no selection. + Prop> selected; + /// @brief Called with the key the user chose. + std::function onSelect; + /// @brief Dropdown or radio; set once. + SelectStyle style = SelectStyle::Dropdown; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief One entry of a `Menu`: its label and its handler in one place, so there are no parallel lists. +struct MenuItem { + /// @brief The caption. + Prop label; + /// @brief Called when the entry is chosen. + Action onSelect; +}; + +/// @brief A vertical list of commands. +struct Menu { + /// @brief The entries, in order; the list itself is set once, each label may be bound. + std::vector items; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief Children stacked top to bottom. +struct Column { + /// @brief The children, in order; a null child is skipped. + std::vector children; + /// @brief Space between children, in backend units. + int gap = 0; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief Children laid out left to right. +struct Row { + /// @brief The children, in order; a null child is skipped. + std::vector children; + /// @brief Space between children, in backend units. + int gap = 0; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief One cell of a `Grid`. +struct GridCell { + /// @brief The cell's content; a null node leaves no cell. + Node node; + /// @brief How many columns it spans. + int span = 1; +}; + +/// @brief Cells laid out row-major in a fixed number of columns. +struct Grid { + /// @brief The column count. + int columns = 1; + /// @brief The cells, row-major. + std::vector cells; + /// @brief Space between cells, in backend units. + int gap = 0; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief Empty space that takes what its layout hints ask for. +struct Spacer { + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief A titled frame around one child; a collapsible one is an accordion section. +struct Panel { + /// @brief The title. + Prop title; + /// @brief Space between frame and child, in backend units. + int padding = 0; + /// @brief The content; may be null. + Node child; + /// @brief Whether the user can collapse it; set once. + bool collapsible = false; + /// @brief Whether it is collapsed; used only when `collapsible`. + Prop collapsed = false; + /// @brief Called with the collapsed state the user asked for; used only when `collapsible`. + std::function onToggle; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief A scrollable viewport around one child. +struct Scroll { + /// @brief The content; may be null. + Node child; + /// @brief The scroll direction; set once. + Axis axis = Axis::Vertical; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief One case of a `Switch`. +struct SwitchCase { + /// @brief The selector value this case answers. + Key key; + /// @brief The content; may be null. + Node node; +}; + +/// @brief Shows the case whose key the selector yields; remounts only when that key changes. +struct Switch { + /// @brief Which case to show. + Prop selector; + /// @brief The cases; the first one with the selected key wins. + std::vector cases; + /// @brief Shown when no case matches; may be null, and then nothing is shown. + Node fallback; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief One page of `Tabs`. +struct Tab { + /// @brief The tab's caption; set once. + std::string label; + /// @brief The page content, mounted the first time the tab is selected. + Node node; +}; + +/// @brief A tab bar over pages. +struct Tabs { + /// @brief The pages, in order; set once. + std::vector tabs; + /// @brief The selected page's index; an index past the last page shows none. + Prop selected; + /// @brief Called with the index the user picked, and with the page still shown when a picked page fails to mount. + std::function onSelect; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief A modal overlay; its content is mounted only while it is open. +struct Dialog { + /// @brief Whether it is shown. + Prop open; + /// @brief The title. + Prop title; + /// @brief The content; may be null. + Node child; + /// @brief Called when the user dismisses it (Esc on the TUI). + Action onDismiss; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief A progress indicator. +struct Busy { + /// @brief Whether it animates. + Prop active; + /// @brief A caption beside it. + Prop label; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +namespace detail { + +/// @brief One mounted row of a ForEach or a Table: the row's signal, and the view built from it once. +class RowSlot { +public: + RowSlot() = default; + virtual ~RowSlot() = default; + RowSlot(RowSlot const&) = delete; + RowSlot& operator=(RowSlot const&) = delete; + RowSlot(RowSlot&&) = delete; + RowSlot& operator=(RowSlot&&) = delete; + + /// @brief Sets the row's signal to an entry of its session's latest snapshot; an equal row notifies nobody. + /// @param index A position in that snapshot. + virtual void assign(std::size_t index) = 0; + + /// @brief The view built for this row when the slot was made. + /// @return The row's node. + [[nodiscard]] virtual Node view() const = 0; +}; + +/// @brief One mount's use of a ForEach: the latest snapshot of the rows, and a way to make row slots from it. +class ForEachSession { +public: + ForEachSession() = default; + virtual ~ForEachSession() = default; + ForEachSession(ForEachSession const&) = delete; + ForEachSession& operator=(ForEachSession const&) = delete; + ForEachSession(ForEachSession&&) = delete; + ForEachSession& operator=(ForEachSession&&) = delete; + + /// @brief Reads the rows (tracked, inside the mount's Effect), keeps them as the snapshot, and returns their + /// keys. + /// @return One key per row, in order, duplicates included. + [[nodiscard]] virtual std::vector pull() = 0; + + /// @brief Makes the slot for one snapshot entry, building its view. The mount calls it untracked. + /// @param index A position in the latest snapshot. + /// @return The slot. It refers to this session, which must outlive it. + [[nodiscard]] virtual std::unique_ptr makeRow(std::size_t index) = 0; +}; + +/// @brief A ForEach's row source, key function and row view, with the row type erased. +class ForEachModel { +public: + ForEachModel() = default; + virtual ~ForEachModel() = default; + ForEachModel(ForEachModel const&) = delete; + ForEachModel& operator=(ForEachModel const&) = delete; + ForEachModel(ForEachModel&&) = delete; + ForEachModel& operator=(ForEachModel&&) = delete; + + /// @brief Starts one mount's session. + /// @param runtime The runtime the row signals belong to. + /// @return The session. It refers to this model, which must outlive it. + [[nodiscard]] virtual std::unique_ptr open(reactive::Runtime& runtime) const = 0; +}; + +/// @brief The typed model behind `forEach` and `table`. +/// @tparam RowT The row type; copyable. With an `operator==`, an unchanged row notifies nobody; without one, every +/// new snapshot notifies every kept row. +template +class TypedForEach final : public ForEachModel { +public: + /// @param rows Reads the rows; every signal it reads is a dependency of the ForEach. + /// @param keyOf A row's identity. + /// @param rowView Builds a row's view once, from a signal the mount keeps equal to the row. + /// @throws std::invalid_argument when any of the three is empty, as a `Prop` refuses an empty binding. + TypedForEach(std::function()> rows, std::function keyOf, + std::function const&)> rowView) + : _rows{nonEmpty(std::move(rows))}, + _keyOf{nonEmpty(std::move(keyOf))}, + _rowView{nonEmpty(std::move(rowView))} {} + + [[nodiscard]] std::unique_ptr open(reactive::Runtime& runtime) const override { + return std::make_unique(runtime, *this); + } + +private: + template + [[nodiscard]] static F nonEmpty(F function) { + if (!function) { + throw std::invalid_argument{"ui::forEach: the rows, the key function and the row view must not be empty"}; + } + return function; + } + + class Slot final : public RowSlot { + public: + Slot(reactive::Runtime& runtime, std::vector const& snapshot, std::size_t index, + TypedForEach const& model) + : _snapshot{&snapshot}, _row{runtime, snapshot.at(index)}, _view{model._rowView(_row)} {} + + void assign(std::size_t index) override { _row.set(_snapshot->at(index)); } + [[nodiscard]] Node view() const override { return _view; } + + private: + std::vector const* _snapshot; + reactive::Signal _row; + Node _view; + }; + + class Session final : public ForEachSession { + public: + Session(reactive::Runtime& runtime, TypedForEach const& model) : _rt{&runtime}, _model{&model} {} + + [[nodiscard]] std::vector pull() override { + _snapshot = _model->_rows(); + std::vector keys; + keys.reserve(_snapshot.size()); + for (RowT const& entry : _snapshot) { + keys.push_back(_model->_keyOf(entry)); + } + return keys; + } + + [[nodiscard]] std::unique_ptr makeRow(std::size_t index) override { + return std::make_unique(*_rt, _snapshot, index, *_model); + } + + private: + reactive::Runtime* _rt; + TypedForEach const* _model; + std::vector _snapshot; + }; + + std::function()> _rows; + std::function _keyOf; + std::function const&)> _rowView; +}; + +} // namespace detail + +/// @brief One widget per row of a keyed collection, updated in place when a row with the same key changes. +struct ForEach { + /// @brief The rows, the key function and the row view, type-erased; null shows no rows. + std::shared_ptr model; + /// @brief The direction rows are stacked in; set once. + Axis axis = Axis::Vertical; + /// @brief Space between rows, in backend units. + int gap = 0; + /// @brief Visibility, enablement, layout, drag-and-drop of the row container. + Common common{}; +}; + +/// @brief One column of a `Table`. +struct TableColumn { + /// @brief The header caption. + std::string label; + /// @brief The column's width request. + Sizing width = Sizing::content(); +}; + +/// @brief Keyed rows of cells under column headers. Each row's view is a `Row` whose children are the cells, one +/// per column. +struct Table { + /// @brief The columns, in order; set once. + std::vector columns; + /// @brief The rows, the key function and the row view, type-erased; null shows no rows. + std::shared_ptr rows; + /// @brief How many rows the user can select; set once. + SelectionMode selectionMode = SelectionMode::None; + /// @brief The selected rows' keys. A key with no row selects nothing until such a row appears. + Prop> selection; + /// @brief Called with the keys the user selected. + std::function)> onSelectionChange; + /// @brief Called with a row's key when the user activates it (Enter or a double click). + std::function onActivate; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief A date, or a date and time, edited in a display zone and reported as an instant. +struct DateTimeInput { + /// @brief The instant shown; `nullopt` (or an empty `Timestamp`) shows an empty field. + Prop> value; + /// @brief Called with the instant the user entered, or `nullopt` when they cleared the field. + std::function)> onChange; + /// @brief Date or date and time; set once. + DateMode mode = DateMode::DateTime; + /// @brief The display zone's offset from UTC, in minutes; set once. + int offsetMinutes = 0; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief An integer chosen on a range. +struct Slider { + /// @brief The value shown. + Prop value; + /// @brief The smallest value; set once. + std::int64_t minimum = 0; + /// @brief The largest value; set once. + std::int64_t maximum = 100; + /// @brief The increment between neighbouring values; set once. + std::int64_t step = 1; + /// @brief Called with the value the user moved to. + std::function onChange; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief A file path, typed or chosen in the platform's file dialog. +struct FilePicker { + /// @brief The path shown. + Prop path; + /// @brief Open an existing file or name one to save; set once. + FilePickerMode mode = FilePickerMode::Open; + /// @brief Called with the path the user picked. + std::function onPicked; + /// @brief Visibility, enablement, layout, drag-and-drop. + Common common{}; +}; + +/// @brief What a `Node` points at: exactly one node kind. +struct NodeData { + /// @brief The node. + std::variant + kind; +}; + +namespace detail { + +/// @brief Wraps one node aggregate into a shared node. +/// @tparam Kind The node aggregate type. +/// @param spec The node. +/// @return The shared node. +template +[[nodiscard]] Node makeNode(Kind spec) { + return std::make_shared(NodeData{.kind = std::move(spec)}); +} + +} // namespace detail + +/// @brief Static or bound text. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node text(Text spec) { return detail::makeNode(std::move(spec)); } + +/// @brief A push button. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node button(Button spec) { return detail::makeNode(std::move(spec)); } + +/// @brief An editable text field. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node textInput(TextInput spec) { return detail::makeNode(std::move(spec)); } + +/// @brief A labelled check box. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node checkbox(Checkbox spec) { return detail::makeNode(std::move(spec)); } + +/// @brief A choice of one option. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node select(Select spec) { return detail::makeNode(std::move(spec)); } + +/// @brief A list of commands. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node menu(Menu spec) { return detail::makeNode(std::move(spec)); } + +/// @brief Children stacked top to bottom. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node column(Column spec) { return detail::makeNode(std::move(spec)); } + +/// @brief Children laid out left to right. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node row(Row spec) { return detail::makeNode(std::move(spec)); } + +/// @brief Cells in a fixed number of columns. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node grid(Grid spec) { return detail::makeNode(std::move(spec)); } + +/// @brief Empty space. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node spacer(Spacer spec = {}) { return detail::makeNode(std::move(spec)); } + +/// @brief A titled frame, optionally collapsible. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node panel(Panel spec) { return detail::makeNode(std::move(spec)); } + +/// @brief A scrollable viewport. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node scroll(Scroll spec) { return detail::makeNode(std::move(spec)); } + +/// @brief Shows the case the selector picks. Spelled `switchOf` because `switch` is a keyword. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node switchOf(Switch spec) { return detail::makeNode(std::move(spec)); } + +/// @brief A tab bar over pages. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node tabs(Tabs spec) { return detail::makeNode(std::move(spec)); } + +/// @brief A modal overlay. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node dialog(Dialog spec) { return detail::makeNode(std::move(spec)); } + +/// @brief A progress indicator. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node busy(Busy spec) { return detail::makeNode(std::move(spec)); } + +/// @brief A date or date-and-time field. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node dateTimeInput(DateTimeInput spec) { return detail::makeNode(std::move(spec)); } + +/// @brief An integer on a range. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node slider(Slider spec) { return detail::makeNode(std::move(spec)); } + +/// @brief A file path field. +/// @param spec The node. +/// @return The node. +[[nodiscard]] inline Node filePicker(FilePicker spec) { return detail::makeNode(std::move(spec)); } + +/// @brief A Switch over an enumeration: each enumerator becomes the `int64` key of its case. +/// +/// Enumerators are compared as `int64`: an unsigned value above `INT64_MAX` wraps to a negative key, and distinct +/// enumerators still give distinct keys. +/// @tparam E The enumeration. +/// @param selector Which enumerator to show; every signal it reads is a dependency. +/// @param cases One node per enumerator shown. +/// @param fallback Shown when no case matches; may be null. +/// @return The node. +/// @throws std::invalid_argument when @p selector is empty, as a `Prop` refuses an empty binding. +template + requires std::is_enum_v +[[nodiscard]] Node switchOn(std::function selector, std::vector> cases, Node fallback = {}) { + if (!selector) { + throw std::invalid_argument{"ui::switchOn: the selector must not be empty"}; + } + std::vector keyed; + keyed.reserve(cases.size()); + for (auto& [value, node] : cases) { + keyed.push_back(SwitchCase{.key = Key{static_cast(value)}, .node = std::move(node)}); + } + Switch spec{ + .selector = [current = std::move(selector)] { return Key{static_cast(current())}; }, + .cases = std::move(keyed), + }; + spec.fallback = std::move(fallback); + return switchOf(std::move(spec)); +} + +/// @brief One widget per row of a keyed collection read through a binding (a `Query`'s `value()` included). +/// +/// A kept key keeps its widget, which a changed row updates in place; a new key is mounted, a gone key unmounted, and +/// the rows are brought into the snapshot's order by moving as few widgets as possible. +/// @tparam RowT The row type; copyable. +/// @param rows Reads the rows; every signal it reads is a dependency. +/// @param keyOf A row's identity; a later duplicate of a key is reported and refused, and the first one is kept. +/// @param rowView Builds a row's view once, from a signal the mount keeps equal to the row. Read the signal inside +/// bindings: what the view reads directly while it is built is read once and never again. +/// @param axis The stacking direction. +/// @param gap Space between rows. +/// @return The node. +/// @throws std::invalid_argument when @p rows, @p keyOf or @p rowView is empty. +template +[[nodiscard]] Node forEach(std::function()> rows, std::function keyOf, + std::function const&)> rowView, Axis axis = Axis::Vertical, + int gap = 0) { + return detail::makeNode(ForEach{ + .model = + std::make_shared const>(std::move(rows), std::move(keyOf), std::move(rowView)), + .axis = axis, + .gap = gap, + }); +} + +/// @brief One widget per row of a keyed collection held in a signal. +/// @tparam RowT The row type; copyable. +/// @param rows The signal; it must outlive every mount of the node. +/// @param keyOf A row's identity; a later duplicate of a key is reported and refused, and the first one is kept. +/// @param rowView Builds a row's view once, from a signal the mount keeps equal to the row. +/// @param axis The stacking direction. +/// @param gap Space between rows. +/// @return The node. +/// @throws std::invalid_argument when @p keyOf or @p rowView is empty. +template +[[nodiscard]] Node forEach(reactive::Signal> const& rows, std::function keyOf, + std::function const&)> rowView, Axis axis = Axis::Vertical, + int gap = 0) { + return forEach([&rows] { return rows.get(); }, std::move(keyOf), std::move(rowView), axis, gap); +} + +/// @brief Everything about a `table` beyond its columns and rows. +struct TableOptions { + /// @brief How many rows the user can select. + SelectionMode selectionMode = SelectionMode::None; + /// @brief The selected rows' keys. + Prop> selection; + /// @brief Called with the keys the user selected. + std::function)> onSelectionChange; + /// @brief Called with a row's key when the user activates it. + std::function onActivate; + /// @brief Visibility, enablement, layout, drag-and-drop of the table. + Common common{}; +}; + +/// @brief A table of keyed rows: one `Row` of cells per row, under the column headers. +/// +/// Rows are mounted, updated, unmounted and reordered as `forEach` does. A row whose cells are not exactly one +/// non-null node per column would shift the columns after it, so it is refused as a row that fails to mount is: +/// reported, left out, and tried again on the next change of the rows. +/// @tparam RowT The row type; copyable. +/// @param columns The columns, in order. +/// @param rows Reads the rows; every signal it reads is a dependency. +/// @param keyOf A row's identity; a later duplicate of a key is reported and refused, and the first one is kept. +/// @param cells Builds a row's cells once, one per column, from a signal the mount keeps equal to the row. Read the +/// signal inside bindings: what the cells read directly while they are built is read once and never again. +/// @param options Selection, activation and `Common`. +/// @return The node. +/// @throws std::invalid_argument when @p rows, @p keyOf or @p cells is empty. +template +[[nodiscard]] Node table(std::vector columns, std::function()> rows, + std::function keyOf, + std::function(reactive::Signal const&)> cells, + TableOptions options = {}) { + if (!rows || !keyOf || !cells) { + throw std::invalid_argument{"ui::table: the rows, the key function and the cells must not be empty"}; + } + std::function const&)> rowView = + [cellsOf = std::move(cells), count = columns.size()](reactive::Signal const& entry) { + std::vector rowCells = cellsOf(entry); + if (rowCells.size() != count || + std::ranges::any_of(rowCells, [](Node const& cell) { return cell == nullptr; })) { + throw std::invalid_argument{"ui::table: a row needs exactly one non-null cell per column"}; + } + return row(Row{.children = std::move(rowCells)}); + }; + Table spec{ + .rows = + std::make_shared const>(std::move(rows), std::move(keyOf), std::move(rowView)), + .selectionMode = options.selectionMode, + }; + spec.columns = std::move(columns); + spec.selection = std::move(options.selection); + spec.onSelectionChange = std::move(options.onSelectionChange); + spec.onActivate = std::move(options.onActivate); + spec.common = std::move(options.common); + return detail::makeNode(std::move(spec)); +} + +} // namespace morph::ui diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ef6ca05b1..e8b30544c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -155,6 +155,13 @@ add_executable(morph_tests test_reactive_query.cpp test_reactive_mutation.cpp test_reactive_control_bridge.cpp + test_ui_view.cpp + test_ui_recording_backend.cpp + test_ui_mount.cpp + test_ui_structure.cpp + test_ui_foreach.cpp + test_ui_table.cpp + test_ui_conformance.cpp test_cancellation_policy.cpp test_file_io_ops.cpp test_extension_bag_spike.cpp diff --git a/tests/test_ui_conformance.cpp b/tests/test_ui_conformance.cpp new file mode 100644 index 000000000..3b98af19f --- /dev/null +++ b/tests/test_ui_conformance.cpp @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace ui = morph::ui; + +namespace { + +// Undoes `detail::escape`: a backslash and `n` or `r` is a line ending, a backslash and anything else is that +// character. +std::string unescape(std::string_view text) { + std::string plain; + for (std::size_t i = 0; i < text.size(); ++i) { + char const character = text.at(i); + if (character != '\\' || i + 1 == text.size()) { + plain += character; + continue; + } + switch (char const escaped = text.at(++i)) { + case 'n': + plain += '\n'; + break; + case 'r': + plain += '\r'; + break; + default: + plain += escaped; + break; + } + } + return plain; +} + +// The elements of a `detail::formatList` list, still escaped: split at the commas no backslash escapes. +std::vector listItems(std::string_view list) { + std::vector items; + if (list.size() < 2 || list == "[]") { + return items; + } + std::string item; + for (std::size_t i = 1; i + 1 < list.size(); ++i) { + char const character = list.at(i); + if (character == '\\' && i + 2 < list.size()) { + item += character; + item += list.at(++i); + } else if (character == ',') { + items.push_back(std::move(item)); + item.clear(); + } else { + item += character; + } + } + items.push_back(std::move(item)); + return items; +} + +// Undoes `detail::formatKey`. +ui::Key parseKey(std::string_view text) { + if (text.starts_with('"') && text.size() >= 2) { + return ui::Key{unescape(text.substr(1, text.size() - 2))}; + } + return ui::Key{std::int64_t{std::stoll(std::string{text})}}; +} + +// The reference probe: what a TUI or Qt Quick probe does with a rendered screen or a QQuickItem tree, this one does +// with the recording backend's records. +class RecordingProbe final : public ui::testing::ConformanceProbe { +public: + ui::IViewBackend& backend() override { return _backend; } + morph::reactive::Runtime& runtime() override { return _rt; } + void settle() override { _owner.runAll(); } + + [[nodiscard]] std::string textOf(ui::Widget const& widget) override { + int const widgetId = _backend.idOf(widget); + std::string const kind = _backend.kindOf(widgetId); + if (kind == "Select") { + return markedLabel(widgetId); + } + return unescape(_backend.prop(widgetId, kind == "Button" || kind == "Checkbox" ? "label" : "text")); + } + [[nodiscard]] bool visibleOf(ui::Widget const& widget) override { + return _backend.prop(_backend.idOf(widget), "visible") != "false"; + } + [[nodiscard]] bool enabledOf(ui::Widget const& widget) override { + return _backend.prop(_backend.idOf(widget), "enabled") != "false"; + } + [[nodiscard]] std::size_t childCount(ui::ContainerWidget const& container) override { + return _backend.children(_backend.idOf(container)).size(); + } + [[nodiscard]] ui::Widget* childAt(ui::ContainerWidget const& container, std::size_t index) override { + auto const children = _backend.children(_backend.idOf(container)); + return index < children.size() ? _backend.widget(children.at(index)) : nullptr; + } + void click(ui::Widget& widget) override { _backend.click(_backend.idOf(widget)); } + void type(ui::Widget& widget, std::string_view text) override { + _backend.edit(_backend.idOf(widget), std::string{text}); + } + void drag(ui::Widget& source, ui::Widget& target) override { + static_cast(_backend.drag(_backend.idOf(source), _backend.idOf(target))); + } + void dismiss(ui::Widget& dialog) override { _backend.dismiss(_backend.idOf(dialog)); } + void selectRows(ui::Widget& table, std::vector const& rows) override { + int const tableId = _backend.idOf(table); + std::vector const children = _backend.children(tableId); + std::vector keys; + keys.reserve(rows.size()); + for (std::size_t const row : rows) { + keys.push_back(parseKey(_backend.prop(children.at(row), "rowKey"))); + } + _backend.selectRows(tableId, std::move(keys)); + } + [[nodiscard]] std::vector selectedRows(ui::Widget const& table) override { + int const tableId = _backend.idOf(table); + std::vector const marked = listItems(_backend.prop(tableId, "selection")); + std::vector const children = _backend.children(tableId); + std::vector rows; + for (std::size_t i = 0; i < children.size(); ++i) { + if (std::ranges::find(marked, _backend.prop(children.at(i), "rowKey")) != marked.end()) { + rows.push_back(i); + } + } + return rows; + } + +private: + // The label of the option whose key `selected` shows, from `options`' `key:label` elements. + [[nodiscard]] std::string markedLabel(int selectId) const { + std::string const selected = _backend.prop(selectId, "selected"); + if (selected.empty() || selected == "none") { + return {}; + } + for (std::string const& option : listItems(_backend.prop(selectId, "options"))) { + if (option.starts_with(selected + ":")) { + return unescape(std::string_view{option}.substr(selected.size() + 1)); + } + } + return {}; + } + + morph::testing::StepExecutor _owner; + ui::testing::RecordingBackend _backend; + morph::reactive::Runtime _rt{_owner}; +}; + +} // namespace + +TEST_CASE("ui conformance: RecordingBackend passes every case", "[ui][conformance]") { + auto const cases = ui::testing::conformanceCases(); + REQUIRE(cases.size() == 23); + std::set names; + for (auto const& testCase : cases) { + CHECK(names.insert(testCase.name).second); + RecordingProbe probe; + std::optional const failure = testCase.run(probe); + INFO(std::string{testCase.name} + ": " + failure.value_or("passed")); + CHECK_FALSE(failure.has_value()); + } +} diff --git a/tests/test_ui_foreach.cpp b/tests/test_ui_foreach.cpp new file mode 100644 index 000000000..59c5f4318 --- /dev/null +++ b/tests/test_ui_foreach.cpp @@ -0,0 +1,636 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" +#include "ui_echoing_backend.hpp" +#include "ui_test_support.hpp" + +namespace ui = morph::ui; + +namespace { + +using morph::reactive::Runtime; +using morph::reactive::Signal; +using morph::testing::EchoingBackend; +using morph::testing::failWhenSpent; +using ui::testing::RecordingBackend; +using Owner = morph::testing::StepExecutor; +using Probe = morph::testing::OwnerProbeRecorder; +using Lines = std::vector; + +struct Lap { + std::int64_t id = 0; + std::string label; + bool operator==(Lap const&) const = default; +}; + +struct Tag { + std::string name; + int uses = 0; + bool operator==(Tag const&) const = default; +}; + +// A row type that cannot be compared: every assignment of its row signal notifies. +struct Plain { + std::int64_t id = 0; + std::string label; +}; + +ui::Key keyOf(Lap const& lap) { return ui::Key{lap.id}; } + +ui::Node lapView(Signal const& lap) { + return ui::text({.text = [&lap] { return lap.get().label; }}); +} + +ui::Node lapList(Signal> const& rows) { return ui::forEach(rows, keyOf, lapView); } + +std::vector abc() { return {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}, {.id = 3, .label = "c"}}; } + +void removeLap(Signal>& rows, std::int64_t lapId) { + rows.mutate( + [lapId](std::vector& laps) { std::erase_if(laps, [lapId](Lap const& lap) { return lap.id == lapId; }); }); +} + +// A widget that does nothing; a FakeList's children. +class FakeItem final : public ui::Widget { +public: + void setVisible(bool /*visible*/) override {} + void setEnabled(bool /*enabled*/) override {} + void setLayout(ui::LayoutHints const& /*hints*/) override {} + void setDragKey(std::optional const& /*key*/) override {} + void setDropHandler(std::function /*accepts*/, + std::function /*onDrop*/) override {} +}; + +// A container that applies `moveChild` as the contract defines it and counts the moves; the move numbered +// `failingMove` (from 0), if any, throws instead. +class FakeList final : public ui::ContainerWidget { +public: + explicit FakeList(std::vector children, std::optional failingMove = std::nullopt) + : _children{std::move(children)}, _failingMove{failingMove} {} + + void setVisible(bool /*visible*/) override {} + void setEnabled(bool /*enabled*/) override {} + void setLayout(ui::LayoutHints const& /*hints*/) override {} + void setDragKey(std::optional const& /*key*/) override {} + void setDropHandler(std::function /*accepts*/, + std::function /*onDrop*/) override {} + + void moveChild(ui::Widget& child, std::size_t index) override { + if (_failingMove == _moves) { + throw std::runtime_error{"the backend could not move the widget"}; + } + ++_moves; + auto const found = std::ranges::find(_children, &child); + REQUIRE(found != _children.end()); + _children.erase(found); + _children.insert(_children.begin() + static_cast(std::min(index, _children.size())), &child); + } + + [[nodiscard]] std::vector const& children() const noexcept { return _children; } + [[nodiscard]] std::size_t moves() const noexcept { return _moves; } + +private: + std::vector _children; + std::optional _failingMove; + std::size_t _moves = 0; +}; + +// The length of a longest increasing run in @p sequence, by the quadratic definition. +std::size_t longestIncreasing(std::vector const& sequence) { + std::vector ending(sequence.size(), 1); + std::size_t longest = 0; + for (std::size_t i = 0; i < sequence.size(); ++i) { + for (std::size_t j = 0; j < i; ++j) { + if (sequence.at(j) < sequence.at(i)) { + ending.at(i) = std::max(ending.at(i), ending.at(j) + 1); + } + } + longest = std::max(longest, ending.at(i)); + } + return longest; +} + +} // namespace + +TEST_CASE("ui::forEach: a session snapshots the rows and makes a slot per row", "[ui]") { + Owner owner; + Runtime runtime{owner}; + Signal> rows{runtime, {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}}; + ui::Node const node = ui::forEach(rows, keyOf, lapView, ui::Axis::Horizontal, 2); + auto const& each = std::get(node->kind); + CHECK(each.axis == ui::Axis::Horizontal); + CHECK(each.gap == 2); + auto session = each.model->open(runtime); + CHECK(session->pull() == std::vector{ui::Key{std::int64_t{1}}, ui::Key{std::int64_t{2}}}); + auto slot = session->makeRow(1); + REQUIRE(slot->view() != nullptr); + CHECK(std::get(slot->view()->kind).text.evaluate() == "b"); + rows.set({{.id = 2, .label = "b2"}}); + CHECK(session->pull() == std::vector{ui::Key{std::int64_t{2}}}); + slot->assign(0); + CHECK(std::get(slot->view()->kind).text.evaluate() == "b2"); +} + +TEST_CASE("ui::forEach: an empty function is refused where it is written", "[ui]") { + Owner owner; + Runtime runtime{owner}; + Signal> const rows{runtime}; + CHECK_THROWS_AS(ui::forEach(std::function()>{}, keyOf, lapView), std::invalid_argument); + CHECK_THROWS_AS(ui::forEach(rows, std::function{}, lapView), std::invalid_argument); + CHECK_THROWS_AS(ui::forEach(rows, keyOf, std::function const&)>{}), + std::invalid_argument); +} + +TEST_CASE("ui::forEach: mounts one row per key, in order", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> const rows{runtime, abc()}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + CHECK(backend.dump() == + "Column#1 gap=0\n" + " Text#2 role=Normal text=a\n" + " Text#3 role=Normal text=b\n" + " Text#4 role=Normal text=c\n"); +} + +TEST_CASE("ui::forEach: an update of a kept key keeps its widget", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, abc()}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + backend.clearLog(); + rows.set({{.id = 1, .label = "a"}, {.id = 2, .label = "b2"}, {.id = 3, .label = "c"}}); + owner.runAll(); + CHECK(backend.log() == Lines{"set Text#3 text=b2"}); +} + +TEST_CASE("ui::forEach: an equal kept row notifies nobody; a row type without == notifies every kept row", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + + SECTION("a row type with ==") { + Signal> rows{runtime, abc()}; + int evaluations = 0; + ui::Mounted const view{runtime, backend, ui::forEach(rows, keyOf, [&evaluations](Signal const& lap) { + return ui::text({.text = [&evaluations, &lap] { + ++evaluations; + return lap.get().label; + }}); + })}; + CHECK(evaluations == 3); + rows.set({{.id = 1, .label = "a"}, {.id = 2, .label = "b2"}, {.id = 3, .label = "c"}}); + owner.runAll(); + CHECK(evaluations == 4); + } + SECTION("a row type without ==") { + Signal> rows{runtime, {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}}; + int evaluations = 0; + ui::Mounted const view{runtime, backend, + ui::forEach( + rows, [](Plain const& plain) { return ui::Key{plain.id}; }, + [&evaluations](Signal const& plain) { + return ui::text({.text = [&evaluations, &plain] { + ++evaluations; + return plain.get().label; + }}); + })}; + CHECK(evaluations == 2); + backend.clearLog(); + rows.set({{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}); + owner.runAll(); + CHECK(evaluations == 4); + CHECK(backend.log().empty()); // the text's own Computed is equality-gated + rows.set({{.id = 1, .label = "a"}, {.id = 2, .label = "b2"}}); + owner.runAll(); + CHECK(evaluations == 6); + CHECK(backend.log() == Lines{"set Text#3 text=b2"}); + } +} + +TEST_CASE("ui::forEach: an append only creates and a removal only destroys", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, abc()}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + + backend.clearLog(); + rows.mutate([](std::vector& laps) { laps.push_back({.id = 4, .label = "d"}); }); + owner.runAll(); + CHECK(backend.log() == Lines{"create Text#5 in Column#1", "set Text#5 text=d", "set Text#5 role=Normal"}); + + backend.clearLog(); + rows.set({{.id = 1, .label = "a"}, {.id = 3, .label = "c"}, {.id = 4, .label = "d"}}); + owner.runAll(); + CHECK(backend.log() == Lines{"destroy Text#3"}); +} + +TEST_CASE("ui::forEach: rows that go in one snapshot are unmounted last first", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, abc()}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + backend.clearLog(); + rows.set({{.id = 2, .label = "b"}}); + owner.runAll(); + CHECK(backend.log() == Lines{"destroy Text#4", "destroy Text#2"}); +} + +TEST_CASE("ui::forEach: an insert at the front moves only the new row", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + backend.clearLog(); + rows.set({{.id = 3, .label = "c"}, {.id = 1, .label = "a"}, {.id = 2, .label = "b"}}); + owner.runAll(); + CHECK(backend.log() == + Lines{"create Text#4 in Column#1", "set Text#4 text=c", "set Text#4 role=Normal", "move Text#4 to 0"}); +} + +TEST_CASE("ui::forEach: a key that changes while its index stays replaces that row", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, abc()}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + backend.clearLog(); + rows.set({{.id = 1, .label = "a"}, {.id = 5, .label = "b"}, {.id = 3, .label = "c"}}); + owner.runAll(); + CHECK(backend.log() == Lines{"destroy Text#3", "create Text#5 in Column#1", "set Text#5 text=b", + "set Text#5 role=Normal", "move Text#4 to 2"}); + CHECK(backend.dump() == + "Column#1 gap=0\n" + " Text#2 role=Normal text=a\n" + " Text#5 role=Normal text=b\n" + " Text#4 role=Normal text=c\n"); +} + +TEST_CASE("ui::forEach: a reorder moves only the rows outside the longest run in order", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + + SECTION("the last row to the front is one move") { + Signal> rows{runtime, abc()}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + backend.clearLog(); + rows.set({{.id = 3, .label = "c"}, {.id = 1, .label = "a"}, {.id = 2, .label = "b"}}); + owner.runAll(); + CHECK(backend.log() == Lines{"move Text#4 to 0"}); + } + SECTION("the first row to the end is one move") { + Signal> rows{runtime, abc()}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + backend.clearLog(); + rows.set({{.id = 2, .label = "b"}, {.id = 3, .label = "c"}, {.id = 1, .label = "a"}}); + owner.runAll(); + CHECK(backend.log() == Lines{"move Text#2 to 2"}); + } + SECTION("a reversal of three is two moves") { + Signal> rows{runtime, abc()}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + backend.clearLog(); + rows.set({{.id = 3, .label = "c"}, {.id = 2, .label = "b"}, {.id = 1, .label = "a"}}); + owner.runAll(); + CHECK(backend.log() == Lines{"move Text#2 to 2", "move Text#3 to 1"}); + CHECK(backend.dump() == + "Column#1 gap=0\n" + " Text#4 role=Normal text=c\n" + " Text#3 role=Normal text=b\n" + " Text#2 role=Normal text=a\n"); + } +} + +TEST_CASE("ui::detail::reorderChildren: every permutation of five ends in order with the fewest moves", "[ui]") { + std::array items; + std::vector start; + start.reserve(items.size()); + for (FakeItem& item : items) { + start.push_back(&item); + } + std::vector permutation{0, 1, 2, 3, 4}; + int permutations = 0; + for (bool more = true; more; more = std::ranges::next_permutation(permutation).found) { + std::vector target; + target.reserve(permutation.size()); + for (std::size_t const index : permutation) { + target.push_back(start.at(index)); + } + // Each child's target position, in current order. + std::vector sequence(start.size()); + for (std::size_t position = 0; position < permutation.size(); ++position) { + sequence.at(permutation.at(position)) = position; + } + FakeList list{start}; + std::vector current = start; + ui::detail::reorderChildren(list, current, target); + CHECK(list.children() == target); + CHECK(current == target); + CHECK(list.moves() == start.size() - longestIncreasing(sequence)); + ++permutations; + } + CHECK(permutations == 120); +} + +TEST_CASE("ui::detail::reorderChildren: a move that throws leaves the order as far as it got", "[ui]") { + std::array items; + std::vector const start{&items.at(0), &items.at(1), &items.at(2), &items.at(3)}; + std::vector const reversed{&items.at(3), &items.at(2), &items.at(1), &items.at(0)}; + FakeList list{start, 1}; + std::vector current = start; + CHECK_THROWS_AS(ui::detail::reorderChildren(list, current, reversed), std::runtime_error); + CHECK(list.moves() == 1); + CHECK(current == list.children()); + CHECK(current != start); +} + +TEST_CASE("ui::forEach: string keys are keys too", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> tags{runtime, {{.name = "red"}, {.name = "blue"}}}; + ui::Mounted const view{ + runtime, backend, + ui::forEach( + tags, [](Tag const& tag) { return ui::Key{tag.name}; }, + [](Signal const& tag) { + return ui::text({.text = [&tag] { return tag.get().name + " " + std::to_string(tag.get().uses); }}); + })}; + backend.clearLog(); + tags.set({{.name = "blue", .uses = 1}, {.name = "red"}}); + owner.runAll(); + // The move happens in the ForEach's own run; the row's binding runs after it in the same flush. + CHECK(backend.log() == Lines{"move Text#2 to 1", "set Text#3 text=blue 1"}); +} + +TEST_CASE("ui::forEach: a duplicate key is reported and refused; the first one is kept", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> const rows{runtime, + {{.id = 1, .label = "a"}, {.id = 1, .label = "dup"}, {.id = 2, .label = "b"}}}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + CHECK(probe.count(ui::detail::site::kDuplicateKey) == 1); + CHECK(backend.dump() == "Column#1 gap=0\n Text#2 role=Normal text=a\n Text#3 role=Normal text=b\n"); +} + +TEST_CASE("ui::forEach: a duplicate in a later snapshot is refused; the first one decides value and place", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + backend.clearLog(); + rows.set({{.id = 2, .label = "x"}, {.id = 1, .label = "a"}, {.id = 2, .label = "z"}}); + owner.runAll(); + CHECK(probe.count(ui::detail::site::kDuplicateKey) == 1); + CHECK(backend.log() == Lines{"move Text#2 to 1", "set Text#3 text=x"}); + CHECK(backend.dump() == "Column#1 gap=0\n Text#3 role=Normal text=x\n Text#2 role=Normal text=a\n"); +} + +TEST_CASE("ui::forEach: emptied then refilled", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, abc()}; + ui::Mounted const view{runtime, backend, lapList(rows)}; + rows.set({}); + owner.runAll(); + CHECK(backend.dump() == "Column#1 gap=0\n"); + CHECK(backend.all("Text").empty()); + rows.set({{.id = 9, .label = "z"}}); + owner.runAll(); + CHECK(backend.dump() == "Column#1 gap=0\n Text#5 role=Normal text=z\n"); +} + +TEST_CASE("ui::forEach: a null row view mounts no widget, and the other rows keep their order", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, abc()}; + ui::Mounted const view{runtime, backend, ui::forEach(rows, keyOf, [](Signal const& lap) -> ui::Node { + return lap.peek().id == 2 ? nullptr : lapView(lap); + })}; + CHECK(backend.dump() == "Column#1 gap=0\n Text#2 role=Normal text=a\n Text#3 role=Normal text=c\n"); + backend.clearLog(); + rows.set({{.id = 3, .label = "c"}, {.id = 2, .label = "b"}, {.id = 1, .label = "a"}}); + owner.runAll(); + CHECK(backend.log() == Lines{"move Text#2 to 1"}); + backend.clearLog(); + rows.set({{.id = 3, .label = "c"}, {.id = 1, .label = "a"}}); + owner.runAll(); + CHECK(backend.log().empty()); + CHECK(backend.dump() == "Column#1 gap=0\n Text#3 role=Normal text=c\n Text#2 role=Normal text=a\n"); +} + +TEST_CASE("ui::forEach: building a row reads nothing on the ForEach's behalf", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, {{.id = 1, .label = "a"}}}; + Signal theme{runtime, "light"}; + int pulls = 0; + ui::Mounted const view{ + runtime, backend, + ui::forEach( + [&] { + ++pulls; + return rows.get(); + }, + keyOf, + [&theme](Signal const& lap) { return ui::text({.text = theme.get() + ":" + lap.peek().label}); })}; + CHECK(backend.prop(2, "text") == "light:a"); + theme.set("dark"); + CHECK(owner.pending() == 0); + rows.set({{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}); + owner.runAll(); + CHECK(pulls == 2); + CHECK(backend.prop(3, "text") == "dark:b"); +} + +TEST_CASE( + "ui::forEach: a row whose mount throws leaves nothing behind, the others mount, and it mounts again on " + "the next change", + "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + EchoingBackend backend; + RecordingBackend& recording = backend.recording(); + std::optional spare; + backend.setOnCreate(failWhenSpent(spare)); + auto const pairView = [](Signal const& lap) { + return ui::column({.children = {lapView(lap), ui::text({.text = "-"})}}); + }; + + SECTION("in a later snapshot") { + Signal> rows{runtime, {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}}; + ui::Mounted const view{runtime, backend, ui::forEach(rows, keyOf, pairView)}; + std::size_t const mountedNodes = runtime.core()->liveNodes(); + + recording.clearLog(); + spare = 1; // the new row's Column; its first Text fails + rows.set({{.id = 1, .label = "a"}, {.id = 3, .label = "x"}, {.id = 2, .label = "b"}, {.id = 4, .label = "d"}}); + owner.runAll(); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(recording.log() == Lines{"create Column#8 in Column#1", "set Column#8 gap=0", "destroy Column#8", + "create Column#9 in Column#1", "set Column#9 gap=0", + "create Text#10 in Column#9", "set Text#10 text=d", "set Text#10 role=Normal", + "create Text#11 in Column#9", "set Text#11 text=-", "set Text#11 role=Normal"}); + // One row more: its signal, and its label's Computed and Effect. + CHECK(runtime.core()->liveNodes() == mountedNodes + 3); + + recording.clearLog(); + rows.mutate([](std::vector& /*laps*/) {}); + owner.runAll(); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(recording.log().back() == "move Column#12 to 1"); + CHECK(recording.dump() == + "Column#1 gap=0\n" + " Column#2 gap=0\n" + " Text#3 role=Normal text=a\n" + " Text#4 role=Normal text=-\n" + " Column#12 gap=0\n" + " Text#13 role=Normal text=x\n" + " Text#14 role=Normal text=-\n" + " Column#5 gap=0\n" + " Text#6 role=Normal text=b\n" + " Text#7 role=Normal text=-\n" + " Column#9 gap=0\n" + " Text#10 role=Normal text=d\n" + " Text#11 role=Normal text=-\n"); + } + SECTION("in the first mount, which is reported rather than thrown") { + Signal> const rows{runtime, {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}}; + spare = 1; // the ForEach's own Column; the first row's Column fails + ui::Mounted const view{runtime, backend, ui::forEach(rows, keyOf, pairView)}; + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(recording.dump() == + "Column#1 gap=0\n" + " Column#2 gap=0\n" + " Text#3 role=Normal text=b\n" + " Text#4 role=Normal text=-\n"); + } + SECTION("from the row view itself") { + Signal> rows{runtime, {{.id = 1, .label = "a"}}}; + bool refuse = true; + ui::Mounted const view{runtime, backend, ui::forEach(rows, keyOf, [&refuse](Signal const& lap) { + if (std::exchange(refuse, false)) { + throw std::runtime_error{"the row view could not be built"}; + } + return lapView(lap); + })}; + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(recording.dump() == "Column#1 gap=0\n"); + rows.set({{.id = 1, .label = "a"}, {.id = 2, .label = "b"}}); + owner.runAll(); + CHECK(recording.dump() == "Column#1 gap=0\n Text#2 role=Normal text=a\n Text#3 role=Normal text=b\n"); + } +} + +TEST_CASE("ui::forEach: a row's own button may remove its row", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, abc()}; + // Larger than std::function's inline buffer, so the handler's closure lives on the heap, where ASan watches it. + std::string const note(64, 'n'); + bool aliveAfterWrite = false; + std::string seenAfter; + ui::Mounted const view{ + runtime, backend, + ui::forEach(rows, keyOf, [&rows, &backend, &aliveAfterWrite, &seenAfter, ¬e](Signal const& lap) { + return ui::button({.label = [&lap] { return lap.get().label; }, + .onClick = + [&rows, &backend, &aliveAfterWrite, &seenAfter, &lap, note = note] { + removeLap(rows, lap.peek().id); + aliveAfterWrite = backend.exists(3); + seenAfter = note + lap.peek().label; + }}); + })}; + std::size_t const mountedNodes = runtime.core()->liveNodes(); + + backend.clearLog(); + backend.click(3); + CHECK(aliveAfterWrite); + CHECK(seenAfter == note + "b"); + owner.runAll(); + CHECK(backend.log() == Lines{"destroy Button#3"}); + CHECK(backend.dump() == "Column#1 gap=0\n Button#2 label=a\n Button#4 label=c\n"); + // The row's signal, and its label's Computed and Effect, are gone with it. + CHECK(runtime.core()->liveNodes() == mountedNodes - 3); +} + +TEST_CASE("ui::forEach: a row's button survives its removal coming due while its handler runs", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, abc()}; + std::string const note(64, 'n'); + bool aliveInHandler = false; + std::string seenInHandler; + ui::Mounted const view{ + runtime, backend, + ui::forEach( + rows, keyOf, [&owner, &backend, &aliveInHandler, &seenInHandler, ¬e](Signal const& lap) { + return ui::button({.label = [&lap] { return lap.get().label; }, + .onClick = + [&owner, &backend, &aliveInHandler, &seenInHandler, &lap, note = note] { + CHECK(owner.runOne()); // a modal loop + aliveInHandler = backend.exists(3); + seenInHandler = note + lap.peek().label; + }}); + })}; + removeLap(rows, 2); // a write from elsewhere: its posted flush would unmount the row + backend.click(3); + CHECK(aliveInHandler); + CHECK(seenInHandler == note + "b"); + owner.runAll(); + CHECK(backend.dump() == "Column#1 gap=0\n Button#2 label=a\n Button#4 label=c\n"); +} + +TEST_CASE("ui::forEach: unmounting destroys the rows last first, and every node they made", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> rows{runtime, abc()}; + std::size_t const before = runtime.core()->liveNodes(); + auto view = std::make_unique(runtime, backend, lapList(rows)); + // Shown in an order that is not the order the rows were made in. + rows.set({{.id = 3, .label = "c"}, {.id = 1, .label = "a"}, {.id = 2, .label = "b"}}); + owner.runAll(); + backend.clearLog(); + view.reset(); + CHECK(backend.log() == Lines{"destroy Text#3", "destroy Text#2", "destroy Text#4", "destroy Column#1"}); + CHECK(runtime.core()->liveNodes() == before); +} diff --git a/tests/test_ui_mount.cpp b/tests/test_ui_mount.cpp new file mode 100644 index 000000000..5378643b9 --- /dev/null +++ b/tests/test_ui_mount.cpp @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" +#include "ui_echoing_backend.hpp" + +namespace ui = morph::ui; + +namespace { + +using morph::reactive::Runtime; +using morph::reactive::Signal; +using ui::testing::RecordingBackend; +using Owner = morph::testing::StepExecutor; +using Probe = morph::testing::OwnerProbeRecorder; +using Lines = std::vector; +using morph::testing::EchoingBackend; + +} // namespace + +static_assert(!std::is_copy_constructible_v && !std::is_move_constructible_v); + +TEST_CASE("ui::Mounted: a constant tree is built once and makes no reactive node", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + ui::Mounted const + view{runtime, backend, + ui::column( + { + .children = + { + ui::text({.text = "Title", .role = ui::TextRole::Heading}), + ui::row({.children = {ui::button({.label = "Go"}), + ui::spacer( + {.common = {.layout = {.width = ui::Sizing::stretch()}}})}, + .gap = 2}), + ui::textInput( + {.value = "abc", .placeholder = "name", .mode = ui::TextInputMode::Password}), + ui::checkbox({.label = "Agree", .checked = true}), + ui::select({.options = + std::vector{ + {.key = ui::Key{std::int64_t{1}}, .label = "One"}, + {.key = ui::Key{std::string{"b"}}, .label = "Bee"}}, + .selected = std::optional{ui::Key{std::string{"b"}}}, + .style = ui::SelectStyle::Radio}), + ui::menu({.items = {{.label = "Open"}, {.label = "Quit"}}}), + ui::grid( + {.columns = 2, .cells = {{.node = ui::text({.text = "wide"}), .span = 2}}, .gap = 1}), + ui::panel({.title = "Box", + .padding = 1, + .child = ui::busy({.active = true, .label = "wait"}), + .collapsible = true, + .collapsed = false}), + ui::scroll({.child = ui::slider({.value = 5, .minimum = 0, .maximum = 10, .step = 1}), + .axis = ui::Axis::Horizontal}), + ui::dateTimeInput( + {.value = std::nullopt, .mode = ui::DateMode::Date, .offsetMinutes = 60}), + ui::filePicker({.path = "/tmp/x", .mode = ui::FilePickerMode::Save}), + }, + .gap = 1, + })}; + CHECK(backend.dump() == + "Column#1 gap=1\n" + " Text#2 role=Heading text=Title\n" + " Row#3 gap=2\n" + " Button#4 label=Go\n" + " Spacer#5 layout=stretch(1)/content\n" + " TextInput#6 mode=Password placeholder=name text=abc\n" + " Checkbox#7 checked=true label=Agree\n" + " Select#8 options=[1:One,\"b\":Bee] selected=\"b\" style=Radio\n" + " Menu#9 items=[Open,Quit]\n" + " Grid#10 columns=2 gap=1\n" + " Text#11 role=Normal span=2 text=wide\n" + " Panel#12 collapsed=false collapsible=true padding=1 title=Box\n" + " Busy#13 active=true label=wait\n" + " Scroll#14 axis=Horizontal\n" + " Slider#15 range=0..10/1 value=5\n" + " DateTimeInput#16 mode=Date offset=60 value=none\n" + " FilePicker#17 mode=Save path=/tmp/x\n"); + CHECK(runtime.core()->liveNodes() == 0); + CHECK(owner.pending() == 0); +} + +TEST_CASE("ui::Mounted: a binding calls its setter once per real change", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal name{runtime, "ada"}; + Signal count{runtime, 1}; + ui::Mounted const greeting{runtime, backend, ui::text({.text = [&] { return "Hi " + name.get(); }})}; + ui::Mounted const parity{runtime, backend, + ui::text({.text = [&] { return count.get() % 2 == 0 ? "even" : "odd"; }})}; + backend.clearLog(); + + name.set("bob"); + owner.runAll(); + CHECK(backend.log() == Lines{"set Text#1 text=Hi bob"}); + + backend.clearLog(); + name.set("bob"); + CHECK(owner.pending() == 0); + + count.set(3); // the binding re-runs and yields "odd" again: no setter call + owner.runAll(); + CHECK(backend.log().empty()); +} + +TEST_CASE("ui::Mounted: visible and enabled follow their props", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal shown{runtime, true}; + ui::Mounted const view{runtime, backend, + ui::column({.children = { + ui::button({.label = "Bound", + .common = {.visible = [&] { return shown.get(); }, + .enabled = [&] { return shown.get(); }}}), + ui::button({.label = "Hidden", .common = {.visible = false}}), + ui::button({.label = "Plain"}), + }})}; + CHECK(backend.dump() == + "Column#1 gap=0\n" + " Button#2 enabled=true label=Bound visible=true\n" + " Button#3 label=Hidden visible=false\n" + " Button#4 label=Plain\n"); + backend.clearLog(); + shown.set(false); + owner.runAll(); + CHECK(backend.log() == Lines{"set Button#2 visible=false", "set Button#2 enabled=false"}); +} + +TEST_CASE("ui::Mounted: a pending flush does not run inside a click handler", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + RecordingBackend backend; + Signal clicks{runtime, 0}; + std::string labelInHandler; + ui::Mounted const view{runtime, backend, + ui::button({.label = [&] { return "clicked " + std::to_string(clicks.get()); }, + .onClick = + [&] { + CHECK(owner.runOne()); // a nested event loop pumps the owner + labelInHandler = backend.prop(1, "label"); + }})}; + clicks.set(1); // posts a flush that would update the label + backend.click(1); + CHECK(labelInHandler == "clicked 0"); + owner.runAll(); + CHECK(backend.prop(1, "label") == "clicked 1"); +} + +TEST_CASE("ui::Mounted: typing reaches onChange, and a programmatic value is not echoed", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal name{runtime, "a"}; + Lines changes; + Lines submits; + ui::Mounted const view{runtime, backend, + ui::textInput({.value = [&] { return name.get(); }, + .onChange = + [&](std::string text) { + changes.push_back(text); + name.set(std::move(text)); + }, + .onSubmit = [&](std::string text) { submits.push_back(std::move(text)); }, + .placeholder = "name"})}; + backend.edit(1, "ab"); + owner.runAll(); + CHECK(changes == Lines{"ab"}); + CHECK(backend.prop(1, "text") == "ab"); + + name.set("xyz"); + owner.runAll(); + CHECK(backend.prop(1, "text") == "xyz"); + CHECK(changes == Lines{"ab"}); + + backend.submit(1, "xyz"); + CHECK(submits == Lines{"xyz"}); +} + +TEST_CASE("ui::Mounted: a menu entry runs its own action and its label may be bound", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal count{runtime, 1}; + Lines ran; + ui::Mounted const view{runtime, backend, + ui::menu({.items = {{.label = [&] { return "Count " + std::to_string(count.get()); }, + .onSelect = [&] { ran.emplace_back("count"); }}, + {.label = "Quit", .onSelect = [&] { ran.emplace_back("quit"); }}}})}; + CHECK(backend.prop(1, "items") == "[Count 1,Quit]"); + backend.chooseIndex(1, 1); + backend.chooseIndex(1, 7); // out of range: nothing runs + CHECK(ran == Lines{"quit"}); + backend.clearLog(); + count.set(2); + owner.runAll(); + CHECK(backend.log() == Lines{"set Menu#1 items=[Count 2,Quit]"}); +} + +TEST_CASE("ui::Mounted: a collapsible panel reports a toggle and follows its collapsed prop", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal collapsed{runtime, false}; + ui::Mounted const view{runtime, backend, + ui::column({.children = { + ui::panel({.title = "Advanced", + .child = ui::text({.text = "inside"}), + .collapsible = true, + .collapsed = [&] { return collapsed.get(); }, + .onToggle = [&](bool value) { collapsed.set(value); }}), + ui::panel({.title = "Fixed", .collapsed = true}), + }})}; + CHECK(backend.prop(4, "collapsed").empty()); // not collapsible: collapsed is never set + backend.collapse(2, true); + owner.runAll(); + CHECK(collapsed.peek()); + CHECK(backend.prop(2, "collapsed") == "true"); +} + +TEST_CASE("ui::Mounted: date-time, slider and file picker report what the user entered", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + using morph::time::Timestamp; + Signal> due{runtime, std::nullopt}; + Signal volume{runtime, 3}; + Signal path{runtime, ""}; + ui::Mounted const view{ + runtime, backend, + ui::column({.children = { + ui::dateTimeInput({.value = [&] { return due.get(); }, + .onChange = [&](std::optional value) { due.set(value); }}), + ui::slider({.value = [&] { return volume.get(); }, + .minimum = 0, + .maximum = 10, + .step = 1, + .onChange = [&](std::int64_t value) { volume.set(value); }}), + ui::filePicker({.path = [&] { return path.get(); }, + .onPicked = [&](std::string value) { path.set(std::move(value)); }}), + }})}; + Timestamp const when{morph::time::DateTime{std::chrono::year{2026}, std::chrono::month{10}, std::chrono::day{4}, + std::chrono::hours{9}, std::chrono::minutes{30}, + std::chrono::seconds{0}}}; + backend.setDateTime(2, when); + backend.slide(3, 7); + backend.pick(4, "/tmp/report.csv"); + owner.runAll(); + CHECK(due.peek() == std::optional{when}); + CHECK(volume.peek() == 7); + CHECK(path.peek() == "/tmp/report.csv"); + CHECK(backend.dump() == + "Column#1 gap=0\n" + " DateTimeInput#2 mode=DateTime offset=0 value=2026-10-04T09:30:00.000Z\n" + " Slider#3 range=0..10/1 value=7\n" + " FilePicker#4 mode=Open path=/tmp/report.csv\n"); +} + +TEST_CASE("ui::Mounted: a drag key reaches a drop target that accepts it", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + std::vector dropped; + auto const keep = [&dropped](ui::Key key) { dropped.push_back(std::move(key)); }; + ui::Mounted const view{ + runtime, backend, + ui::column( + {.children = { + ui::text({.text = "card", .common = {.dragKey = std::optional{ui::Key{std::int64_t{7}}}}}), + ui::panel( + {.title = "Numbers", + .common = {.accepts = + [](ui::Key const& key) { return std::holds_alternative(key); }, + .onDrop = keep}}), + ui::panel({.title = "Words", + .common = {.accepts = [](ui::Key const& + key) { return std::holds_alternative(key); }, + .onDrop = keep}}), + ui::panel({.title = "Anything", .common = {.onDrop = keep}}), + ui::text({.text = "not a target"}), + }})}; + CHECK(backend.prop(2, "dragKey") == "7"); + CHECK(backend.drag(2, 3)); + CHECK_FALSE(backend.drag(2, 4)); + CHECK(backend.drag(2, 5)); // no accepts: every key is accepted + CHECK_FALSE(backend.drag(2, 6)); + CHECK_FALSE(backend.drag(6, 3)); + CHECK(dropped == std::vector{ui::Key{std::int64_t{7}}, ui::Key{std::int64_t{7}}}); +} + +TEST_CASE("ui::Mounted: unmounting destroys children first and leaves no binding", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal label{runtime, "x"}; + auto view = std::make_unique( + runtime, backend, + ui::column({.children = {ui::text({.text = [&] { return label.get(); }}), + ui::row({.children = {ui::button({.label = "Go"})}})}})); + CHECK(runtime.core()->liveNodes() == 3); // the signal, and the text binding's Computed and Effect + backend.clearLog(); + view.reset(); + CHECK(backend.log() == Lines{"destroy Button#4", "destroy Row#3", "destroy Text#2", "destroy Column#1"}); + CHECK(runtime.core()->liveNodes() == 1); // the signal + label.set("y"); + CHECK(owner.pending() == 0); +} + +TEST_CASE("ui::Mounted: mounts into an existing container, and refuses a null root", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + auto host = backend.createStack(nullptr, ui::Axis::Vertical); + { + ui::Mounted const view{runtime, backend, ui::text({.text = "inside"}), host.get()}; + CHECK(backend.dump() == "Column#1\n Text#2 role=Normal text=inside\n"); + CHECK(backend.idOf(view.root()) == 2); + } + CHECK(backend.dump() == "Column#1\n"); + CHECK_THROWS_AS((ui::Mounted{runtime, backend, ui::Node{}}), std::invalid_argument); +} + +TEST_CASE("ui::Mounted: a node is created, then given Common, then its own props, then its children", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal enabled{runtime, true}; + ui::Mounted const view{ + runtime, backend, + ui::row( + {.children = { + ui::button({.label = "B", + .common = {.visible = false, + .enabled = [&] { return enabled.get(); }, + .layout = {.width = ui::Sizing::fixed(3)}, + .dragKey = std::optional{ui::Key{std::string{"k"}}}, + .onDrop = [](ui::Key const&) {}}}), + ui::Node{}, + ui::grid({.columns = 3, + .cells = {{.node = ui::text({.text = "one"})}, + {.node = ui::Node{}, .span = 2}, + {.node = ui::spacer(), .span = 2}}}), + ui::panel({.title = "P", .child = ui::text({.text = "in"}), .collapsible = true, .collapsed = true}), + }})}; + CHECK(backend.log() == + Lines{"create Row#1 in root", "set Row#1 gap=0", "create Button#2 in Row#1", + "set Button#2 visible=false", "set Button#2 enabled=true", "set Button#2 layout=fixed(3)/content", + "set Button#2 dragKey=\"k\"", "set Button#2 drop=handler", "set Button#2 label=B", + "create Grid#3 in Row#1", "set Grid#3 columns=3", "set Grid#3 gap=0", + "create Text#4 in Grid#3", "set Text#4 text=one", "set Text#4 role=Normal", + "create Spacer#5 in Grid#3", "set Spacer#5 span=2", "create Panel#6 in Row#1", + "set Panel#6 title=P", "set Panel#6 padding=0", "set Panel#6 collapsible=true", + "set Panel#6 collapsed=true", "create Text#7 in Panel#6", "set Text#7 text=in", + "set Text#7 role=Normal"}); +} + +TEST_CASE("ui::Mounted: a binding that throws is reported, and its widget keeps its last value", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + RecordingBackend backend; + Signal value{runtime, 0}; + ui::Mounted const view{runtime, backend, ui::text({.text = [&] { + if (value.get() % 2 == 0) { + throw std::runtime_error{"even"}; + } + return std::to_string(value.get()); + }})}; + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK_FALSE(backend.hasProp(1, "text")); + CHECK(backend.prop(1, "role") == "Normal"); // the mount went on past the failed binding + + value.set(1); + owner.runAll(); + CHECK(backend.prop(1, "text") == "1"); + + backend.clearLog(); + value.set(2); + owner.runAll(); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 2); + CHECK(backend.log().empty()); + CHECK(backend.prop(1, "text") == "1"); + + value.set(3); + owner.runAll(); + CHECK(backend.prop(1, "text") == "3"); +} + +TEST_CASE("ui::Mounted: a click handler may unmount the view that holds its own button", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal label{runtime, "Close"}; + std::unique_ptr view; + std::string seenAfter; + // Larger than std::function's inline buffer, so the handler's closure is on the heap, and the read after the + // unmount is a use after free unless the mount's wrapper keeps its own copy of the handler. + std::string const note = "read after the unmount, from the handler's own closure"; + view = std::make_unique( + runtime, backend, + ui::column({.children = {ui::button({.label = [&] { return label.get(); }, + .onClick = + [&view, &label, &seenAfter, note = note] { + label.set("Closing"); + view.reset(); + seenAfter = note; + }})}})); + backend.clearLog(); + backend.click(2); + CHECK(view == nullptr); + CHECK(seenAfter == note); + owner.runAll(); + CHECK(backend.log() == Lines{"destroy Button#2", "destroy Column#1"}); + CHECK(backend.dump().empty()); + CHECK(runtime.core()->liveNodes() == 1); // the signal +} + +TEST_CASE("ui::Mounted: a handler a setter echoes does not subscribe the binding that called the setter", "[ui]") { + Owner owner; + Runtime runtime{owner}; + EchoingBackend backend; + Signal> options{ + runtime, std::vector{{.key = ui::Key{std::int64_t{1}}, .label = "One"}}}; + Signal elsewhere{runtime, 0}; + std::vector echoed; + ui::Mounted const view{runtime, backend, + ui::select({.options = [&] { return options.get(); }, + .onSelect = + [&](ui::Key key) { + static_cast(elsewhere.get()); + echoed.push_back(std::move(key)); + }})}; + options.set(std::vector{{.key = ui::Key{std::int64_t{2}}, .label = "Two"}}); + owner.runAll(); + CHECK(echoed == std::vector{ui::Key{std::int64_t{2}}}); // called from inside the options binding + + backend.recording().clearLog(); + elsewhere.set(1); + owner.runAll(); + CHECK(backend.recording().log().empty()); + CHECK(echoed.size() == 1); +} + +TEST_CASE("ui::Mounted: a callback that throws is reported, and what it wrote before still flushes", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + RecordingBackend backend; + Signal clicks{runtime, 0}; + std::vector dropped; + ui::Mounted const view{ + runtime, backend, + ui::column( + {.children = { + ui::button({.label = [&] { return "clicked " + std::to_string(clicks.get()); }, + .onClick = + [&] { + clicks.set(1); + throw std::runtime_error{"the handler failed"}; + }}), + ui::text({.text = "card", .common = {.dragKey = std::optional{ui::Key{"k"}}}}), + ui::panel({.title = "Target", + .common = {.accepts = [](ui::Key const&) -> bool { + throw std::runtime_error{"the predicate failed"}; + }, + .onDrop = [&dropped](ui::Key key) { dropped.push_back(std::move(key)); }}}), + }})}; + CHECK_NOTHROW(backend.click(2)); + CHECK(probe.count(ui::detail::site::kCallbackThrew) == 1); + owner.runAll(); + CHECK(backend.prop(2, "label") == "clicked 1"); + + bool accepted = true; + CHECK_NOTHROW(accepted = backend.drag(3, 4)); + CHECK_FALSE(accepted); // a predicate that throws refuses the drop + CHECK(dropped.empty()); + CHECK(probe.count(ui::detail::site::kCallbackThrew) == 2); +} + +TEST_CASE("ui::Mounted: mounting inside an Effect does not subscribe that Effect to what the mount reads", "[ui]") { + Owner owner; + Runtime runtime{owner}; + EchoingBackend backend; + Signal theme{runtime, 0}; + backend.setOnCreate([&theme] { static_cast(theme.get()); }); + int hostRuns = 0; + std::unique_ptr view; + morph::reactive::Effect const host{runtime, [&] { + ++hostRuns; + view = std::make_unique(runtime, backend, + ui::text({.text = "inside"})); + }}; + CHECK(hostRuns == 1); + CHECK(backend.recording().dump() == "Text#1 role=Normal text=inside\n"); + theme.set(1); + owner.runAll(); + CHECK(hostRuns == 1); +} diff --git a/tests/test_ui_recording_backend.cpp b/tests/test_ui_recording_backend.cpp new file mode 100644 index 000000000..adae2c371 --- /dev/null +++ b/tests/test_ui_recording_backend.cpp @@ -0,0 +1,502 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui_test_support.hpp" + +namespace ui = morph::ui; + +namespace { + +using morph::testing::intKey; +using ui::testing::RecordingBackend; + +} // namespace + +TEST_CASE("RecordingBackend: logs creation, setters, moves and destruction", "[ui]") { + RecordingBackend backend; + auto column = backend.createStack(nullptr, ui::Axis::Vertical); + auto first = backend.createText(column.get()); + auto second = backend.createButton(column.get()); + first->setText("hello"); + second->setLabel("Go"); + column->moveChild(*second, 0); + CHECK(backend.log() == std::vector{"create Column#1 in root", "create Text#2 in Column#1", + "create Button#3 in Column#1", "set Text#2 text=hello", + "set Button#3 label=Go", "move Button#3 to 0"}); + CHECK(backend.dump() == "Column#1\n Button#3 label=Go\n Text#2 text=hello\n"); + + backend.clearLog(); + first.reset(); + CHECK(backend.log() == std::vector{"destroy Text#2"}); + CHECK_FALSE(backend.exists(2)); + CHECK(backend.children(1) == std::vector{3}); +} + +TEST_CASE("RecordingBackend: creation parameters are properties, a horizontal stack is a Row", "[ui]") { + RecordingBackend backend; + auto row = backend.createStack(nullptr, ui::Axis::Horizontal); + auto input = backend.createTextInput(row.get(), ui::TextInputMode::Password); + auto date = backend.createDateTimeInput(row.get(), ui::DateMode::Date, -90); + auto scroll = backend.createScroll(row.get(), ui::Axis::Horizontal); + auto picker = backend.createFilePicker(row.get(), ui::FilePickerMode::Save); + auto choice = backend.createSelect(row.get(), ui::SelectStyle::Radio); + choice->setOptions({{.key = intKey(1), .label = "One"}, {.key = ui::Key{std::string{"b"}}, .label = "Bee"}}); + choice->setSelected(ui::Key{std::string{"b"}}); + CHECK(backend.dump() == + "Row#1\n" + " TextInput#2 mode=Password\n" + " DateTimeInput#3 mode=Date offset=-90\n" + " Scroll#4 axis=Horizontal\n" + " FilePicker#5 mode=Save\n" + " Select#6 options=[1:One,\"b\":Bee] selected=\"b\" style=Radio\n"); +} + +TEST_CASE("RecordingBackend: Common setters are properties", "[ui]") { + RecordingBackend backend; + auto label = backend.createText(nullptr); + label->setVisible(false); + label->setEnabled(false); + label->setLayout({.width = ui::Sizing::stretch(2), .height = ui::Sizing::fixed(1)}); + label->setDragKey(intKey(7)); + label->setDropHandler([](ui::Key const&) { return true; }, [](ui::Key const&) {}); + CHECK(backend.dump() == "Text#1 dragKey=7 drop=handler enabled=false layout=stretch(2)/fixed(1) visible=false\n"); + label->setDragKey(std::nullopt); + CHECK(backend.prop(1, "dragKey") == "none"); +} + +TEST_CASE("RecordingBackend: lookups by kind, property, id and widget", "[ui]") { + RecordingBackend backend; + auto column = backend.createStack(nullptr, ui::Axis::Vertical); + auto save = backend.createButton(column.get()); + auto quit = backend.createButton(column.get()); + save->setLabel("Save"); + quit->setLabel("Quit"); + CHECK(backend.find("Button", "label", "Quit") == std::optional{3}); + CHECK_FALSE(backend.find("Button", "label", "Open").has_value()); + CHECK(backend.all("Button") == std::vector{2, 3}); + CHECK(backend.prop(2, "label") == "Save"); + CHECK(backend.prop(2, "unset").empty()); + CHECK(backend.idOf(*quit) == 3); + CHECK(backend.kindOf(1) == "Column"); + CHECK(backend.widget(3) == quit.get()); + CHECK_THROWS_AS(backend.prop(99, "label"), std::out_of_range); + RecordingBackend other; + auto stranger = other.createText(nullptr); + CHECK_THROWS_AS(backend.idOf(*stranger), std::out_of_range); +} + +TEST_CASE("RecordingBackend: a child outlives a destroyed parent as a root", "[ui]") { + RecordingBackend backend; + auto column = backend.createStack(nullptr, ui::Axis::Vertical); + auto label = backend.createText(column.get()); + column.reset(); + CHECK(backend.dump() == "Text#2\n"); +} + +TEST_CASE("RecordingBackend: the interaction helpers call what the widget was given", "[ui]") { + RecordingBackend backend; + int clicks = 0; + std::vector edits; + std::vector submits; + std::vector toggles; + std::vector chosen; + std::vector activated; + std::vector collapses; + std::vector> dates; + std::vector slides; + std::vector picks; + + auto button = backend.createButton(nullptr); + button->setOnClick([&] { ++clicks; }); + auto input = backend.createTextInput(nullptr, ui::TextInputMode::SingleLine); + input->setOnChange([&](std::string text) { edits.push_back(std::move(text)); }); + input->setOnSubmit([&](std::string text) { submits.push_back(std::move(text)); }); + auto box = backend.createCheckbox(nullptr); + box->setOnToggle([&](bool checked) { toggles.push_back(checked); }); + auto choice = backend.createSelect(nullptr, ui::SelectStyle::Dropdown); + choice->setOnSelect([&](ui::Key key) { chosen.push_back(std::move(key)); }); + choice->setOptions({{.key = intKey(5), .label = "Five"}}); + auto menu = backend.createMenu(nullptr); + menu->setOnActivate([&](std::size_t index) { activated.push_back(index); }); + auto panel = backend.createPanel(nullptr); + panel->setOnToggle([&](bool collapsed) { collapses.push_back(collapsed); }); + panel->setCollapsible(true); + auto date = backend.createDateTimeInput(nullptr, ui::DateMode::DateTime, 0); + date->setOnChange([&](std::optional value) { dates.push_back(value); }); + auto slider = backend.createSlider(nullptr); + slider->setOnChange([&](std::int64_t value) { slides.push_back(value); }); + auto picker = backend.createFilePicker(nullptr, ui::FilePickerMode::Open); + picker->setOnPicked([&](std::string path) { picks.push_back(std::move(path)); }); + + backend.click(1); + backend.edit(2, "ab"); + backend.submit(2, "abc"); + backend.toggle(3); + backend.toggle(3); + backend.choose(4, intKey(5)); + backend.chooseIndex(5, 1); + backend.collapse(6, true); + morph::time::Timestamp const when{morph::time::DateTime{std::chrono::year{2026}, std::chrono::month{10}, + std::chrono::day{4}, std::chrono::hours{9}, + std::chrono::minutes{30}, std::chrono::seconds{0}}}; + backend.setDateTime(7, when); + backend.slide(8, 40); + backend.pick(9, "/tmp/in.csv"); + + CHECK(clicks == 1); + CHECK(edits == std::vector{"ab"}); + CHECK(submits == std::vector{"abc"}); + CHECK(toggles == std::vector{true, false}); + CHECK(chosen == std::vector{intKey(5)}); + CHECK(activated == std::vector{1}); + CHECK(collapses == std::vector{true}); + CHECK(dates == std::vector>{when}); + CHECK(slides == std::vector{40}); + CHECK(picks == std::vector{"/tmp/in.csv"}); + // What the user changed shows on the widget, without a setter call in the log. + CHECK(backend.prop(2, "text") == "abc"); + CHECK(backend.prop(3, "checked") == "false"); + CHECK(backend.prop(4, "selected") == "5"); + CHECK(backend.prop(7, "value") == "2026-10-04T09:30:00.000Z"); + CHECK(backend.log().size() == 11); +} + +TEST_CASE("RecordingBackend: setText never calls onChange", "[ui]") { + RecordingBackend backend; + auto input = backend.createTextInput(nullptr, ui::TextInputMode::SingleLine); + int changes = 0; + input->setOnChange([&](std::string const&) { ++changes; }); + input->setText("programmatic"); + CHECK(changes == 0); + CHECK(backend.prop(1, "text") == "programmatic"); +} + +TEST_CASE("RecordingBackend: a hidden or disabled widget ignores the helpers", "[ui]") { + RecordingBackend backend; + int clicks = 0; + auto hidden = backend.createButton(nullptr); + hidden->setOnClick([&] { ++clicks; }); + hidden->setVisible(false); + auto disabled = backend.createButton(nullptr); + disabled->setOnClick([&] { ++clicks; }); + disabled->setEnabled(false); + backend.click(1); + backend.click(2); + CHECK(clicks == 0); + disabled->setEnabled(true); + backend.click(2); + CHECK(clicks == 1); +} + +TEST_CASE("RecordingBackend: a helper survives a handler that destroys its widget", "[ui]") { + RecordingBackend backend; + std::unique_ptr button = backend.createButton(nullptr); + std::string seen; + // The capture is larger than std::function's small buffer, so it lives in the stored handler's own allocation. + button->setOnClick([&button, &seen, marker = std::string{"the handler's state outlives its widget"}] { + button.reset(); + seen = marker; + }); + backend.click(1); + CHECK(seen == "the handler's state outlives its widget"); + CHECK_FALSE(backend.exists(1)); +} + +TEST_CASE("RecordingBackend: drag delivers the source's key to a target that accepts it", "[ui]") { + RecordingBackend backend; + std::vector dropped; + auto card = backend.createText(nullptr); + card->setDragKey(intKey(7)); + auto plain = backend.createText(nullptr); + auto numbers = backend.createPanel(nullptr); + numbers->setDropHandler([](ui::Key const& key) { return std::holds_alternative(key); }, + [&](ui::Key key) { dropped.push_back(std::move(key)); }); + auto words = backend.createPanel(nullptr); + words->setDropHandler([](ui::Key const& key) { return std::holds_alternative(key); }, + [&](ui::Key key) { dropped.push_back(std::move(key)); }); + + CHECK(backend.drag(1, 3)); + CHECK_FALSE(backend.drag(1, 4)); // refused by accepts + CHECK_FALSE(backend.drag(1, 2)); // not a drop target + CHECK_FALSE(backend.drag(2, 3)); // not draggable + CHECK(dropped == std::vector{intKey(7)}); +} + +TEST_CASE("RecordingBackend: a widget inside a hidden, disabled or collapsed container ignores the helpers", "[ui]") { + RecordingBackend backend; + int clicks = 0; + std::vector edits; + std::vector chosen; + std::vector dropped; + auto panel = backend.createPanel(nullptr); + panel->setCollapsible(true); + auto column = backend.createStack(panel.get(), ui::Axis::Vertical); + auto button = backend.createButton(column.get()); + button->setOnClick([&] { ++clicks; }); + auto input = backend.createTextInput(column.get(), ui::TextInputMode::SingleLine); + input->setOnChange([&](std::string text) { edits.push_back(std::move(text)); }); + auto choice = backend.createSelect(column.get(), ui::SelectStyle::Dropdown); + choice->setOptions({{.key = intKey(1), .label = "One"}}); + choice->setOnSelect([&](ui::Key key) { chosen.push_back(std::move(key)); }); + auto card = backend.createText(column.get()); + card->setDragKey(intKey(7)); + auto target = backend.createPanel(nullptr); + target->setDropHandler([](ui::Key const&) { return true; }, + [&](ui::Key key) { dropped.push_back(std::move(key)); }); + + auto const useEverything = [&] { + backend.click(3); + backend.edit(4, "x"); + backend.choose(5, intKey(1)); + return backend.drag(6, 7); + }; + panel->setVisible(false); + CHECK_FALSE(useEverything()); + panel->setVisible(true); + panel->setEnabled(false); + CHECK_FALSE(useEverything()); + panel->setEnabled(true); + panel->setCollapsed(true); + CHECK_FALSE(useEverything()); + CHECK(clicks == 0); + CHECK(edits.empty()); + CHECK(chosen.empty()); + CHECK(dropped.empty()); + + panel->setCollapsed(false); + CHECK(useEverything()); + CHECK(clicks == 1); + CHECK(edits == std::vector{"x"}); + CHECK(chosen == std::vector{intKey(1)}); + CHECK(dropped == std::vector{intKey(7)}); +} + +TEST_CASE("RecordingBackend: a collapsed panel's own collapse control still works", "[ui]") { + RecordingBackend backend; + std::vector requested; + auto panel = backend.createPanel(nullptr); + panel->setCollapsible(true); + panel->setCollapsed(true); + panel->setOnToggle([&](bool collapsed) { requested.push_back(collapsed); }); + backend.collapse(1, false); + CHECK(requested == std::vector{false}); +} + +TEST_CASE("RecordingBackend: a select marks its requested key only while the options contain it", "[ui]") { + RecordingBackend backend; + auto choice = backend.createSelect(nullptr, ui::SelectStyle::Dropdown); + choice->setSelected(ui::Key{std::string{"b"}}); + CHECK(backend.prop(1, "selected") == "none"); + choice->setOptions({{.key = intKey(1), .label = "One"}, {.key = ui::Key{std::string{"b"}}, .label = "Bee"}}); + CHECK(backend.prop(1, "selected") == "\"b\""); + choice->setOptions({{.key = intKey(1), .label = "One"}}); + CHECK(backend.prop(1, "selected") == "none"); + choice->setOptions({{.key = ui::Key{std::string{"b"}}, .label = "Bee"}}); + CHECK(backend.prop(1, "selected") == "\"b\""); + // Re-resolving is the widget's own doing, not a setter call. + CHECK(backend.log() == std::vector{"create Select#1 in root", "set Select#1 selected=none", + "set Select#1 options=[1:One,\"b\":Bee]", + "set Select#1 options=[1:One]", + "set Select#1 options=[\"b\":Bee]"}); + choice->setSelected(std::nullopt); + choice->setOptions({{.key = ui::Key{std::string{"b"}}, .label = "Bee"}}); + CHECK(backend.prop(1, "selected") == "none"); +} + +TEST_CASE("RecordingBackend: what the user chose stays selected across new options", "[ui]") { + RecordingBackend backend; + auto choice = backend.createSelect(nullptr, ui::SelectStyle::Radio); + choice->setOptions({{.key = intKey(1), .label = "One"}, {.key = intKey(2), .label = "Two"}}); + backend.choose(1, intKey(2)); + choice->setOptions({{.key = intKey(1), .label = "One"}}); + CHECK(backend.prop(1, "selected") == "none"); + choice->setOptions({{.key = intKey(2), .label = "Two"}}); + CHECK(backend.prop(1, "selected") == "2"); +} + +TEST_CASE("RecordingBackend: choosing a key the select does not offer is a test error", "[ui]") { + RecordingBackend backend; + std::vector chosen; + auto choice = backend.createSelect(nullptr, ui::SelectStyle::Dropdown); + choice->setOptions({{.key = intKey(1), .label = "One"}}); + choice->setOnSelect([&](ui::Key key) { chosen.push_back(std::move(key)); }); + CHECK_THROWS_AS(backend.choose(1, intKey(2)), std::logic_error); + CHECK_THROWS_AS(backend.choose(1, ui::Key{std::string{"1"}}), std::logic_error); + CHECK(chosen.empty()); + CHECK(backend.prop(1, "selected").empty()); +} + +TEST_CASE("RecordingBackend: a helper used on the wrong kind of widget is a test error", "[ui]") { + RecordingBackend backend; + auto label = backend.createText(nullptr); + auto button = backend.createButton(nullptr); + auto fixed = backend.createPanel(nullptr); + CHECK_THROWS_AS(backend.click(1), std::logic_error); + CHECK_THROWS_AS(backend.edit(2, "x"), std::logic_error); + CHECK_THROWS_AS(backend.submit(1, "x"), std::logic_error); + CHECK_THROWS_AS(backend.toggle(1), std::logic_error); + CHECK_THROWS_AS(backend.choose(1, intKey(1)), std::logic_error); + CHECK_THROWS_AS(backend.chooseIndex(1, 0), std::logic_error); + CHECK_THROWS_AS(backend.collapse(1, true), std::logic_error); + CHECK_THROWS_AS(backend.setDateTime(1, std::nullopt), std::logic_error); + CHECK_THROWS_AS(backend.slide(1, 1), std::logic_error); + CHECK_THROWS_AS(backend.pick(1, "/tmp/x"), std::logic_error); + CHECK_THROWS_AS(backend.dismiss(1), std::logic_error); + // A panel the user cannot collapse has no control to do it with. + CHECK_THROWS_AS(backend.collapse(3, true), std::logic_error); +} + +TEST_CASE("RecordingBackend: a grid spans only its own children", "[ui]") { + RecordingBackend backend; + auto grid = backend.createGrid(nullptr); + auto other = backend.createGrid(nullptr); + auto stranger = backend.createText(other.get()); + auto root = backend.createText(nullptr); + CHECK_THROWS_AS(grid->setSpan(*stranger, 2), std::logic_error); + CHECK_THROWS_AS(grid->setSpan(*root, 2), std::logic_error); + CHECK(backend.prop(3, "span").empty()); +} + +TEST_CASE("RecordingBackend: dragging a widget onto itself is a test error", "[ui]") { + RecordingBackend backend; + auto card = backend.createPanel(nullptr); + card->setDragKey(intKey(1)); + card->setDropHandler([](ui::Key const&) { return true; }, [](ui::Key const&) {}); + CHECK_THROWS_AS(backend.drag(1, 1), std::logic_error); +} + +TEST_CASE("RecordingBackend: text in a value cannot pass for another line, field or element", "[ui]") { + RecordingBackend backend; + auto label = backend.createText(nullptr); + label->setText("a\nText#9 b=c\\d"); + auto menu = backend.createMenu(nullptr); + menu->setItems({"x,y", "[z]"}); + auto choice = backend.createSelect(nullptr, ui::SelectStyle::Dropdown); + choice->setOptions({{.key = ui::Key{std::string{"q\"r"}}, .label = "s:t"}}); + CHECK(backend.dump() == + "Text#1 text=a\\nText#9 b\\=c\\\\d\n" + "Menu#2 items=[x\\,y,\\[z\\]]\n" + "Select#3 options=[\"q\\\"r\":s:t] style=Dropdown\n"); + CHECK(backend.log().at(1) == "set Text#1 text=a\\nText#9 b\\=c\\\\d"); + CHECK(backend.find("Text", "text", "a\\nText#9 b\\=c\\\\d") == std::optional{1}); +} + +TEST_CASE("RecordingBackend: every kind's setters show in the dump", "[ui]") { + RecordingBackend backend; + auto grid = backend.createGrid(nullptr); + grid->setColumns(3); + grid->setGap(1); + auto heading = backend.createText(grid.get()); + heading->setRole(ui::TextRole::Heading); + grid->setSpan(*heading, 2); + auto gap = backend.createSpacer(grid.get()); + auto row = backend.createStack(nullptr, ui::Axis::Horizontal); + row->setGap(2); + auto menu = backend.createMenu(row.get()); + menu->setItems({"Open", "Quit"}); + auto busy = backend.createBusy(row.get()); + busy->setActive(true); + busy->setLabel("wait"); + CHECK(backend.dump() == + "Grid#1 columns=3 gap=1\n" + " Text#2 role=Heading span=2\n" + " Spacer#3\n" + "Row#4 gap=2\n" + " Menu#5 items=[Open,Quit]\n" + " Busy#6 active=true label=wait\n"); +} + +TEST_CASE("RecordingBackend: a moved widget keeps its state and handlers", "[ui]") { + RecordingBackend backend; + int clicks = 0; + auto column = backend.createStack(nullptr, ui::Axis::Vertical); + auto first = backend.createText(column.get()); + auto button = backend.createButton(column.get()); + button->setLabel("Go"); + button->setOnClick([&] { ++clicks; }); + column->moveChild(*button, 0); + backend.click(3); + CHECK(clicks == 1); + CHECK(backend.dump() == "Column#1\n Button#3 label=Go\n Text#2\n"); +} + +TEST_CASE("RecordingBackend: a hidden source cannot be dragged", "[ui]") { + RecordingBackend backend; + std::vector dropped; + auto card = backend.createText(nullptr); + card->setDragKey(intKey(7)); + card->setVisible(false); + auto target = backend.createPanel(nullptr); + target->setDropHandler([](ui::Key const&) { return true; }, + [&](ui::Key key) { dropped.push_back(std::move(key)); }); + CHECK_FALSE(backend.drag(1, 2)); + CHECK(dropped.empty()); +} + +TEST_CASE("RecordingBackend: hasProp tells a property never set from one set empty", "[ui]") { + RecordingBackend backend; + auto label = backend.createText(nullptr); + CHECK_FALSE(backend.hasProp(1, "text")); + label->setText(""); + CHECK(backend.hasProp(1, "text")); + CHECK(backend.prop(1, "text").empty()); + CHECK_THROWS_AS(backend.hasProp(2, "text"), std::out_of_range); +} + +TEST_CASE("RecordingBackend: slots, tab bars and dialogs show in the dump", "[ui]") { + RecordingBackend backend; + std::vector picked; + auto tabs = backend.createTabs(nullptr); + tabs->setTabs({"a,b", "c"}); + tabs->setSelected(1); + tabs->setOnSelect([&](std::size_t index) { picked.push_back(index); }); + auto slot = backend.createSlot(tabs.get()); + auto dialog = backend.createDialog(nullptr); + dialog->setTitle("x=y"); + dialog->setOpen(true); + CHECK(backend.dump() == + "Tabs#1 selected=1 tabs=[a\\,b,c]\n" + " Slot#2\n" + "Dialog#3 open=true title=x\\=y\n"); + + backend.clearLog(); + backend.chooseIndex(1, 0); + CHECK(picked == std::vector{0}); + CHECK(backend.prop(1, "selected") == "0"); + CHECK(backend.log().empty()); +} + +TEST_CASE("RecordingBackend: a closed dialog ignores dismiss, and so does everything inside it", "[ui]") { + RecordingBackend backend; + int dismissed = 0; + int clicks = 0; + auto dialog = backend.createDialog(nullptr); + dialog->setOnDismiss([&] { ++dismissed; }); + auto button = backend.createButton(dialog.get()); + button->setOnClick([&] { ++clicks; }); + auto const useBoth = [&] { + backend.dismiss(1); + backend.click(2); + }; + useBoth(); // a new dialog is closed + dialog->setOpen(true); + useBoth(); + dialog->setOpen(false); + useBoth(); + CHECK(dismissed == 1); + CHECK(clicks == 1); +} diff --git a/tests/test_ui_structure.cpp b/tests/test_ui_structure.cpp new file mode 100644 index 000000000..513a391fb --- /dev/null +++ b/tests/test_ui_structure.cpp @@ -0,0 +1,658 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" +#include "ui_echoing_backend.hpp" +#include "ui_test_support.hpp" + +namespace ui = morph::ui; + +namespace { + +using morph::reactive::Runtime; +using morph::reactive::Signal; +using morph::testing::EchoingBackend; +using morph::testing::failWhenSpent; +using morph::testing::intKey; +using ui::testing::RecordingBackend; +using Owner = morph::testing::StepExecutor; +using Probe = morph::testing::OwnerProbeRecorder; +using Lines = std::vector; + +enum class Page : std::uint8_t { Home, Settings }; + +} // namespace + +TEST_CASE("ui::switchOf: mounts the selected case and remounts child-first", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal which{runtime, 0}; + ui::Mounted const view{ + runtime, backend, + ui::switchOf({ + .selector = [&] { return ui::Key{which.get()}; }, + .cases = {{.key = intKey(0), .node = ui::column({.children = {ui::text({.text = "zero"})}})}, + {.key = intKey(1), .node = ui::column({.children = {ui::text({.text = "one"})}})}}, + })}; + CHECK(backend.dump() == "Slot#1\n Column#2 gap=0\n Text#3 role=Normal text=zero\n"); + backend.clearLog(); + which.set(1); + owner.runAll(); + CHECK(backend.log() == Lines{"destroy Text#3", "destroy Column#2", "create Column#4 in Slot#1", + "set Column#4 gap=0", "create Text#5 in Column#4", "set Text#5 text=one", + "set Text#5 role=Normal"}); +} + +TEST_CASE("ui::switchOf: an unchanged key never remounts", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal count{runtime, 1}; + ui::Mounted const view{runtime, backend, + ui::switchOf({ + .selector = [&] { return intKey(count.get() > 0 ? 1 : 0); }, + .cases = {{.key = intKey(0), .node = ui::text({.text = "none"})}, + {.key = intKey(1), .node = ui::text({.text = "some"})}}, + })}; + backend.clearLog(); + count.set(5); + owner.runAll(); + CHECK(backend.log().empty()); +} + +TEST_CASE("ui::switchOf: a key with no case and no fallback mounts nothing", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal which{runtime, 9}; + ui::Mounted const view{runtime, backend, + ui::switchOf({.selector = [&] { return ui::Key{which.get()}; }, + .cases = {{.key = intKey(0), .node = ui::text({.text = "zero"})}}})}; + CHECK(backend.dump() == "Slot#1\n"); + which.set(0); + owner.runAll(); + CHECK(backend.dump() == "Slot#1\n Text#2 role=Normal text=zero\n"); + which.set(9); + owner.runAll(); + CHECK(backend.dump() == "Slot#1\n"); +} + +TEST_CASE("ui::switchOf: the fallback answers a key with no case", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + ui::Mounted const view{runtime, backend, + ui::switchOf({.selector = ui::Key{std::string{"missing"}}, + .cases = {{.key = intKey(0), .node = ui::text({.text = "zero"})}}, + .fallback = ui::text({.text = "other"})})}; + CHECK(backend.dump() == "Slot#1\n Text#2 role=Normal text=other\n"); +} + +TEST_CASE("ui::switchOf: a case being left never runs against the state that removes it", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal which{runtime, 0}; + // The case's text and its inner Switch both read what the outer Switch selects on, so one write wakes all + // three; the outer one is the oldest, runs first, and removes the other two before they run. + ui::Mounted const view{ + runtime, backend, + ui::switchOf({ + .selector = [&] { return ui::Key{which.get()}; }, + .cases = {{.key = intKey(0), + .node = ui::column( + {.children = {ui::text({.text = [&] { return "which:" + std::to_string(which.get()); }}), + ui::switchOf({ + .selector = [&] { return ui::Key{which.get()}; }, + .cases = {{.key = intKey(0), .node = ui::text({.text = "inner zero"})}}, + .fallback = ui::text({.text = "inner other"}), + })}})}, + {.key = intKey(1), .node = ui::text({.text = "one"})}}, + })}; + CHECK(backend.dump() == + "Slot#1\n" + " Column#2 gap=0\n" + " Text#3 role=Normal text=which:0\n" + " Slot#4\n" + " Text#5 role=Normal text=inner zero\n"); + backend.clearLog(); + which.set(1); + owner.runAll(); + CHECK(backend.log() == Lines{"destroy Text#5", "destroy Slot#4", "destroy Text#3", "destroy Column#2", + "create Text#6 in Slot#1", "set Text#6 text=one", "set Text#6 role=Normal"}); +} + +TEST_CASE("ui::switchOf: a case's own button may switch that case away", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal which{runtime, 0}; + // Larger than std::function's inline buffer, so the handler's closure lives on the heap, where ASan watches it. + std::string const note(64, 'n'); + bool aliveAfterWrite = false; + std::string seenAfter; + ui::Mounted const view{ + runtime, backend, + ui::switchOf({ + .selector = [&] { return ui::Key{which.get()}; }, + .cases = {{.key = intKey(0), + .node = ui::button({.label = "Next", + .onClick = + [&, note = note] { + which.set(1); + aliveAfterWrite = backend.exists(2); + seenAfter = note; + }})}, + {.key = intKey(1), .node = ui::button({.label = "Back", .onClick = [&] { which.set(0); }})}}, + })}; + std::size_t const mountedNodes = runtime.core()->liveNodes(); + + backend.clearLog(); + backend.click(2); + CHECK(aliveAfterWrite); + CHECK(seenAfter == note); + owner.runAll(); + CHECK(backend.log() == Lines{"destroy Button#2", "create Button#3 in Slot#1", "set Button#3 label=Back"}); + + backend.click(3); + owner.runAll(); + CHECK(backend.dump() == "Slot#1\n Button#4 label=Next\n"); + CHECK(runtime.core()->liveNodes() == mountedNodes); +} + +TEST_CASE("ui::switchOf: a case's button survives a remount that is due while its handler runs", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + RecordingBackend backend; + Signal which{runtime, 0}; + std::string const note(64, 'n'); + bool aliveInHandler = false; + std::string seenInHandler; + ui::Mounted const view{runtime, backend, + ui::switchOf({ + .selector = [&] { return ui::Key{which.get()}; }, + .cases = {{.key = intKey(0), + .node = ui::button({.label = "Next", + .onClick = + [&, note = note] { + CHECK(owner.runOne()); // a modal loop + aliveInHandler = backend.exists(2); + seenInHandler = note; + }})}, + {.key = intKey(1), .node = ui::text({.text = "one"})}}, + })}; + which.set(1); // a write from elsewhere: its posted flush would unmount the button + backend.click(2); + CHECK(aliveInHandler); + CHECK(seenInHandler == note); + owner.runAll(); + CHECK(backend.dump() == "Slot#1\n Text#3 role=Normal text=one\n"); +} + +TEST_CASE("ui::switchOf: a case that fails to mount leaves nothing behind, and mounts when selected again", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + EchoingBackend backend; + RecordingBackend& recording = backend.recording(); + std::optional spare; + backend.setOnCreate(failWhenSpent(spare)); + Signal which{runtime, 0}; + ui::Mounted const view{ + runtime, backend, + ui::switchOf({ + .selector = [&] { return ui::Key{which.get()}; }, + .cases = {{.key = intKey(0), .node = ui::text({.text = "zero"})}, + {.key = intKey(1), + .node = + ui::column({.children = {ui::text({.text = [&] { return std::to_string(which.get()); }}), + ui::text({.text = "last"})}})}}, + })}; + std::size_t const mountedNodes = runtime.core()->liveNodes(); + + recording.clearLog(); + spare = 2; // the Column and the bound Text; the last Text fails + which.set(1); + owner.runAll(); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(recording.log() == Lines{"destroy Text#2", "create Column#3 in Slot#1", "set Column#3 gap=0", + "create Text#4 in Column#3", "set Text#4 text=1", "set Text#4 role=Normal", + "destroy Text#4", "destroy Column#3"}); + CHECK(recording.dump() == "Slot#1\n"); + CHECK(runtime.core()->liveNodes() == mountedNodes); + + which.set(0); + owner.runAll(); + which.set(1); + owner.runAll(); + CHECK(recording.dump() == + "Slot#1\n Column#6 gap=0\n Text#7 role=Normal text=1\n Text#8 role=Normal text=last\n"); +} + +TEST_CASE("ui::switchOn: each enumerator selects its case", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal page{runtime, Page::Home}; + ui::Mounted const view{ + runtime, backend, + ui::switchOn([&] { return page.get(); }, {{Page::Home, ui::text({.text = "home"})}, + {Page::Settings, ui::text({.text = "settings"})}})}; + CHECK(backend.dump() == "Slot#1\n Text#2 role=Normal text=home\n"); + page.set(Page::Settings); + owner.runAll(); + CHECK(backend.dump() == "Slot#1\n Text#3 role=Normal text=settings\n"); +} + +TEST_CASE("ui::switchOn: an empty selector is refused where it is written", "[ui]") { + CHECK_THROWS_AS(ui::switchOn(std::function{}, {{Page::Home, ui::text({.text = "home"})}}), + std::invalid_argument); +} + +TEST_CASE("ui::tabs: pages mount on first selection and are kept, hidden, afterwards", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal selected{runtime, 0}; + ui::Mounted const view{runtime, backend, + ui::tabs({ + .tabs = {{.label = "Dashboard", .node = ui::text({.text = "dash"})}, + {.label = "Stats", .node = ui::text({.text = "stats"})}}, + .selected = [&] { return selected.get(); }, + .onSelect = [&](std::size_t index) { selected.set(index); }, + })}; + CHECK(backend.dump() == "Tabs#1 selected=0 tabs=[Dashboard,Stats]\n Slot#2\n Text#3 role=Normal text=dash\n"); + + backend.clearLog(); + backend.chooseIndex(1, 1); + owner.runAll(); + // The new page is complete before the old one is hidden. + CHECK(backend.log() == Lines{"create Slot#4 in Tabs#1", "create Text#5 in Slot#4", "set Text#5 text=stats", + "set Text#5 role=Normal", "set Slot#2 visible=false", "set Tabs#1 selected=1"}); + + backend.clearLog(); + backend.chooseIndex(1, 0); + owner.runAll(); + CHECK(backend.log() == Lines{"set Slot#4 visible=false", "set Slot#2 visible=true", "set Tabs#1 selected=0"}); +} + +TEST_CASE("ui::tabs: an index past the last page shows none, and a null page is an empty slot", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal selected{runtime, 5}; + ui::Mounted const view{runtime, backend, + ui::tabs({ + .tabs = {{.label = "A", .node = ui::text({.text = "a"})}, {.label = "B", .node = {}}}, + .selected = [&] { return selected.get(); }, + })}; + CHECK(backend.dump() == "Tabs#1 selected=5 tabs=[A,B]\n"); + + selected.set(1); + owner.runAll(); + CHECK(backend.dump() == "Tabs#1 selected=1 tabs=[A,B]\n Slot#2\n"); + + backend.clearLog(); + selected.set(7); + owner.runAll(); + CHECK(backend.log() == Lines{"set Slot#2 visible=false", "set Tabs#1 selected=7"}); + + backend.clearLog(); + selected.set(0); + owner.runAll(); + CHECK(backend.log() == Lines{"create Slot#3 in Tabs#1", "create Text#4 in Slot#3", "set Text#4 text=a", + "set Text#4 role=Normal", "set Tabs#1 selected=0"}); +} + +TEST_CASE("ui::tabs: a page that fails to mount leaves the previous page shown and selected", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + EchoingBackend backend; + RecordingBackend& recording = backend.recording(); + std::optional spare; + backend.setOnCreate(failWhenSpent(spare)); + Signal selected{runtime, 0}; + ui::Mounted const view{ + runtime, backend, + ui::tabs({ + .tabs = {{.label = "A", .node = ui::text({.text = "a"})}, + {.label = "B", + .node = ui::column({.children = {ui::text({.text = [] { return std::string{"b1"}; }}), + ui::text({.text = "b2"})}})}}, + .selected = [&] { return selected.get(); }, + .onSelect = [&](std::size_t index) { selected.set(index); }, + })}; + std::string const before = recording.dump(); + REQUIRE(before == "Tabs#1 selected=0 tabs=[A,B]\n Slot#2\n Text#3 role=Normal text=a\n"); + std::size_t const mountedNodes = runtime.core()->liveNodes(); + + recording.clearLog(); + spare = 3; // the Slot, the Column and the first Text; the second Text fails + recording.chooseIndex(1, 1); + owner.runAll(); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + // The bar's highlight, which the user's pick moved, goes back to the page still shown, and the application hears + // of it through onSelect, so its index matches what the widget shows. Its write lands inside the binding's own + // run, so the binding does not run again for it. + CHECK(recording.log() == Lines{"create Slot#4 in Tabs#1", "create Column#5 in Slot#4", "set Column#5 gap=0", + "create Text#6 in Column#5", "set Text#6 text=b1", "set Text#6 role=Normal", + "destroy Text#6", "destroy Column#5", "destroy Slot#4", "set Tabs#1 selected=0"}); + CHECK(selected.peek() == 0); + CHECK(recording.dump() == before); + CHECK(runtime.core()->liveNodes() == mountedNodes); + + // Selecting the failed tab again is therefore a change, and its page mounts this time. + recording.chooseIndex(1, 1); + owner.runAll(); + CHECK(recording.dump() == + "Tabs#1 selected=1 tabs=[A,B]\n" + " Slot#2 visible=false\n" + " Text#3 role=Normal text=a\n" + " Slot#7\n" + " Column#8 gap=0\n" + " Text#9 role=Normal text=b1\n" + " Text#10 role=Normal text=b2\n"); +} + +TEST_CASE("ui::tabs: the onSelect that reports a failed page may unmount the view", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + EchoingBackend backend; + std::optional spare; + backend.setOnCreate(failWhenSpent(spare)); + Signal selected{runtime, 0}; + std::size_t const before = runtime.core()->liveNodes(); + std::vector reported; + std::unique_ptr view; + view = std::make_unique( + runtime, backend, + ui::tabs({ + .tabs = {{.label = "A", .node = ui::text({.text = "a"})}, {.label = "B", .node = ui::text({.text = "b"})}}, + .selected = [&] { return selected.get(); }, + .onSelect = + [&](std::size_t index) { + reported.push_back(index); + selected.set(index); + if (index == 0) { // only the report: the user picks the failing page + view.reset(); + } + }, + })); + spare = 1; // the Slot; the Text fails + backend.recording().chooseIndex(1, 1); + owner.runAll(); + CHECK(reported == std::vector{1, 0}); + CHECK(view == nullptr); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(backend.recording().dump().empty()); + CHECK(runtime.core()->liveNodes() == before); +} + +TEST_CASE("ui::dialog: the content exists only while open, and dismiss reaches onDismiss", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal open{runtime, false}; + ui::Mounted const view{runtime, backend, + ui::dialog({.open = [&] { return open.get(); }, + .title = "Confirm", + .child = ui::column({.children = {ui::text({.text = "Sure?"})}}), + .onDismiss = [&] { open.set(false); }})}; + CHECK(backend.dump() == "Dialog#1 open=false title=Confirm\n"); + + backend.clearLog(); + open.set(true); + owner.runAll(); + CHECK(backend.log() == Lines{"create Column#2 in Dialog#1", "set Column#2 gap=0", "create Text#3 in Column#2", + "set Text#3 text=Sure?", "set Text#3 role=Normal", "set Dialog#1 open=true"}); + + backend.clearLog(); + backend.dismiss(1); + owner.runAll(); + CHECK(backend.log() == Lines{"destroy Text#3", "destroy Column#2", "set Dialog#1 open=false"}); +} + +TEST_CASE("ui::dialog: a button inside may close the dialog, which reopens with new content", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal open{runtime, true}; + int dismissed = 0; + ui::Mounted const view{runtime, backend, + ui::dialog({.open = [&] { return open.get(); }, + .title = "Done", + .child = ui::button({.label = "Close", .onClick = [&] { open.set(false); }}), + .onDismiss = [&] { ++dismissed; }})}; + REQUIRE(backend.kindOf(2) == "Button"); + backend.click(2); + owner.runAll(); + CHECK_FALSE(backend.exists(2)); + CHECK(backend.dump() == "Dialog#1 open=false title=Done\n"); + + backend.dismiss(1); // a closed dialog is not shown, so there is nothing to dismiss + CHECK(dismissed == 0); + + open.set(true); + owner.runAll(); + CHECK(backend.dump() == "Dialog#1 open=true title=Done\n Button#3 label=Close\n"); + backend.dismiss(1); + CHECK(dismissed == 1); +} + +TEST_CASE("ui::dialog: without onDismiss, failed content leaves the dialog closed until open turns true again", + "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + EchoingBackend backend; + RecordingBackend& recording = backend.recording(); + std::optional spare; + backend.setOnCreate(failWhenSpent(spare)); + Signal open{runtime, false}; + ui::Mounted const view{ + runtime, backend, + ui::dialog({.open = [&] { return open.get(); }, + .title = "T", + .child = ui::column({.children = {ui::button({.label = [] { return std::string{"OK"}; }}), + ui::text({.text = "body"})}})})}; + std::size_t const mountedNodes = runtime.core()->liveNodes(); + + recording.clearLog(); + spare = 2; // the Column and the Button; the Text fails + open.set(true); + owner.runAll(); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(recording.log() == Lines{"create Column#2 in Dialog#1", "set Column#2 gap=0", "create Button#3 in Column#2", + "set Button#3 label=OK", "destroy Button#3", "destroy Column#2"}); + CHECK(recording.dump() == "Dialog#1 open=false title=T\n"); + CHECK(runtime.core()->liveNodes() == mountedNodes); + + // `open` is still true, so the mount is retried the next time it turns true. + open.set(false); + owner.runAll(); + open.set(true); + owner.runAll(); + CHECK(recording.dump() == + "Dialog#1 open=true title=T\n Column#4 gap=0\n Button#5 label=OK\n Text#6 role=Normal text=body\n"); +} + +TEST_CASE("ui::dialog: content that fails to mount dismisses the dialog, so the next open mounts again", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + EchoingBackend backend; + RecordingBackend& recording = backend.recording(); + std::optional spare; + backend.setOnCreate(failWhenSpent(spare)); + Signal open{runtime, false}; + int dismissed = 0; + ui::Mounted const view{runtime, backend, + ui::dialog({.open = [&] { return open.get(); }, + .title = "T", + .child = ui::column({.children = {ui::text({.text = "body"})}}), + .onDismiss = + [&] { + ++dismissed; + open.set(false); + }})}; + std::size_t const mountedNodes = runtime.core()->liveNodes(); + + recording.clearLog(); + spare = 1; // the Column; the Text fails + open.set(true); + owner.runAll(); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(recording.log() == Lines{"create Column#2 in Dialog#1", "set Column#2 gap=0", "destroy Column#2"}); + CHECK(recording.dump() == "Dialog#1 open=false title=T\n"); + CHECK(runtime.core()->liveNodes() == mountedNodes); + // The application hears of it through onDismiss, so its `open` follows the widget. + CHECK(dismissed == 1); + CHECK_FALSE(open.peek()); + + // Opening it again is therefore a change, and the content mounts this time. + open.set(true); + owner.runAll(); + CHECK(recording.dump() == "Dialog#1 open=true title=T\n Column#3 gap=0\n Text#4 role=Normal text=body\n"); + CHECK(dismissed == 1); +} + +TEST_CASE("ui::dialog: the onDismiss that reports failed content may unmount the view", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + EchoingBackend backend; + std::optional spare; + backend.setOnCreate(failWhenSpent(spare)); + Signal open{runtime, false}; + std::size_t const before = runtime.core()->liveNodes(); + std::unique_ptr view; + view = std::make_unique(runtime, backend, + ui::dialog({.open = [&] { return open.get(); }, + .title = "T", + .child = ui::text({.text = "body"}), + .onDismiss = [&] { view.reset(); }})); + spare = 0; // the Text fails + open.set(true); + owner.runAll(); + CHECK(view == nullptr); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(backend.recording().dump().empty()); + CHECK(runtime.core()->liveNodes() == before); +} + +TEST_CASE("ui::dialog: a Dialog's button survives a nested loop in its own handler", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + RecordingBackend backend; + Signal open{runtime, true}; + bool aliveInHandler = false; + ui::Mounted const view{runtime, backend, + ui::dialog({.open = [&] { return open.get(); }, + .title = "Confirm", + .child = ui::button({.label = "Close", .onClick = [&] { + CHECK(owner.runOne()); // a modal loop pumps the owner + aliveInHandler = backend.exists(2); + }})})}; + REQUIRE(backend.kindOf(2) == "Button"); + open.set(false); // a write from elsewhere: its posted flush would unmount the button + backend.click(2); + CHECK(aliveInHandler); + owner.runAll(); + CHECK_FALSE(backend.exists(2)); + CHECK(backend.prop(1, "open") == "false"); +} + +TEST_CASE("ui structure: unmounting tears content down before its host, the newest page first", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal selected{runtime, 1}; + std::size_t const before = runtime.core()->liveNodes(); + auto view = std::make_unique( + runtime, backend, + ui::column({.children = { + ui::tabs({ + .tabs = {{.label = "A", .node = ui::text({.text = "a"})}, + {.label = "B", .node = ui::text({.text = "b"})}}, + .selected = [&] { return selected.get(); }, + }), + ui::dialog({.open = true, .title = "D", .child = ui::text({.text = "body"})}), + ui::switchOf({.selector = intKey(0), + .cases = {{.key = intKey(0), .node = ui::text({.text = "case"})}}}), + }})); + // The second tab's page is shown first, so the order pages were shown in is not the order of the tabs. + selected.set(0); + owner.runAll(); + CHECK(backend.dump() == + "Column#1 gap=0\n" + " Tabs#2 selected=0 tabs=[A,B]\n" + " Slot#3 visible=false\n" + " Text#4 role=Normal text=b\n" + " Slot#9\n" + " Text#10 role=Normal text=a\n" + " Dialog#5 open=true title=D\n" + " Text#6 role=Normal text=body\n" + " Slot#7\n" + " Text#8 role=Normal text=case\n"); + + backend.clearLog(); + view.reset(); + CHECK(backend.log() == Lines{"destroy Text#8", "destroy Slot#7", "destroy Text#6", "destroy Dialog#5", + "destroy Text#10", "destroy Slot#9", "destroy Text#4", "destroy Slot#3", + "destroy Tabs#2", "destroy Column#1"}); + CHECK(backend.dump().empty()); + CHECK(runtime.core()->liveNodes() == before); +} + +TEST_CASE("ui structure: what a backend reads while content mounts subscribes no structure binding", "[ui]") { + Owner owner; + Runtime runtime{owner}; + EchoingBackend backend; + Signal noise{runtime, 0}; + Signal which{runtime, 0}; + Signal selected{runtime, 0}; + Signal open{runtime, false}; + backend.setOnCreate([&noise] { static_cast(noise.get()); }); + ui::Mounted const view{ + runtime, backend, + ui::column({.children = { + ui::switchOf({.selector = [&] { return ui::Key{which.get()}; }, + .cases = {{.key = intKey(0), .node = ui::text({.text = "zero"})}, + {.key = intKey(1), .node = ui::text({.text = "one"})}}}), + ui::tabs({.tabs = {{.label = "A", .node = ui::text({.text = "a"})}, + {.label = "B", .node = ui::text({.text = "b"})}}, + .selected = [&] { return selected.get(); }}), + ui::dialog({.open = [&] { return open.get(); }, .child = ui::text({.text = "body"})}), + }})}; + // Each binding mounts content from inside its own Effect here. + which.set(1); + selected.set(1); + open.set(true); + owner.runAll(); + REQUIRE(backend.recording().all("Text").size() == 4); + + backend.recording().clearLog(); + noise.set(1); + owner.runAll(); + CHECK(backend.recording().log().empty()); +} diff --git a/tests/test_ui_table.cpp b/tests/test_ui_table.cpp new file mode 100644 index 000000000..a788fdbe8 --- /dev/null +++ b/tests/test_ui_table.cpp @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" +#include "ui_test_support.hpp" + +namespace ui = morph::ui; + +namespace { + +using morph::reactive::Runtime; +using morph::reactive::Signal; +using morph::testing::intKey; +using ui::testing::RecordingBackend; +using Owner = morph::testing::StepExecutor; +using Probe = morph::testing::OwnerProbeRecorder; +using Lines = std::vector; + +struct Item { + std::int64_t id = 0; + std::string name; + int qty = 0; + bool operator==(Item const&) const = default; +}; + +struct Fixture { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> items{runtime, + {{.id = 1, .name = "apple", .qty = 3}, {.id = 2, .name = "pear", .qty = 5}}}; + Signal> selection{runtime, {}}; + std::vector activated; + + ui::Node view() { + return ui::table( + {{.label = "Name"}, {.label = "Qty", .width = ui::Sizing::fixed(6)}}, [this] { return items.get(); }, + [](Item const& item) { return ui::Key{item.id}; }, + [](Signal const& item) { + return std::vector{ui::text({.text = [&item] { return item.get().name; }}), + ui::text({.text = [&item] { return std::to_string(item.get().qty); }})}; + }, + {.selectionMode = ui::SelectionMode::Single, + .selection = [this] { return selection.get(); }, + .onSelectionChange = [this](std::vector keys) { selection.set(std::move(keys)); }, + .onActivate = [this](ui::Key key) { activated.push_back(std::move(key)); }}); + } +}; + +ui::Key keyOf(Item const& item) { return ui::Key{item.id}; } + +// One cell per column: the name. +std::vector nameCell(Signal const& item) { + return {ui::text({.text = [&item] { return item.get().name; }})}; +} + +std::vector fruit() { + return {{.id = 1, .name = "apple", .qty = 3}, {.id = 2, .name = "pear", .qty = 5}, {.id = 3, .name = "plum"}}; +} + +void removeItem(Signal>& items, std::int64_t itemId) { + items.mutate([itemId](std::vector& rows) { + std::erase_if(rows, [itemId](Item const& item) { return item.id == itemId; }); + }); +} + +} // namespace + +TEST_CASE("ui::table: columns, keyed rows of cells, selection after the rows", "[ui]") { + Fixture fixture; + ui::Mounted const mounted{fixture.runtime, fixture.backend, fixture.view()}; + CHECK(fixture.backend.dump() == + "Table#1 columns=[Name:content,Qty:fixed(6)] selection=[] selectionMode=Single\n" + " Row#2 gap=0 rowKey=1\n" + " Text#3 role=Normal text=apple\n" + " Text#4 role=Normal text=3\n" + " Row#5 gap=0 rowKey=2\n" + " Text#6 role=Normal text=pear\n" + " Text#7 role=Normal text=5\n"); + CHECK(fixture.backend.log().back() == "set Table#1 selection=[]"); +} + +TEST_CASE("ui::table: a user's selection comes back through the selection prop", "[ui]") { + Fixture fixture; + ui::Mounted const mounted{fixture.runtime, fixture.backend, fixture.view()}; + fixture.backend.clearLog(); + fixture.backend.selectRows(1, {intKey(2)}); + fixture.owner.runAll(); + CHECK(fixture.selection.peek() == std::vector{intKey(2)}); + CHECK(fixture.backend.log() == Lines{"set Table#1 selection=[2]"}); +} + +TEST_CASE("ui::table: activating a row reports its key", "[ui]") { + Fixture fixture; + ui::Mounted const mounted{fixture.runtime, fixture.backend, fixture.view()}; + fixture.backend.activateRow(1, intKey(1)); + CHECK(fixture.activated == std::vector{intKey(1)}); +} + +TEST_CASE("ui::table: a new row is told its key once; a reorder only moves", "[ui]") { + Fixture fixture; + ui::Mounted const mounted{fixture.runtime, fixture.backend, fixture.view()}; + + fixture.backend.clearLog(); + fixture.items.mutate([](std::vector& rows) { rows.push_back({.id = 3, .name = "plum", .qty = 1}); }); + fixture.owner.runAll(); + CHECK(fixture.backend.log() == Lines{"create Row#8 in Table#1", "set Row#8 gap=0", "create Text#9 in Row#8", + "set Text#9 text=plum", "set Text#9 role=Normal", "create Text#10 in Row#8", + "set Text#10 text=1", "set Text#10 role=Normal", "set Row#8 rowKey=3"}); + + fixture.backend.clearLog(); + fixture.items.set({{.id = 2, .name = "pear", .qty = 5}, + {.id = 1, .name = "apple", .qty = 3}, + {.id = 3, .name = "plum", .qty = 1}}); + fixture.owner.runAll(); + CHECK(fixture.backend.log() == Lines{"move Row#2 to 1"}); +} + +TEST_CASE("ui::table: an empty rows, key or cells function is refused where it is written", "[ui]") { + std::vector const columns{{.label = "Name"}}; + auto const rows = [] { return fruit(); }; + CHECK_THROWS_AS(ui::table(columns, {}, keyOf, nameCell), std::invalid_argument); + CHECK_THROWS_AS(ui::table(columns, rows, {}, nameCell), std::invalid_argument); + CHECK_THROWS_AS(ui::table(columns, rows, keyOf, {}), std::invalid_argument); +} + +TEST_CASE("ui::table: column captions are escaped as list items", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + ui::Mounted const mounted{runtime, backend, + ui::table( + {{.label = "a,b"}, {.label = "x=[y]:z", .width = ui::Sizing::stretch(2)}}, + [] { return std::vector{}; }, keyOf, + [](Signal const& item) { + return std::vector{ + ui::text({.text = [&item] { return item.get().name; }}), + ui::text({.text = [&item] { return std::to_string(item.get().qty); }})}; + })}; + CHECK(backend.prop(1, "columns") == "[a\\,b:content,x\\=\\[y\\]:z:stretch(2)]"); +} + +TEST_CASE("ui::table: a row whose cells are not one per column is refused and reported; the others mount", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> items{runtime, {{.id = 1, .name = "apple"}, {.id = 2, .name = "pear"}}}; + // Two columns. Pear gets one cell; plum gets a null second cell. + ui::Mounted const mounted{ + runtime, backend, + ui::table( + {{.label = "Name"}, {.label = "Qty"}}, [&items] { return items.get(); }, keyOf, + [](Signal const& item) { + std::vector cells{ui::text({.text = [&item] { return item.get().name; }})}; + if (item.peek().id == 1) { + cells.push_back(ui::text({.text = "-"})); + } else if (item.peek().id == 3) { + cells.push_back(nullptr); + } + return cells; + })}; + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(backend.dump() == + "Table#1 columns=[Name:content,Qty:content] selection=[] selectionMode=None\n" + " Row#2 gap=0 rowKey=1\n" + " Text#3 role=Normal text=apple\n" + " Text#4 role=Normal text=-\n"); + + items.set({{.id = 1, .name = "apple"}, {.id = 3, .name = "plum"}}); + owner.runAll(); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 2); + CHECK(backend.all("Row") == std::vector{2}); +} + +TEST_CASE("ui::table: a selected key without a row marks nothing until its row arrives, and stays selected", "[ui]") { + Fixture fixture; + ui::Mounted const mounted{fixture.runtime, fixture.backend, fixture.view()}; + fixture.backend.clearLog(); + fixture.selection.set({intKey(3)}); + fixture.owner.runAll(); + CHECK(fixture.backend.log() == Lines{"set Table#1 selection=[]"}); + + fixture.backend.clearLog(); + fixture.items.mutate([](std::vector& rows) { rows.push_back({.id = 3, .name = "plum", .qty = 1}); }); + fixture.owner.runAll(); + CHECK(fixture.backend.prop(1, "selection") == "[3]"); + CHECK(fixture.backend.log().back() == "set Row#8 rowKey=3"); // marked by the widget, not by a setter + + // A reorder keeps the mark. + fixture.items.set({{.id = 3, .name = "plum", .qty = 1}, + {.id = 1, .name = "apple", .qty = 3}, + {.id = 2, .name = "pear", .qty = 5}}); + fixture.owner.runAll(); + CHECK(fixture.backend.prop(1, "selection") == "[3]"); + + // Its row going unmarks it; the key stays requested, so its row coming back marks it again. + removeItem(fixture.items, 3); + fixture.owner.runAll(); + CHECK(fixture.backend.prop(1, "selection") == "[]"); + fixture.items.mutate([](std::vector& rows) { rows.push_back({.id = 3, .name = "plum", .qty = 1}); }); + fixture.owner.runAll(); + CHECK(fixture.backend.prop(1, "selection") == "[3]"); + + // None of that was the user's doing: the handler never ran, so the application's selection is untouched. + CHECK(fixture.selection.peek() == std::vector{intKey(3)}); +} + +TEST_CASE("ui::table: a Multiple table reports every selected key, in the user's order", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + std::vector> reported; + ui::Mounted const mounted{runtime, backend, + ui::table({{.label = "Name"}}, [] { return fruit(); }, keyOf, nameCell, + {.selectionMode = ui::SelectionMode::Multiple, + .onSelectionChange = [&reported](std::vector keys) { + reported.push_back(std::move(keys)); + }})}; + backend.selectRows(1, {intKey(3), intKey(1)}); + CHECK(reported == std::vector>{{intKey(3), intKey(1)}}); + CHECK(backend.prop(1, "selection") == "[3,1]"); +} + +TEST_CASE("ui::table: a user's change reports the selected rows only, and drops keys still waiting for a row", + "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> items{runtime, {{.id = 1, .name = "apple"}, {.id = 3, .name = "plum"}}}; + std::vector> reported; + ui::Mounted const mounted{runtime, backend, + ui::table({{.label = "Name"}}, [&items] { return items.get(); }, keyOf, nameCell, + {.selectionMode = ui::SelectionMode::Multiple, + .selection = std::vector{intKey(1), intKey(2)}, + .onSelectionChange = [&reported](std::vector keys) { + reported.push_back(std::move(keys)); + }})}; + CHECK(backend.prop(1, "selection") == "[1]"); // 2 waits for its row + + backend.selectRows(1, {intKey(3), intKey(1)}); + CHECK(reported == std::vector>{{intKey(3), intKey(1)}}); + CHECK(backend.prop(1, "selection") == "[3,1]"); + + // The user's change replaced the requested keys: row 2 arriving now is not selected. + items.mutate([](std::vector& rows) { rows.push_back({.id = 2, .name = "pear"}); }); + owner.runAll(); + CHECK(backend.prop(1, "selection") == "[3,1]"); + CHECK(reported.size() == 1); +} + +TEST_CASE("ui::table: selectRows and activateRow refuse what a user cannot do", "[ui]") { + Fixture fixture; + ui::Mounted const mounted{fixture.runtime, fixture.backend, fixture.view()}; + CHECK_THROWS_AS(fixture.backend.selectRows(1, {intKey(9)}), std::logic_error); // no such row + CHECK_THROWS_AS(fixture.backend.selectRows(1, {intKey(1), intKey(2)}), std::logic_error); // Single + CHECK_THROWS_AS(fixture.backend.selectRows(1, {intKey(1), intKey(1)}), std::logic_error); // one row twice + CHECK_THROWS_AS(fixture.backend.activateRow(1, intKey(9)), std::logic_error); + CHECK_THROWS_AS(fixture.backend.selectRows(3, {intKey(1)}), std::logic_error); // a Text + CHECK_THROWS_AS(fixture.backend.activateRow(2, intKey(1)), std::logic_error); // a Row + CHECK(fixture.activated.empty()); + CHECK(fixture.selection.peek().empty()); + + // A table without selection still activates. + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + std::vector activated; + ui::Mounted const plain{ + runtime, backend, + ui::table({{.label = "Name"}}, [] { return fruit(); }, keyOf, nameCell, + {.onActivate = [&activated](ui::Key key) { activated.push_back(std::move(key)); }})}; + CHECK_THROWS_AS(backend.selectRows(1, {intKey(1)}), std::logic_error); + backend.activateRow(1, intKey(2)); + CHECK(activated == std::vector{intKey(2)}); +} + +TEST_CASE("ui::table: a hidden or disabled table takes no selection and no activation", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal shown{runtime, false}; + Signal enabled{runtime, true}; + int calls = 0; + ui::Mounted const mounted{runtime, backend, + ui::table({{.label = "Name"}}, [] { return fruit(); }, keyOf, nameCell, + {.selectionMode = ui::SelectionMode::Single, + .onSelectionChange = [&calls](std::vector const&) { ++calls; }, + .onActivate = [&calls](ui::Key const&) { ++calls; }, + .common = {.visible = [&shown] { return shown.get(); }, + .enabled = [&enabled] { return enabled.get(); }}})}; + backend.selectRows(1, {intKey(1)}); + backend.activateRow(1, intKey(1)); + CHECK(calls == 0); + CHECK(backend.prop(1, "selection") == "[]"); + + shown.set(true); + enabled.set(false); + owner.runAll(); + backend.selectRows(1, {intKey(1)}); + backend.activateRow(1, intKey(1)); + CHECK(calls == 0); + + enabled.set(true); + owner.runAll(); + backend.selectRows(1, {intKey(1)}); + backend.activateRow(1, intKey(1)); + CHECK(calls == 2); +} + +TEST_CASE("ui::table: a callback that throws is reported and goes no further", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + RecordingBackend backend; + ui::Mounted const mounted{ + runtime, backend, + ui::table( + {{.label = "Name"}}, [] { return fruit(); }, keyOf, nameCell, + {.selectionMode = ui::SelectionMode::Single, + .onSelectionChange = [](std::vector const&) { throw std::runtime_error{"selection failed"}; }, + .onActivate = [](ui::Key const&) { throw std::runtime_error{"activation failed"}; }})}; + CHECK_NOTHROW(backend.selectRows(1, {intKey(2)})); + CHECK_NOTHROW(backend.activateRow(1, intKey(2))); + CHECK(probe.count(ui::detail::site::kCallbackThrew) == 2); +} + +TEST_CASE("ui::table: activating a row may remove it, and a selection may empty the table", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> items{runtime, fruit()}; + // Larger than std::function's inline buffer, so each handler's closure lives on the heap, where ASan watches it. + std::string const note(64, 'n'); + bool rowAliveAfterWrite = false; + std::string seenAfter; + ui::Mounted const mounted{ + runtime, backend, + ui::table({{.label = "Name"}}, [&items] { return items.get(); }, keyOf, nameCell, + {.selectionMode = ui::SelectionMode::Single, + .onSelectionChange = + [&items, &seenAfter, note = note](std::vector const&) { + items.set({}); + seenAfter = note + "cleared"; + }, + .onActivate = + [&items, &backend, &rowAliveAfterWrite, &seenAfter, note = note](ui::Key const& key) { + removeItem(items, std::get(key)); + rowAliveAfterWrite = backend.exists(4); + seenAfter = note + "removed"; + }})}; + std::size_t const mountedNodes = runtime.core()->liveNodes(); + + backend.clearLog(); + backend.activateRow(1, intKey(2)); // Row#4, with Text#5 + CHECK(rowAliveAfterWrite); + CHECK(seenAfter == note + "removed"); + owner.runAll(); + CHECK(backend.log() == Lines{"destroy Text#5", "destroy Row#4"}); + // The row's signal, and its text's Computed and Effect, are gone with it. + CHECK(runtime.core()->liveNodes() == mountedNodes - 3); + + backend.selectRows(1, {intKey(3)}); + CHECK(seenAfter == note + "cleared"); + owner.runAll(); + CHECK(backend.dump() == "Table#1 columns=[Name:content] selection=[] selectionMode=Single\n"); +} + +TEST_CASE("ui::table: a callback may destroy the whole view, its table included", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + std::size_t const before = runtime.core()->liveNodes(); + std::string const note(64, 'n'); + std::unique_ptr view; + std::string seenAfter; + auto const build = [&] { + view = std::make_unique( + runtime, backend, + ui::table({{.label = "Name"}}, [] { return fruit(); }, keyOf, nameCell, + {.selectionMode = ui::SelectionMode::Single, + .onSelectionChange = + [&view, &seenAfter, note = note](std::vector const& keys) { + view.reset(); + seenAfter = note + std::to_string(keys.size()); + }, + .onActivate = + [&view, &seenAfter, note = note](ui::Key const& key) { + view.reset(); + seenAfter = note + std::to_string(std::get(key)); + }})); + }; + + build(); + int const table = backend.idOf(view->root()); + backend.activateRow(table, intKey(2)); + CHECK(view == nullptr); + CHECK_FALSE(backend.exists(table)); + CHECK(seenAfter == note + "2"); + CHECK(runtime.core()->liveNodes() == before); + + build(); + int const second = backend.idOf(view->root()); + backend.selectRows(second, {intKey(1)}); + CHECK(view == nullptr); + CHECK_FALSE(backend.exists(second)); + CHECK(seenAfter == note + "1"); + CHECK(runtime.core()->liveNodes() == before); +} diff --git a/tests/test_ui_view.cpp b/tests/test_ui_view.cpp new file mode 100644 index 000000000..52d4a2a92 --- /dev/null +++ b/tests/test_ui_view.cpp @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ui = morph::ui; + +static_assert(std::is_same_v>); +static_assert(std::is_same_v>); + +TEST_CASE("ui::Prop: a constant is not bound, a callable is", "[ui]") { + ui::Prop const fixed = "hello"; + CHECK_FALSE(fixed.isBound()); + CHECK(fixed.constant() == "hello"); + CHECK(fixed.evaluate() == "hello"); + + int calls = 0; + ui::Prop const bound = [&calls] { + ++calls; + return std::string{"bound"}; + }; + CHECK(bound.isBound()); + CHECK(bound.evaluate() == "bound"); + CHECK(bound.binding()() == "bound"); + CHECK(calls == 2); +} + +TEST_CASE("ui::Prop: a captureless lambda is a binding, not a truthy constant", "[ui]") { + ui::Prop const bound = [] { return false; }; + CHECK(bound.isBound()); + CHECK_FALSE(bound.evaluate()); +} + +TEST_CASE("ui::Prop: a default-constructed prop is the value-initialised constant", "[ui]") { + ui::Prop const text; + ui::Prop> const key; + CHECK_FALSE(text.isBound()); + CHECK(text.constant().empty()); + CHECK_FALSE(key.constant().has_value()); +} + +TEST_CASE("ui::Sizing and ui::LayoutHints: the three rules and their equality", "[ui]") { + CHECK(ui::Sizing::content() == ui::Sizing{}); + CHECK(ui::Sizing::fixed(3) == ui::Sizing{.kind = ui::Sizing::Kind::Fixed, .amount = 3}); + CHECK(ui::Sizing::stretch() == ui::Sizing{.kind = ui::Sizing::Kind::Stretch, .amount = 1}); + CHECK(ui::LayoutHints{} == ui::LayoutHints{.width = ui::Sizing::content(), .height = ui::Sizing::content()}); + CHECK_FALSE(ui::LayoutHints{.width = ui::Sizing::fixed(2)} == ui::LayoutHints{}); +} + +TEST_CASE("ui::Common: shown, enabled, content-sized, not draggable, not a drop target", "[ui]") { + ui::Common const common; + CHECK(common.visible.constant()); + CHECK(common.enabled.constant()); + CHECK(common.layout == ui::LayoutHints{}); + CHECK_FALSE(common.dragKey.constant().has_value()); + CHECK_FALSE(common.accepts); + CHECK_FALSE(common.onDrop); +} + +TEST_CASE("ui builders: nodes are shared, immutable data", "[ui]") { + ui::Node const tree = ui::column({ + .children = + { + ui::text({.text = "Title", .role = ui::TextRole::Heading}), + ui::button({.label = "Go", .onClick = [] {}}), + ui::spacer(), + }, + .gap = 1, + }); + REQUIRE(tree != nullptr); + auto const* column = std::get_if(&tree->kind); + REQUIRE(column != nullptr); + REQUIRE(column->children.size() == 3); + CHECK(column->gap == 1); + auto const* title = std::get_if(&column->children.at(0)->kind); + REQUIRE(title != nullptr); + CHECK(title->text.constant() == "Title"); + CHECK(title->role.constant() == ui::TextRole::Heading); + CHECK(std::holds_alternative(column->children.at(2)->kind)); +} + +TEST_CASE("ui builders: the input kinds carry their modes and ranges", "[ui]") { + using morph::time::DateTime; + using morph::time::Timestamp; + Timestamp const when{DateTime{std::chrono::year{2026}, std::chrono::month{10}, std::chrono::day{4}, + std::chrono::hours{9}, std::chrono::minutes{30}, std::chrono::seconds{0}}}; + ui::Node const date = + ui::dateTimeInput({.value = std::optional{when}, .mode = ui::DateMode::Date, .offsetMinutes = 120}); + ui::Node const slider = ui::slider({.value = 5, .minimum = 0, .maximum = 10, .step = 5}); + ui::Node const picker = ui::filePicker({.path = "/tmp/out.csv", .mode = ui::FilePickerMode::Save}); + ui::Node const panel = ui::panel({.title = "Advanced", .collapsible = true, .collapsed = true}); + + auto const& dateSpec = std::get(date->kind); + CHECK(dateSpec.value.constant() == std::optional{when}); + CHECK(dateSpec.offsetMinutes == 120); + CHECK(std::get(slider->kind).step == 5); + CHECK(std::get(picker->kind).mode == ui::FilePickerMode::Save); + CHECK(std::get(panel->kind).collapsed.constant()); +} + +namespace { + +std::string boundLabel() { return "from a function"; } + +} // namespace + +TEST_CASE("ui::Prop: a pointer is not a bool constant, a floating-point value is not an integral one", "[ui]") { + // Runtime checks rather than static_asserts, so that loosening the constraint fails a test instead of the build. + CHECK_FALSE(std::is_constructible_v, char const(&)[5]>); + CHECK_FALSE(std::is_constructible_v, int*>); + CHECK_FALSE(std::is_constructible_v, double>); + CHECK_FALSE(std::is_constructible_v, float>); + CHECK_FALSE(std::is_constructible_v, double>); + + CHECK(std::is_constructible_v, bool>); + CHECK(std::is_constructible_v, int>); + ui::Prop const count = 0; + CHECK(count.constant() == 0U); + ui::Prop const literal = "text"; + CHECK(literal.constant() == "text"); +} + +TEST_CASE("ui::Prop: an empty binding is refused at construction", "[ui]") { + std::function const empty; + std::string (*const nullBinding)() = nullptr; + CHECK_THROWS_AS(ui::Prop{empty}, std::invalid_argument); + CHECK_THROWS_AS(ui::Prop{nullBinding}, std::invalid_argument); +} + +TEST_CASE("ui::Prop: every callable form is a binding", "[ui]") { + ui::Prop const fromPointer = &boundLabel; + CHECK(fromPointer.isBound()); + CHECK(fromPointer.evaluate() == "from a function"); + + std::function const function = [] { return std::string{"from a std::function"}; }; + ui::Prop const fromFunction = function; + CHECK(fromFunction.isBound()); + CHECK(fromFunction.evaluate() == "from a std::function"); + + ui::Prop const counter = [next = std::int64_t{0}]() mutable { return ++next; }; + CHECK(counter.isBound()); + CHECK(counter.evaluate() == 1); + CHECK(counter.evaluate() == 2); + + ui::Prop> const key = [] { return std::optional{std::int64_t{7}}; }; + ui::Prop> const keyFromKey = [] { return ui::Key{std::string{"seven"}}; }; + CHECK(key.isBound()); + CHECK(key.evaluate() == std::optional{std::int64_t{7}}); + CHECK(keyFromKey.isBound()); + CHECK(keyFromKey.evaluate() == std::optional{std::string{"seven"}}); +} + +TEST_CASE("ui::Prop: copying a prop copies it, binding or constant", "[ui]") { + // The copies below are the subject of the test, so the copy-initialisation check is suppressed on them. + int calls = 0; + ui::Prop const bound = [&calls] { + ++calls; + return std::string{"bound"}; + }; + ui::Prop const boundCopy = bound; // NOLINT(performance-unnecessary-copy-initialization) + CHECK(boundCopy.isBound()); + CHECK(boundCopy.evaluate() == "bound"); + CHECK(calls == 1); + + ui::Prop const fixed = false; + ui::Prop const fixedCopy = fixed; // NOLINT(performance-unnecessary-copy-initialization) + CHECK_FALSE(fixedCopy.isBound()); + CHECK_FALSE(fixedCopy.constant()); +} diff --git a/tests/ui_echoing_backend.hpp b/tests/ui_echoing_backend.hpp new file mode 100644 index 000000000..3a8685c9e --- /dev/null +++ b/tests/ui_echoing_backend.hpp @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace morph::testing { + +/// A Select that, like a toolkit combo box being repopulated, reports the option it now marks when its options are +/// replaced. The contract allows this: only `setSelected` promises never to call `onSelect`. +class EchoingSelect final : public ::morph::ui::SelectWidget { +public: + explicit EchoingSelect(std::unique_ptr<::morph::ui::SelectWidget> inner) : _inner{std::move(inner)} {} + void setVisible(bool visible) override { _inner->setVisible(visible); } + void setEnabled(bool enabled) override { _inner->setEnabled(enabled); } + void setLayout(::morph::ui::LayoutHints const& hints) override { _inner->setLayout(hints); } + void setDragKey(std::optional<::morph::ui::Key> const& key) override { _inner->setDragKey(key); } + void setDropHandler(std::function accepts, + std::function onDrop) override { + _inner->setDropHandler(std::move(accepts), std::move(onDrop)); + } + void setOptions(std::vector<::morph::ui::SelectOption> const& options) override { + _inner->setOptions(options); + if (auto const echo = _onSelect; echo && !options.empty()) { + echo(options.front().key); + } + } + void setSelected(std::optional<::morph::ui::Key> const& key) override { _inner->setSelected(key); } + void setOnSelect(std::function onSelect) override { + _onSelect = onSelect; + _inner->setOnSelect(std::move(onSelect)); + } + +private: + std::unique_ptr<::morph::ui::SelectWidget> _inner; + std::function _onSelect; +}; + +/// A `RecordingBackend` whose Selects echo (see `EchoingSelect`), and whose factories first run `onCreate`, as a +/// backend that reads state while it builds a widget would. +class EchoingBackend final : public ::morph::ui::IViewBackend { +public: + [[nodiscard]] ::morph::ui::testing::RecordingBackend& recording() noexcept { return _recording; } + void setOnCreate(std::function onCreate) { _onCreate = std::move(onCreate); } + + std::unique_ptr<::morph::ui::TextWidget> createText(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createText(parent); + } + std::unique_ptr<::morph::ui::ButtonWidget> createButton(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createButton(parent); + } + std::unique_ptr<::morph::ui::TextInputWidget> createTextInput(::morph::ui::ContainerWidget* parent, + ::morph::ui::TextInputMode mode) override { + created(); + return _recording.createTextInput(parent, mode); + } + std::unique_ptr<::morph::ui::CheckboxWidget> createCheckbox(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createCheckbox(parent); + } + std::unique_ptr<::morph::ui::SelectWidget> createSelect(::morph::ui::ContainerWidget* parent, + ::morph::ui::SelectStyle style) override { + created(); + return std::make_unique(_recording.createSelect(parent, style)); + } + std::unique_ptr<::morph::ui::MenuWidget> createMenu(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createMenu(parent); + } + std::unique_ptr<::morph::ui::StackWidget> createStack(::morph::ui::ContainerWidget* parent, + ::morph::ui::Axis axis) override { + created(); + return _recording.createStack(parent, axis); + } + std::unique_ptr<::morph::ui::GridWidget> createGrid(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createGrid(parent); + } + std::unique_ptr<::morph::ui::SpacerWidget> createSpacer(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createSpacer(parent); + } + std::unique_ptr<::morph::ui::PanelWidget> createPanel(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createPanel(parent); + } + std::unique_ptr<::morph::ui::ScrollWidget> createScroll(::morph::ui::ContainerWidget* parent, + ::morph::ui::Axis axis) override { + created(); + return _recording.createScroll(parent, axis); + } + std::unique_ptr<::morph::ui::SlotWidget> createSlot(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createSlot(parent); + } + std::unique_ptr<::morph::ui::TabsWidget> createTabs(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createTabs(parent); + } + std::unique_ptr<::morph::ui::DialogWidget> createDialog(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createDialog(parent); + } + std::unique_ptr<::morph::ui::BusyWidget> createBusy(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createBusy(parent); + } + std::unique_ptr<::morph::ui::TableWidget> createTable(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createTable(parent); + } + std::unique_ptr<::morph::ui::DateTimeInputWidget> createDateTimeInput(::morph::ui::ContainerWidget* parent, + ::morph::ui::DateMode mode, + int offsetMinutes) override { + created(); + return _recording.createDateTimeInput(parent, mode, offsetMinutes); + } + std::unique_ptr<::morph::ui::SliderWidget> createSlider(::morph::ui::ContainerWidget* parent) override { + created(); + return _recording.createSlider(parent); + } + std::unique_ptr<::morph::ui::FilePickerWidget> createFilePicker(::morph::ui::ContainerWidget* parent, + ::morph::ui::FilePickerMode mode) override { + created(); + return _recording.createFilePicker(parent, mode); + } + +private: + void created() const { + if (_onCreate) { + _onCreate(); + } + } + + ::morph::ui::testing::RecordingBackend _recording; + std::function _onCreate; +}; + +} // namespace morph::testing diff --git a/tests/ui_test_support.hpp b/tests/ui_test_support.hpp new file mode 100644 index 000000000..d7a6eb19f --- /dev/null +++ b/tests/ui_test_support.hpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include + +namespace morph::testing { + +/// An integer key, spelled once instead of a `Key` constructor at every use. +inline ::morph::ui::Key intKey(std::int64_t value) { return ::morph::ui::Key{value}; } + +/// A factory hook for `EchoingBackend`: while @p spare is engaged, each widget made spends one, and the factory that +/// finds none left throws instead of making its widget. That disengages @p spare, so the next attempt succeeds. +inline std::function failWhenSpent(std::optional& spare) { + return [&spare] { + if (spare.has_value() && (*spare)-- == 0) { + spare.reset(); + throw std::runtime_error{"the backend could not make the widget"}; + } + }; +} + +} // namespace morph::testing From 847ec43d9cfbf03bf735a001d07c470661aba48b Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 10 Oct 2026 17:49:43 +0300 Subject: [PATCH 3/9] ui: content scopes are child scopes, and properties are slots with ids A Switch case, a Tabs page, a Dialog's content and every ForEach or Table row now mount into a scope one level deeper than the binding that mounts them, and Mounted takes the depth of the scope it is placed under. The runtime runs queued Effects owners first, so an Effect that takes content away runs before that content's bindings in the same flush, even when the content is older, and a binding never evaluates against state its owner is removing. A bound property is a slot: ui::slot(id, read) gives it the stable id a code-generating renderer refers to, a bare callable gives it none, and Prop::slotId() reads it. Mounted ignores the id. Co-Authored-By: Claude Opus 5.5 --- include/morph/ui/mount.hpp | 57 +++++++++++------- include/morph/ui/view.hpp | 114 +++++++++++++++++++++++++++++------- tests/test_ui_structure.cpp | 41 +++++++++++++ tests/test_ui_view.cpp | 24 ++++++++ 4 files changed, 193 insertions(+), 43 deletions(-) diff --git a/include/morph/ui/mount.hpp b/include/morph/ui/mount.hpp index 49c48c2fd..97848b525 100644 --- a/include/morph/ui/mount.hpp +++ b/include/morph/ui/mount.hpp @@ -405,9 +405,11 @@ class Mounter { // Content a binding mounts (a Switch case, Dialog content), all or nothing: it is built in a scope of its own, // which the caller takes only once the mount completed, so content that throws partway leaves nothing behind. - // Built untracked, so the binding's Effect depends on its own prop alone. - [[nodiscard]] std::unique_ptr mountContent(Node const& node, ContainerWidget& host) { - auto content = std::make_unique(*_rt); + // Built untracked, so the binding's Effect depends on its own prop alone. The scope is one level deeper than the + // one that owns the binding, so the owner's Effects run before the content's. + [[nodiscard]] std::unique_ptr mountContent(std::size_t depth, Node const& node, + ContainerWidget& host) { + auto content = std::make_unique(*_rt, depth); _rt->untracked([&] { static_cast(mount(*content, node, &host)); }); return content; } @@ -420,20 +422,21 @@ class Mounter { applyCommon(scope, widget, spec.common); auto& content = scope.make>(); bind(scope, spec.selector, - [this, &widget, &content, cases = spec.cases, fallback = spec.fallback](Key const& key) { + [this, &widget, &content, depth = scope.depth() + 1, cases = spec.cases, + fallback = spec.fallback](Key const& key) { content.reset(); auto const chosen = std::ranges::find(cases, key, &SwitchCase::key); Node const& node = chosen == cases.end() ? fallback : chosen->node; if (node) { - content = mountContent(node, widget); + content = mountContent(depth, node, widget); } }); return widget; } - // Every page is owned by `pageScope`, made before the binding, so pages are destroyed newest first, after the - // binding that shows them and before the tab bar. A scope fixes that order; a vector's element destruction order - // differs between standard libraries. + // Every page is owned by `pageScope`, a child scope made before the binding, so pages are destroyed newest first, + // after the binding that shows them and before the tab bar. A scope fixes that order; a vector's element + // destruction order differs between standard libraries. // // A bound index is not equality-gated: the Effect reads the binding itself, not a Computed. After a failed page, // the application's onSelect writes the index back inside this Effect's own run; a Computed would keep the failed @@ -451,7 +454,7 @@ class Mounter { } widget.setTabs(labels); widget.setOnSelect(event(spec.onSelect)); - auto& pageScope = scope.make(*_rt); + auto& pageScope = scope.child(); auto& pages = scope.make(); pages.slots.resize(spec.tabs.size(), nullptr); pages.onSelect = event(spec.onSelect); @@ -526,7 +529,7 @@ class Mounter { // The page is built in a scope of its own and handed to `pageScope` once complete, so one that fails to mount // leaves nothing behind. void mountPage(TabsWidget& widget, reactive::Scope& pageScope, Pages& pages, Node const& node, std::size_t index) { - auto page = std::make_unique(*_rt); + auto page = std::make_unique(*_rt, pageScope.depth()); SlotWidget& slot = page->adopt(_backend->createSlot(&widget)); static_cast(mount(*page, node, &slot)); pageScope.adopt(std::move(page)); @@ -548,12 +551,13 @@ class Mounter { std::function onDismiss = event(spec.onDismiss); widget.setOnDismiss(onDismiss); auto& content = scope.make>(); - auto show = [this, &widget, &content, child = spec.child](bool open, std::function const& dismiss) { + auto show = [this, &widget, &content, depth = scope.depth() + 1, child = spec.child]( + bool open, std::function const& dismiss) { if (!open) { content.reset(); } else if (content == nullptr) { try { - content = mountContent(child, widget); + content = mountContent(depth, child, widget); } catch (...) { dismissFailed(dismiss); throw; @@ -620,11 +624,11 @@ class Mounter { scope.make>(model); ForEachSession& session = scope.adopt(model->open(*_rt)); auto& rows = scope.make(); - scope.effect([this, &session, &rows, &container, onRow = std::move(onRow)] { + scope.effect([this, &session, &rows, &container, depth = scope.depth() + 1, onRow = std::move(onRow)] { std::vector const keys = session.pull(); // tracked: the rows are this Effect's only source // Untracked: what a row view reads while it is built, or what a kept row's update touches, must not // become a dependency of the whole list. - _rt->untracked([&] { reconcile(session, rows.list, container, keys, onRow); }); + _rt->untracked([&] { reconcile(depth, session, rows.list, container, keys, onRow); }); }); } @@ -643,10 +647,11 @@ class Mounter { return order; } - // All or nothing: the row is built in a scope of its own, which the caller takes only once the mount completed. - [[nodiscard]] KeyedRow mountRow(ForEachSession& session, ContainerWidget& container, Key const& key, - std::size_t index, RowHook const& onRow) { - KeyedRow row{.key = key, .scope = std::make_unique(*_rt)}; + // All or nothing: the row is built in a scope of its own, one level deeper than the ForEach's, which the caller + // takes only once the mount completed. + [[nodiscard]] KeyedRow mountRow(std::size_t depth, ForEachSession& session, ContainerWidget& container, + Key const& key, std::size_t index, RowHook const& onRow) { + KeyedRow row{.key = key, .scope = std::make_unique(*_rt, depth)}; row.slot = &row.scope->adopt(session.makeRow(index)); row.widget = mount(*row.scope, row.slot->view(), &container); if (row.widget != nullptr && onRow) { @@ -705,7 +710,7 @@ class Mounter { // something throws, so a failure costs this run's remaining work and never the bookkeeping of the next one. // A row that fails to mount leaves nothing behind and does not stop the other rows; the first failure is // rethrown at the end, for the Effect to report, and the next run mounts the row again. - void reconcile(ForEachSession& session, std::vector& rows, ContainerWidget& container, + void reconcile(std::size_t depth, ForEachSession& session, std::vector& rows, ContainerWidget& container, std::vector const& keys, RowHook const& onRow) { std::vector const order = firstOccurrences(keys); std::unordered_map wanted; // key -> snapshot index @@ -735,7 +740,7 @@ class Mounter { continue; } try { - rows.push_back(mountRow(session, container, keys.at(index), index, onRow)); + rows.push_back(mountRow(depth, session, container, keys.at(index), index, onRow)); } catch (...) { if (!failure) { failure = std::current_exception(); @@ -819,6 +824,12 @@ class Mounter { /// rows mounts it again. A key repeated in one snapshot is reported (`detail::site::kDuplicateKey`), and only its /// first row is shown. /// +/// Bindings run in owner order. The tree's own bindings are at the depth the mount was given; the content of a Switch +/// case, a Tabs page or a Dialog, and every ForEach or Table row, is in a scope one level deeper than the binding that +/// mounted it. So an Effect that removes content runs before the content's own bindings in the same flush, and a +/// binding never evaluates against state its owner is about to take away. A mount placed under an outer scope (a +/// screen's, a row's) takes that scope's depth plus one, so the outer scope's Effects run first too. +/// /// A mount must run to completion: nothing it calls, a backend factory or setter or a binding's evaluation, may /// destroy this object, its runtime or its backend. Widget callbacks are events, not part of a mount, and may. class Mounted { @@ -827,12 +838,14 @@ class Mounted { /// @param backend Makes the widgets. Borrowed. /// @param root The tree; kept alive as long as the mount. /// @param parent The container the root widget is appended to, or null for a backend root. + /// @param depth The scope depth of the tree's own bindings (`reactive::Scope::depth`): zero for a mount no other + /// scope owns, one more than the owner's for a mount placed under a scope. /// @throws std::invalid_argument when @p root is null. Mounted(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, IViewBackend& backend MORPH_LIFETIMEBOUND, Node root, - ContainerWidget* parent = nullptr) + ContainerWidget* parent = nullptr, std::size_t depth = 0) : _root{nonNull(std::move(root))}, _mounter{runtime, backend}, - _scope{runtime}, + _scope{runtime, depth}, // Untracked, so mounting from inside an Effect does not subscribe that Effect to anything read here. _widget{runtime.untracked([this, parent] { return _mounter.mount(_scope, _root, parent); })} {} diff --git a/include/morph/ui/view.hpp b/include/morph/ui/view.hpp index 1a3c62e84..ec20f2f63 100644 --- a/include/morph/ui/view.hpp +++ b/include/morph/ui/view.hpp @@ -34,8 +34,45 @@ using Action = std::function; /// Never a floating-point number: morph ids can exceed 2^53. using Key = std::variant; +/// @brief The stable identity of a slot: a bound property a renderer can refer to by number. +/// +/// Whoever builds the tree assigns slot ids; the interpreter numbers every bound property of a document, and a +/// code-generating renderer names the value it reads after it (`v.s17`). `Mounted` does not use them. A slot made +/// from a bare callable has no id. +using SlotId = std::uint32_t; + +/// @brief A slot's read together with its id, as `ui::slot` makes it; a `Prop` made from it is a slot with that id. +/// @tparam F A callable taking no arguments. +template +struct SlotBinding { + /// @brief The slot's id. + SlotId id = 0; + /// @brief Reads the slot's value; every signal it reads is a dependency. + F read; +}; + +/// @brief A slot with an id. +/// @tparam F A callable taking no arguments. +/// @param slotId The slot's id. +/// @param read Reads the value; every signal it reads is a dependency. +/// @return What a `Prop` takes as a slot with @p slotId. +template +[[nodiscard]] SlotBinding> slot(SlotId slotId, F&& read) { + return SlotBinding>{.id = slotId, .read = std::forward(read)}; +} + namespace detail { +/// @brief Whether a type is a `SlotBinding`: false here, true for the specialisation below. +/// @tparam T The type. +template +struct IsSlotBinding : std::false_type {}; + +/// @brief A `SlotBinding` is one. +/// @tparam F The read's type. +template +struct IsSlotBinding> : std::true_type {}; + /// @brief Whether `From` may become a constant `Prop`: an implicit conversion that does not silently change /// the kind of value. /// @@ -48,14 +85,16 @@ namespace detail { template concept ConstantConvertible = std::convertible_to && !(std::is_same_v, bool> && std::is_pointer_v>) && - !(std::is_integral_v && std::is_floating_point_v>); + !(std::is_integral_v && std::is_floating_point_v>) && + !IsSlotBinding>::value; } // namespace detail -/// @brief A node property: a constant, or a binding the mount re-evaluates reactively. +/// @brief A node property: a constant, or a slot — a reactive read, with a stable id when it was given one. /// -/// A constant is set on the widget once and creates no reactive node. A binding becomes an equality-gated -/// `reactive::Computed` plus an `reactive::Effect` calling one widget setter. +/// A constant is set on the widget once and creates no reactive node. A slot becomes an equality-gated +/// `reactive::Computed` plus an `reactive::Effect` calling one widget setter. An input whose value is a slot is +/// controlled: after a user's edit, the widget shows what the slot says. A constant is not re-asserted. /// /// A braced list cannot initialise a `Prop`, because the constant constructor deduces its argument's type: name /// the type, as in `.options = std::vector{{...}, {...}}`. @@ -84,41 +123,74 @@ class Prop { // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-array-to-pointer-decay) : _value{std::in_place_index<0>, static_cast(std::forward(value))} {} - /// @brief A binding. + /// @brief A slot without an id. /// - /// The binding must be callable: an empty `std::function` or a null function pointer is refused here, where - /// the mistake is made, rather than when a mount first calls it. + /// The read must be callable: an empty `std::function` or a null function pointer is refused here, where the + /// mistake is made, rather than when a mount first calls it. /// @tparam F A callable taking no arguments and returning something convertible to `T`. /// @param binding Evaluated by the mount inside a `Computed`; every signal it reads is a dependency. /// @throws std::invalid_argument when `binding` is an empty `std::function` or a null function pointer. template requires std::invocable && std::convertible_to, T> - Prop(F binding) : _value{std::in_place_index<1>, std::function{std::move(binding)}} { - if (!std::get<1>(_value)) { - throw std::invalid_argument{"ui::Prop: a binding must not be empty"}; - } - } + Prop(F binding) : _value{std::in_place_index<1>, Slot{.id = std::nullopt, .read = nonEmpty(std::move(binding))}} {} + + /// @brief A slot with an id, as `ui::slot` makes it. + /// @tparam F A callable taking no arguments and returning something convertible to `T`. + /// @param binding The id and the read. + /// @throws std::invalid_argument when the read is an empty `std::function` or a null function pointer. + template + requires std::invocable && std::convertible_to, T> + Prop(SlotBinding binding) + : _value{std::in_place_index<1>, Slot{.id = binding.id, .read = nonEmpty(std::move(binding.read))}} {} - /// @brief Whether this is a binding. - /// @return True for a binding, false for a constant. + /// @brief Whether this is a slot. + /// @return True for a slot, false for a constant. [[nodiscard]] bool isBound() const noexcept { return _value.index() == 1; } /// @brief The constant. Only valid when `!isBound()`. /// @return The constant. - /// @throws std::bad_variant_access when this is a binding. + /// @throws std::bad_variant_access when this is a slot. [[nodiscard]] T const& constant() const { return std::get<0>(_value); } - /// @brief The binding. Only valid when `isBound()`. - /// @return The binding. + /// @brief The slot's read. Only valid when `isBound()`. + /// @return The read. /// @throws std::bad_variant_access when this is a constant. - [[nodiscard]] std::function const& binding() const { return std::get<1>(_value); } + [[nodiscard]] std::function const& binding() const { return std::get<1>(_value).read; } + + /// @brief The slot's id. + /// @return The id given through `ui::slot`; `nullopt` for a constant and for a slot made from a bare callable. + [[nodiscard]] std::optional slotId() const noexcept { + return isBound() ? std::get<1>(_value).id : std::nullopt; + } - /// @brief The current value: the constant, or the binding called once. + /// @brief The current value: the constant, or the slot read once. /// @return The value. - [[nodiscard]] T evaluate() const { return isBound() ? std::get<1>(_value)() : std::get<0>(_value); } + [[nodiscard]] T evaluate() const { return isBound() ? std::get<1>(_value).read() : std::get<0>(_value); } private: - std::variant> _value; + /// @brief A slot: its id, if it has one, and its read. + struct Slot { + /// @brief The id, or none. + std::optional id; + /// @brief The read; never empty. + std::function read; + }; + + /// @brief Wraps a read, refusing an empty one. + /// @tparam F The read's type. + /// @param binding The read. + /// @return The wrapped read. + /// @throws std::invalid_argument when @p binding is empty. + template + [[nodiscard]] static std::function nonEmpty(F binding) { + std::function read{std::move(binding)}; + if (!read) { + throw std::invalid_argument{"ui::Prop: a binding must not be empty"}; + } + return read; + } + + std::variant _value; }; /// @brief How a `Text` is styled; each backend maps a role to its theme. diff --git a/tests/test_ui_structure.cpp b/tests/test_ui_structure.cpp index 513a391fb..72414bf56 100644 --- a/tests/test_ui_structure.cpp +++ b/tests/test_ui_structure.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,7 @@ namespace ui = morph::ui; namespace { +using morph::reactive::Effect; using morph::reactive::Runtime; using morph::reactive::Signal; using morph::testing::EchoingBackend; @@ -104,6 +106,45 @@ TEST_CASE("ui::switchOf: the fallback answers a key with no case", "[ui]") { CHECK(backend.dump() == "Slot#1\n Text#2 role=Normal text=other\n"); } +// Creation order alone would run the view's bindings first here: they are older than the outer Effect. Mutations: +// make Mounter::mountContent's scope depth 0 (the case's binding runs first and throws); build Mounted's scope at +// depth 0 whatever it is given (the root binding runs first and throws). +TEST_CASE("ui::Mounted: an owner that takes content away runs before the content's bindings", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> names{runtime, std::map{{1, "one"}}}; + Signal const which{runtime, 1}; + auto const nameOne = [&] { return names.get().at(1); }; // throws once entry 1 is gone + std::optional view; + view.emplace(runtime, backend, + ui::column({.children = {ui::text({.text = nameOne}), + ui::switchOf({ + .selector = [&] { return ui::Key{which.get()}; }, + .cases = {{.key = intKey(1), .node = ui::text({.text = nameOne})}}, + })}}), + nullptr, 1); + CHECK(backend.dump() == + "Column#1 gap=0\n" + " Text#2 role=Normal text=one\n" + " Slot#3\n" + " Text#4 role=Normal text=one\n"); + // The owner the view is placed under, at depth 0: it takes the view away once entry 1 is gone. + Effect const outer{runtime, + [&] { + if (!names.get().contains(1)) { + view.reset(); + } + }, + 0}; + names.set({}); + owner.runAll(); + CHECK_FALSE(view.has_value()); + CHECK(backend.dump().empty()); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 0); +} + TEST_CASE("ui::switchOf: a case being left never runs against the state that removes it", "[ui]") { Owner owner; Runtime runtime{owner}; diff --git a/tests/test_ui_view.cpp b/tests/test_ui_view.cpp index 52d4a2a92..8d32e2c05 100644 --- a/tests/test_ui_view.cpp +++ b/tests/test_ui_view.cpp @@ -37,6 +37,30 @@ TEST_CASE("ui::Prop: a constant is not bound, a callable is", "[ui]") { CHECK(calls == 2); } +// Mutations: drop the id in Prop's SlotBinding constructor (the slot reports none); return the id for a bare +// callable too (it reports one). +TEST_CASE("ui::Prop: a slot made by ui::slot keeps its id; a bare callable and a constant have none", "[ui]") { + ui::Prop const identified = ui::slot(17, [] { return std::string{"seventeen"}; }); + CHECK(identified.isBound()); + CHECK(identified.slotId() == std::optional{17}); + CHECK(identified.evaluate() == "seventeen"); + + ui::Prop const anonymous = [] { return std::string{"x"}; }; + CHECK(anonymous.isBound()); + CHECK_FALSE(anonymous.slotId().has_value()); + + ui::Prop const fixed = "x"; + CHECK_FALSE(fixed.slotId().has_value()); + + ui::Prop const flag = ui::slot(3, [] { return false; }); + CHECK(flag.isBound()); + CHECK_FALSE(flag.evaluate()); + CHECK(flag.slotId() == std::optional{3}); + + std::function const empty; + CHECK_THROWS_AS(ui::Prop(ui::slot(1, empty)), std::invalid_argument); +} + TEST_CASE("ui::Prop: a captureless lambda is a binding, not a truthy constant", "[ui]") { ui::Prop const bound = [] { return false; }; CHECK(bound.isBound()); From 7f85748bcad928b7dac57b59882debf9d2bbd299 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 10 Oct 2026 18:05:23 +0300 Subject: [PATCH 4/9] ui: input widgets are controlled by their slots An input whose value is a slot now shows what the slot says after a user's request: a text input, checkbox, select, slider, date-time input, file picker, collapsible panel, Tabs index, Dialog open and Table selection. The mount records what each widget shows; after the event, a turn posted behind the event's flush shows the slot if the widget shows anything else. A refused request snaps back, an accepted one costs no setter call, and a transformed one shows the request for that one turn. A slot without a handler is read-only. A constant is not a slot and the widget keeps what the user did, as a QML literal does. The check is held weakly, so a turn posted for content that is gone does nothing; a Dialog whose content failed stays closed rather than opening empty. The backend contract says a setter given the value a widget shows changes nothing, a text input's cursor included. RecordingBackend models a text cursor (moveCursor, cursor) and closes a dialog on dismissal before onDismiss runs, as a toolkit does. Three conformance cases pin the new rules, and the multiple-selection Table case writes the user's selection back, as a controlled table needs. Co-Authored-By: Claude Opus 5.5 --- include/morph/ui/backend.hpp | 14 +- include/morph/ui/mount.hpp | 197 +++++++++-- .../morph/ui/testing/backend_conformance.hpp | 120 ++++++- .../morph/ui/testing/recording_backend.hpp | 73 +++- tests/CMakeLists.txt | 1 + tests/test_ui_conformance.cpp | 8 +- tests/test_ui_controlled.cpp | 314 ++++++++++++++++++ tests/test_ui_recording_backend.cpp | 35 +- tests/test_ui_table.cpp | 4 +- 9 files changed, 720 insertions(+), 46 deletions(-) create mode 100644 tests/test_ui_controlled.cpp diff --git a/include/morph/ui/backend.hpp b/include/morph/ui/backend.hpp index 38cd04904..fd82ea838 100644 --- a/include/morph/ui/backend.hpp +++ b/include/morph/ui/backend.hpp @@ -25,8 +25,12 @@ namespace morph::ui { /// Its destructor detaches it from its parent and frees its native resources; no handler of the widget runs after /// the destructor returns. A handler may destroy its own widget: the backend keeps the handler alive until that call /// returns, for example by calling a copy. A backend may assume a handler, a drop's `accepts` predicate included, -/// never throws: the mount catches and reports what one throws. Every setter takes UTF-8 and may be called with an -/// unchanged value. +/// never throws: the mount catches and reports what one throws. Every setter takes UTF-8. +/// +/// A setter called with the value the widget already shows changes nothing: a text input keeps its cursor, its +/// selection and an input method's composition. Input widgets are controlled: a user's action changes what the +/// widget shows, as the toolkit does, and reports the request through the handler; the mount then shows what the +/// application's slot says, in a turn posted after the request's flush, so a refused request snaps back. class Widget { public: Widget() = default; @@ -97,7 +101,8 @@ class ButtonWidget : public Widget { /// @brief An editable text field. class TextInputWidget : public Widget { public: - /// @brief Replaces the text. Never calls the `onChange` handler. + /// @brief Replaces the text. Never calls the `onChange` handler. With the text the field already shows, it changes + /// nothing: the cursor, the selection and an input method's composition stay as they are. /// @param text The text, UTF-8. virtual void setText(std::string_view text) = 0; @@ -243,7 +248,8 @@ class TabsWidget : public ContainerWidget { /// @brief A modal overlay around its children, with a focus trap while open. A new dialog is closed. /// -/// A closed dialog shows nothing and takes no input, its children included. +/// A closed dialog shows nothing and takes no input, its children included. A user's dismissal closes it, as the +/// toolkit does, before `onDismiss` runs; an application that keeps it open opens it again. class DialogWidget : public ContainerWidget { public: /// @brief Shows or hides the overlay. diff --git a/include/morph/ui/mount.hpp b/include/morph/ui/mount.hpp index 97848b525..7c8d78c40 100644 --- a/include/morph/ui/mount.hpp +++ b/include/morph/ui/mount.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -156,6 +157,8 @@ struct Pages { std::vector slots; /// @brief The page shown now, if any; may be past the last page, and then none is shown. std::optional shown; + /// @brief The tab the bar highlights, as far as the mount knows: the last one it set, or the user's pick since. + std::optional highlighted; /// @brief The Tabs' `onSelect`, run as a widget callback; empty when the Tabs has none. std::function onSelect; }; @@ -236,6 +239,105 @@ class Mounter { }; } + /// @brief A controlled input's re-assertion: what the widget shows as far as the mount knows, what its slot + /// says now, and how to show a value. + /// @tparam T The value type. + template + struct Reassertion { + /// @brief What the widget shows: the last value the mount set, or the last value the user asked for since. + /// Owned by the scope, made before this, so it outlives it. + std::optional* shown; + /// @brief What the slot says now. + std::function read; + /// @brief Shows a value, and records it in `*shown`. + std::function apply; + }; + + // What a controlled input's event calls after the application's handler, with the value the user asked for. + // + // The widget shows the request already: it is recorded as shown. Then one turn is posted to the owner; it reads + // the slot untracked and shows it when the widget shows anything else. The turn is posted after the event's + // widgetEvent closed, so it runs after the flush the event caused, whose bindings record what they set: an + // accepted request costs no setter call, a refused one snaps back, and a transformed one, which the binding has + // shown already, costs nothing more. The check is owned by @p scope and held weakly by the event and the turn, + // so neither does anything once the content is gone. + template + [[nodiscard]] std::function reasserter(reactive::Scope& scope, std::optional& shown, Read read, + Apply apply) { + auto const check = std::make_shared>( + Reassertion{.shown = &shown, .read = std::move(read), .apply = std::move(apply)}); + scope.make>>(check); + return [runtime = _rt, weak = std::weak_ptr{check}](T const& requested) { + if (auto const live = weak.lock()) { + *live->shown = requested; + } else { + return; + } + // Called from a widget handler, which must not throw: a post that fails leaves the widget showing the + // user's value until the slot next changes. + try { + runtime->owner().post([runtime, weak] { + if (auto const live = weak.lock()) { + runtime->untracked([&live] { showSlot(*live); }); + } + }); + } catch (...) { // NOLINT(bugprone-empty-catch): see above + } + }; + } + + // Shows the slot's value if the widget shows anything else. A slot that throws is its binding's failure, which + // the binding's Effect reports; the widget keeps what the user did. + template + static void showSlot(Reassertion const& check) noexcept { + try { + T const current = check.read(); + if (!(check.shown->has_value() && **check.shown == current)) { + check.apply(current); + } + } catch (...) { // NOLINT(bugprone-empty-catch): reported by the binding's Effect, see above + } + } + + // A controlled input's value: bound like any property, recording what it shows, and not sending what it already + // shows; a slot is also re-asserted after each user event (see `reasserter`). Returns what the event calls, or + // nothing for a constant, which is not a slot: the widget keeps what the user did to it. + template + [[nodiscard]] std::function controlled(reactive::Scope& scope, Prop const& prop, Apply apply) { + auto& shown = scope.make>(); + // A value the widget already shows, the user's accepted edit above all, is not sent again. + auto show = [&shown, apply = std::move(apply)](T const& value) { + if (shown.has_value() && *shown == value) { + return; + } + apply(value); + shown = value; + }; + reactive::Computed* const value = bind(scope, prop, show); + if (value == nullptr) { + return {}; + } + return reasserter(scope, shown, [value] { return value->get(); }, std::move(show)); + } + + // An input's event: the application's handler, run as `event` runs it, then the re-assertion of the request. + // Installed whenever either exists, so a slot without a handler is read-only rather than editable. + template + [[nodiscard]] std::function inputEvent(std::function const& handler, + std::function reassert) const { + std::function inner = event(handler); + if (!reassert) { + return inner; + } + return [inner = std::move(inner), reassert = std::move(reassert)](Arg arg) { + T const requested(arg); + if (inner) { + inner(std::move(arg)); + } + reassert(requested); + }; + } + void applyCommon(reactive::Scope& scope, Widget& widget, Common const& common) { if (common.visible.isBound() || !common.visible.constant()) { bind(scope, common.visible, [&widget](bool shown) { widget.setVisible(shown); }); @@ -306,10 +408,10 @@ class Mounter { Widget& mountKind(reactive::Scope& scope, TextInput const& spec, ContainerWidget* parent) { TextInputWidget& widget = scope.adopt(_backend->createTextInput(parent, spec.mode)); applyCommon(scope, widget, spec.common); - bind(scope, spec.value, [&widget](std::string const& value) { widget.setText(value); }); + auto reassert = controlled(scope, spec.value, [&widget](std::string const& value) { widget.setText(value); }); bind(scope, spec.placeholder, [&widget](std::string const& text) { widget.setPlaceholder(text); }); - widget.setOnChange(event(spec.onChange)); - widget.setOnSubmit(event(spec.onSubmit)); + widget.setOnChange(inputEvent(spec.onChange, reassert)); + widget.setOnSubmit(inputEvent(spec.onSubmit, std::move(reassert))); return widget; } @@ -317,8 +419,8 @@ class Mounter { CheckboxWidget& widget = scope.adopt(_backend->createCheckbox(parent)); applyCommon(scope, widget, spec.common); bind(scope, spec.label, [&widget](std::string const& label) { widget.setLabel(label); }); - bind(scope, spec.checked, [&widget](bool checked) { widget.setChecked(checked); }); - widget.setOnToggle(event(spec.onToggle)); + widget.setOnToggle(inputEvent( + spec.onToggle, controlled(scope, spec.checked, [&widget](bool checked) { widget.setChecked(checked); }))); return widget; } @@ -326,8 +428,9 @@ class Mounter { SelectWidget& widget = scope.adopt(_backend->createSelect(parent, spec.style)); applyCommon(scope, widget, spec.common); bind(scope, spec.options, [&widget](std::vector const& options) { widget.setOptions(options); }); - bind(scope, spec.selected, [&widget](std::optional const& key) { widget.setSelected(key); }); - widget.setOnSelect(event(spec.onSelect)); + widget.setOnSelect(inputEvent( + spec.onSelect, + controlled(scope, spec.selected, [&widget](std::optional const& key) { widget.setSelected(key); }))); return widget; } @@ -389,8 +492,9 @@ class Mounter { widget.setPadding(spec.padding); widget.setCollapsible(spec.collapsible); if (spec.collapsible) { - bind(scope, spec.collapsed, [&widget](bool collapsed) { widget.setCollapsed(collapsed); }); - widget.setOnToggle(event(spec.onToggle)); + widget.setOnToggle(inputEvent(spec.onToggle, controlled(scope, spec.collapsed, [&widget](bool collapsed) { + widget.setCollapsed(collapsed); + }))); } static_cast(mount(scope, spec.child, &widget)); return widget; @@ -453,11 +557,20 @@ class Mounter { labels.push_back(tab.label); } widget.setTabs(labels); - widget.setOnSelect(event(spec.onSelect)); auto& pageScope = scope.child(); auto& pages = scope.make(); pages.slots.resize(spec.tabs.size(), nullptr); - pages.onSelect = event(spec.onSelect); + // A bound index is controlled: after a pick, the bar highlights the page that is shown, so a pick the + // document refuses moves the highlight back. A constant index is not a slot, and the bar keeps the pick. + std::function reassert; + if (spec.selected.isBound()) { + reassert = reasserter( + scope, pages.highlighted, + [&pages] { return pages.shown.value_or(std::numeric_limits::max()); }, + [&widget, &pages](std::size_t index) { highlight(widget, pages, index); }); + } + widget.setOnSelect(inputEvent(spec.onSelect, reassert)); + pages.onSelect = inputEvent(spec.onSelect, std::move(reassert)); auto show = [this, &widget, &pageScope, &pages, pageSpecs = spec.tabs](std::size_t index) { _rt->untracked([&] { showPage(widget, pageScope, pages, pageSpecs, index); }); }; @@ -498,7 +611,13 @@ class Mounter { } } pages.shown = index; + highlight(widget, pages, index); + } + + // Moves the bar's highlight, and records it. + static void highlight(TabsWidget& widget, Pages& pages, std::size_t index) { widget.setSelected(index); + pages.highlighted = index; } // After a page failed to mount: puts the bar's highlight back on the page still shown, and tells the application @@ -510,9 +629,9 @@ class Mounter { // // Nothing here may replace the failure being reported: the callback's wrapper reports what the callback throws, // and anything else thrown here is dropped. - static void restoreSelection(TabsWidget& widget, Pages const& pages, std::size_t shown) noexcept { + static void restoreSelection(TabsWidget& widget, Pages& pages, std::size_t shown) noexcept { try { - widget.setSelected(shown); + highlight(widget, pages, shown); } catch (...) { // NOLINT(bugprone-empty-catch) // The page's failure is the one reported; the bar keeps whatever highlight it had. } @@ -548,10 +667,28 @@ class Mounter { DialogWidget& widget = scope.adopt(_backend->createDialog(parent)); applyCommon(scope, widget, spec.common); bind(scope, spec.title, [&widget](std::string const& title) { widget.setTitle(title); }); + auto& content = scope.make>(); + auto& shownOpen = scope.make>(); + // A bound `open` is controlled: a dismissal the document refuses opens the dialog again, over the content it + // still holds. Content that failed to mount is not there to show, so the dialog stays closed for it. std::function onDismiss = event(spec.onDismiss); + if (spec.open.isBound()) { + auto reassert = + reasserter(scope, shownOpen, spec.open.binding(), [&widget, &content, &shownOpen](bool open) { + if (!open || content != nullptr) { + widget.setOpen(open); + shownOpen = open; + } + }); + onDismiss = [inner = std::move(onDismiss), reassert = std::move(reassert)] { + if (inner) { + inner(); + } + reassert(false); + }; + } widget.setOnDismiss(onDismiss); - auto& content = scope.make>(); - auto show = [this, &widget, &content, depth = scope.depth() + 1, child = spec.child]( + auto show = [this, &widget, &content, &shownOpen, depth = scope.depth() + 1, child = spec.child]( bool open, std::function const& dismiss) { if (!open) { content.reset(); @@ -564,6 +701,7 @@ class Mounter { } } widget.setOpen(open); + shownOpen = open; }; if (!spec.open.isBound()) { // A constant has no application state to bring back in line: content that fails throws out of the mount. @@ -606,11 +744,13 @@ class Mounter { applyCommon(scope, widget, spec.common); widget.setColumns(spec.columns); widget.setSelectionMode(spec.selectionMode); - widget.setOnSelectionChange(event(spec.onSelectionChange)); widget.setOnActivate(event(spec.onActivate)); mountRows(scope, spec.rows, widget, [&widget](Widget& rowWidget, Key const& key) { widget.setRowKey(rowWidget, key); }); - bind(scope, spec.selection, [&widget](std::vector const& keys) { widget.setSelection(keys); }); + widget.setOnSelectionChange(inputEvent( + spec.onSelectionChange, controlled(scope, spec.selection, [&widget](std::vector const& keys) { + widget.setSelection(keys); + }))); return widget; } @@ -766,9 +906,10 @@ class Mounter { DateTimeInputWidget& widget = scope.adopt(_backend->createDateTimeInput(parent, spec.mode, spec.offsetMinutes)); applyCommon(scope, widget, spec.common); - bind(scope, spec.value, - [&widget](std::optional const& value) { widget.setValue(value); }); - widget.setOnChange(event(spec.onChange)); + widget.setOnChange(inputEvent( + spec.onChange, + controlled(scope, spec.value, + [&widget](std::optional const& value) { widget.setValue(value); }))); return widget; } @@ -776,16 +917,17 @@ class Mounter { SliderWidget& widget = scope.adopt(_backend->createSlider(parent)); applyCommon(scope, widget, spec.common); widget.setRange(spec.minimum, spec.maximum, spec.step); - bind(scope, spec.value, [&widget](std::int64_t value) { widget.setValue(value); }); - widget.setOnChange(event(spec.onChange)); + widget.setOnChange(inputEvent( + spec.onChange, controlled(scope, spec.value, [&widget](std::int64_t value) { widget.setValue(value); }))); return widget; } Widget& mountKind(reactive::Scope& scope, FilePicker const& spec, ContainerWidget* parent) { FilePickerWidget& widget = scope.adopt(_backend->createFilePicker(parent, spec.mode)); applyCommon(scope, widget, spec.common); - bind(scope, spec.path, [&widget](std::string const& path) { widget.setPath(path); }); - widget.setOnPicked(event(spec.onPicked)); + widget.setOnPicked(inputEvent(spec.onPicked, controlled(scope, spec.path, [&widget](std::string const& path) { + widget.setPath(path); + }))); return widget; } @@ -807,6 +949,13 @@ class Mounter { /// backend's call returns normally, a predicate that threw refuses the drop, and what the callback wrote before /// throwing still flushes. /// +/// An input whose value is a slot is controlled: a text input's text, a checkbox's state, a select's key, a slider's, +/// date-time input's or file picker's value, a collapsible panel's state, a Tabs index, a Dialog's `open` and a +/// Table's selection. After a user's request, and after the flush it caused, a posted turn shows the slot's value if +/// the widget shows anything else: a refused request snaps back, an accepted one costs no setter call, and a +/// transformed one shows the request for that one turn. That holds with no handler too, which makes a slot without one +/// read-only. A constant is not a slot: the widget keeps what the user did to it. +/// /// A mount that throws goes no further than the content it was building, and leaves none of it behind. A node mounted /// directly, a constant Switch selector, Tabs index or Dialog `open` included, throws out of the constructor. Content /// a binding mounts, which a bound selector, index or `open` does in an Effect even during the constructor, is diff --git a/include/morph/ui/testing/backend_conformance.hpp b/include/morph/ui/testing/backend_conformance.hpp index 32b5eb7b2..6d509072a 100644 --- a/include/morph/ui/testing/backend_conformance.hpp +++ b/include/morph/ui/testing/backend_conformance.hpp @@ -119,6 +119,16 @@ class ConformanceProbe { /// @param table The table. /// @return Their positions among the table's current rows, ascending. [[nodiscard]] virtual std::vector selectedRows(Widget const& table) = 0; + + /// @brief Moves a text field's cursor as a user would (the arrow keys, a click), without changing the text. + /// @param field The text field. + /// @param position A byte offset into the text it shows. + virtual void moveCursor(Widget& field, std::size_t position) = 0; + + /// @brief Where a text field's cursor is. + /// @param field The text field. + /// @return A byte offset into the text it shows. + [[nodiscard]] virtual std::size_t cursorOf(Widget const& field) = 0; }; /// @brief One scripted case. @@ -671,6 +681,93 @@ struct Entry { return checks.result(); } +/// @brief Case: a setter given the text a field already shows changes nothing; in particular the cursor stays. +/// +/// Built through the backend directly, so the setter is called with the shown text on purpose: a mount sends no such +/// call for an accepted edit, but a re-assertion of a transformed one, or another renderer, may. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional unchangedTextKeepsCursor(ConformanceProbe& probe) { + std::unique_ptr const field = probe.backend().createTextInput(nullptr, TextInputMode::SingleLine); + probe.settle(); + probe.type(*field, "abc"); + probe.moveCursor(*field, 1); + probe.settle(); + Checks checks; + checks.that(probe.cursorOf(*field) == 1, + "moving the cursor to 1 left it at " + std::to_string(probe.cursorOf(*field))); + field->setText("abc"); + probe.settle(); + checks.text("the field after setText with the text it shows", "abc", probe.textOf(*field)); + checks.that(probe.cursorOf(*field) == 1, "setText with the text the field shows moved the cursor from 1 to " + + std::to_string(probe.cursorOf(*field))); + return checks.result(); +} + +/// @brief Case: an edit the document refuses snaps back, and one it transforms shows the transform. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional textInputIsControlled(ConformanceProbe& probe) { + reactive::Signal name{probe.runtime(), "kept"}; + bool refuse = true; + Mounted const view{probe.runtime(), probe.backend(), + ui::textInput({.value = [&name] { return name.get(); }, + .onChange = + [&name, &refuse](std::string text) { + if (!refuse) { + std::ranges::transform(text, text.begin(), [](char character) { + return character >= 'a' && character <= 'z' + ? static_cast(character - 'a' + 'A') + : character; + }); + name.set(std::move(text)); + } + }})}; + probe.settle(); + Checks checks; + probe.type(view.root(), "typed"); + probe.settle(); + checks.text("the field after an edit the document refused", "kept", probe.textOf(view.root())); + refuse = false; + probe.type(view.root(), "typed"); + probe.settle(); + checks.text("the field after an edit the document upper-cased", "TYPED", probe.textOf(view.root())); + checks.text("the value after an edit the document upper-cased", "TYPED", name.peek()); + return checks.result(); +} + +/// @brief Case: a dialog the user dismisses but the document keeps open is shown again, with its content. +/// @param probe The backend under test. +/// @return `nullopt`, or what went wrong. +[[nodiscard]] inline std::optional refusedDismissalKeepsDialogOpen(ConformanceProbe& probe) { + reactive::Signal const open{probe.runtime(), true}; + int dismissals = 0; + int clicks = 0; + Mounted const view{probe.runtime(), probe.backend(), + ui::dialog({.open = [&open] { return open.get(); }, + .title = "Confirm", + .child = ui::button({.label = "OK", .onClick = [&clicks] { ++clicks; }}), + .onDismiss = [&dismissals] { ++dismissals; }})}; + probe.settle(); + ContainerWidget const* const dialog = asContainer(view.root()); + if (dialog == nullptr) { + return "a Dialog's widget is not a ContainerWidget"; + } + probe.dismiss(view.root()); + probe.settle(); + Checks checks; + checks.that(dismissals == 1, "dismissing the dialog called onDismiss " + std::to_string(dismissals) + " times"); + Widget* const button = probe.childAt(*dialog, 0); + checks.that(button != nullptr, "a dialog kept open lost its content"); + if (button != nullptr) { + probe.click(*button); + probe.settle(); + } + checks.that(clicks == 1, "a click inside a dialog the document kept open ran its action " + + std::to_string(clicks) + " times; the dialog did not open again"); + return checks.result(); +} + /// @brief Case: no setter calls a handler. Setting a value, replacing options, items or rows, requesting a selection, /// opening or closing — none of it is a user's doing, so none of it reaches `onChange`, `onToggle`, /// `onSelect`, `onSelectionChange` and the like. @@ -797,14 +894,17 @@ struct Entry { std::vector withNine = three; withNine.push_back({.id = 9, .label = "i"}); reactive::Signal> rows{runtime, three}; - reactive::Signal> const selection{runtime, {Key{std::int64_t{2}}, Key{std::int64_t{9}}}}; + reactive::Signal> selection{runtime, {Key{std::int64_t{2}}, Key{std::int64_t{9}}}}; std::vector> reports; - Mounted const view{ - runtime, probe.backend(), - entryTable(rows, - {.selectionMode = SelectionMode::Multiple, - .selection = [&selection] { return selection.get(); }, - .onSelectionChange = [&reports](std::vector keys) { reports.push_back(std::move(keys)); }})}; + // The selection is a slot, so the application writes what the user picked for the table to keep showing it. + Mounted const view{runtime, probe.backend(), + entryTable(rows, {.selectionMode = SelectionMode::Multiple, + .selection = [&selection] { return selection.get(); }, + .onSelectionChange = + [&reports, &selection](std::vector keys) { + reports.push_back(keys); + selection.set(std::move(keys)); + }})}; probe.settle(); Checks checks; checks.text("the rows marked for requested keys 2 and 9, with no row 9", "b", selectedTexts(probe, view.root())); @@ -1050,7 +1150,7 @@ class LifeSentinel { /// loops over these with its own `ConformanceProbe`. /// @return The cases. [[nodiscard]] inline std::span conformanceCases() { - static std::array const cases{{ + static std::array const cases{{ {.name = "a Text shows its constant text", .run = detail::textShowsItsText}, {.name = "a bound Text updates once per batch and not for an equal write", .run = detail::boundTextUpdatesOncePerChange}, @@ -1069,6 +1169,10 @@ class LifeSentinel { {.name = "a new Dialog is closed and a closed Dialog takes no input", .run = detail::closedDialogTakesNoInput}, {.name = "a widget inside a hidden, disabled or collapsed container takes no input", .run = detail::unreachableWidgetsTakeNoInput}, + {.name = "a setter given the text a field shows keeps the cursor", .run = detail::unchangedTextKeepsCursor}, + {.name = "a text field shows what the document makes of an edit", .run = detail::textInputIsControlled}, + {.name = "a dismissal the document refuses leaves the dialog open", + .run = detail::refusedDismissalKeepsDialogOpen}, {.name = "no setter calls a handler", .run = detail::settersCallNoHandler}, {.name = "a Select marks the requested key whenever its options contain it", .run = detail::selectReResolvesRequestedKey}, diff --git a/include/morph/ui/testing/recording_backend.hpp b/include/morph/ui/testing/recording_backend.hpp index 14dbbaa50..54f8baa55 100644 --- a/include/morph/ui/testing/recording_backend.hpp +++ b/include/morph/ui/testing/recording_backend.hpp @@ -613,14 +613,48 @@ class FakeButton final : public FakeLeaf { void setOnClick(Action onClick) override { callbacks().click = std::move(onClick); } }; -/// @brief A fake `TextInputWidget`: properties `mode`, `text`, `placeholder`. +/// @brief A fake `TextInputWidget`: properties `mode`, `text`, `placeholder`, and a text cursor. +/// +/// The cursor is a byte offset into the text. A user's edit puts it where the edit left it; `setText` with the text +/// the field shows leaves it alone, as the contract requires, and `setText` with another text puts it at the end. class FakeTextInput final : public FakeLeaf { public: using FakeLeaf::FakeLeaf; - void setText(std::string_view text) override { set("text", formatText(text)); } + + /// @brief Records property `text`; moves the cursor to the end only when the text changes. + /// @param text The text, UTF-8. + void setText(std::string_view text) override { + if (text != _shown) { + _shown = std::string{text}; + _cursor = _shown.size(); + } + set("text", formatText(text)); + } + + /// @brief The text the user typed: the field shows it, and the cursor is at @p cursor; nothing is logged. + /// @param text The whole new text. + /// @param cursor Where the cursor ends up; clamped to the text's size. + void typedByUser(std::string text, std::size_t cursor) { + _shown = std::move(text); + _cursor = std::min(cursor, _shown.size()); + store().setByUser(id(), "text", formatText(_shown)); + } + + /// @brief Moves the cursor as the user's arrow keys or a click would. + /// @param cursor The new position; clamped to the text's size. + void moveCursorByUser(std::size_t cursor) noexcept { _cursor = std::min(cursor, _shown.size()); } + + /// @brief Where the cursor is. + /// @return A byte offset into the text shown. + [[nodiscard]] std::size_t cursor() const noexcept { return _cursor; } + void setPlaceholder(std::string_view placeholder) override { set("placeholder", formatText(placeholder)); } void setOnChange(std::function onChange) override { callbacks().change = std::move(onChange); } void setOnSubmit(std::function onSubmit) override { callbacks().submit = std::move(onSubmit); } + +private: + std::string _shown; + std::size_t _cursor = 0; }; /// @brief A fake `CheckboxWidget`: properties `label`, `checked`. @@ -1111,28 +1145,47 @@ class RecordingBackend final : public IViewBackend { } } - /// @brief Types into a text field: the field shows @p text, then `onChange` gets it. + /// @brief Types into a text field: the field shows @p text with the cursor at its end, then `onChange` gets it. /// @param widgetId The field. /// @param text The whole new text. /// @throws std::logic_error when the widget is not a text field. void edit(int widgetId, std::string text) { if (detail::Record const* const record = actionable(widgetId, "edit", {"TextInput"}); record != nullptr) { - _store->setByUser(widgetId, "text", detail::formatText(text)); + textInput(*record).typedByUser(text, text.size()); invoke(record->callbacks.change, std::move(text)); } } - /// @brief Submits a text field with @p text. + /// @brief Submits a text field with @p text, which it then shows with the cursor at its end. /// @param widgetId The field. /// @param text The text submitted. /// @throws std::logic_error when the widget is not a text field. void submit(int widgetId, std::string text) { if (detail::Record const* const record = actionable(widgetId, "submit", {"TextInput"}); record != nullptr) { - _store->setByUser(widgetId, "text", detail::formatText(text)); + textInput(*record).typedByUser(text, text.size()); invoke(record->callbacks.submit, std::move(text)); } } + /// @brief Moves a text field's cursor, as the arrow keys or a click would; no handler runs. + /// @param widgetId The field. + /// @param position A byte offset into the text it shows; clamped to the text's size. + /// @throws std::logic_error when the widget is not a text field. + void moveCursor(int widgetId, std::size_t position) { + if (detail::Record const* const record = actionable(widgetId, "moveCursor", {"TextInput"}); + record != nullptr) { + textInput(*record).moveCursorByUser(position); + } + } + + /// @brief Where a text field's cursor is. + /// @param widgetId The field. + /// @return A byte offset into the text it shows. + /// @throws std::logic_error when the widget is not a text field. + [[nodiscard]] std::size_t cursor(int widgetId) { + return textInput(expectKind(widgetId, "cursor", {"TextInput"})).cursor(); + } + /// @brief Toggles a check box: it flips its own `checked`, then `onToggle` gets the new state. /// @param widgetId The check box. /// @throws std::logic_error when the widget is not a check box. @@ -1194,11 +1247,12 @@ class RecordingBackend final : public IViewBackend { } } - /// @brief Dismisses an open dialog, as Esc does on the TUI. + /// @brief Dismisses an open dialog, as Esc does on the TUI: the dialog closes, then `onDismiss` runs. /// @param widgetId The dialog. /// @throws std::logic_error when the widget is not a dialog. void dismiss(int widgetId) { if (detail::Record const* const record = actionable(widgetId, "dismiss", {"Dialog"}); record != nullptr) { + _store->setByUser(widgetId, "open", detail::formatBool(false)); invoke(record->callbacks.dismiss); } } @@ -1310,6 +1364,11 @@ class RecordingBackend final : public IViewBackend { } private: + // The fake behind a text field's record. + static detail::FakeTextInput& textInput(detail::Record const& record) { + return dynamic_cast(*record.widget); + } + // A helper applies to some kinds only; using it on another kind is a mistake in the test, not a user action. detail::Record& expectKind(int widgetId, std::string_view helper, std::initializer_list kinds) { detail::Record& record = _store->at(widgetId); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e8b30544c..e60d9cc9b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -158,6 +158,7 @@ add_executable(morph_tests test_ui_view.cpp test_ui_recording_backend.cpp test_ui_mount.cpp + test_ui_controlled.cpp test_ui_structure.cpp test_ui_foreach.cpp test_ui_table.cpp diff --git a/tests/test_ui_conformance.cpp b/tests/test_ui_conformance.cpp index 3b98af19f..3e9d192b5 100644 --- a/tests/test_ui_conformance.cpp +++ b/tests/test_ui_conformance.cpp @@ -114,6 +114,12 @@ class RecordingProbe final : public ui::testing::ConformanceProbe { static_cast(_backend.drag(_backend.idOf(source), _backend.idOf(target))); } void dismiss(ui::Widget& dialog) override { _backend.dismiss(_backend.idOf(dialog)); } + void moveCursor(ui::Widget& field, std::size_t position) override { + _backend.moveCursor(_backend.idOf(field), position); + } + [[nodiscard]] std::size_t cursorOf(ui::Widget const& field) override { + return _backend.cursor(_backend.idOf(field)); + } void selectRows(ui::Widget& table, std::vector const& rows) override { int const tableId = _backend.idOf(table); std::vector const children = _backend.children(tableId); @@ -161,7 +167,7 @@ class RecordingProbe final : public ui::testing::ConformanceProbe { TEST_CASE("ui conformance: RecordingBackend passes every case", "[ui][conformance]") { auto const cases = ui::testing::conformanceCases(); - REQUIRE(cases.size() == 23); + REQUIRE(cases.size() == 26); std::set names; for (auto const& testCase : cases) { CHECK(names.insert(testCase.name).second); diff --git a/tests/test_ui_controlled.cpp b/tests/test_ui_controlled.cpp new file mode 100644 index 000000000..0ad4439d5 --- /dev/null +++ b/tests/test_ui_controlled.cpp @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" +#include "ui_echoing_backend.hpp" +#include "ui_test_support.hpp" + +namespace ui = morph::ui; + +namespace { + +using morph::reactive::Runtime; +using morph::reactive::Signal; +using morph::testing::EchoingBackend; +using morph::testing::failWhenSpent; +using ui::testing::RecordingBackend; +using Owner = morph::testing::StepExecutor; +using Lines = std::vector; + +std::string upper(std::string text) { + for (char& character : text) { + character = static_cast(std::toupper(static_cast(character))); + } + return text; +} + +int only(RecordingBackend const& backend, std::string_view kind) { + std::vector const ids = backend.all(kind); + REQUIRE(ids.size() == 1); + return ids.front(); +} + +} // namespace + +// Mutation: in Mounter::reasserter, do not post the turn (the field keeps "ab"). +TEST_CASE("ui controlled: an edit the slot refuses snaps back once the event's turn has run", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal const name{runtime, "a"}; + Lines changes; + ui::Mounted const view{runtime, backend, + ui::textInput({.value = [&] { return name.get(); }, + .onChange = [&](std::string text) { changes.push_back(std::move(text)); }})}; + backend.clearLog(); + backend.edit(1, "ab"); + CHECK(changes == Lines{"ab"}); + CHECK(backend.prop(1, "text") == "ab"); // the field shows the request until the turn runs + owner.runAll(); + CHECK(backend.prop(1, "text") == "a"); + CHECK(backend.log() == Lines{"set TextInput#1 text=a"}); +} + +// Mutations: in Mounter::reassert, show the slot whatever the widget shows; in Mounter::controlled, send a value the +// widget already shows. Either adds a setText the field does not need. +TEST_CASE("ui controlled: an accepted edit costs no setter call", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal name{runtime, "a"}; + ui::Mounted const view{ + runtime, backend, + ui::textInput({.value = [&] { return name.get(); }, .onChange = [&](std::string text) { name.set(text); }})}; + backend.clearLog(); + backend.edit(1, "ab"); + owner.runAll(); + CHECK(name.peek() == "ab"); + CHECK(backend.prop(1, "text") == "ab"); + CHECK(backend.log().empty()); +} + +// Mutation: in Mounter::reasserter, run the check inside the event instead of posting it (the field shows "AB" +// before the flush). +TEST_CASE("ui controlled: a transformed edit shows the raw text for one turn, then the slot's", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal name{runtime, "A"}; + ui::Mounted const view{runtime, backend, + ui::textInput({.value = [&] { return name.get(); }, + .onChange = [&](std::string text) { name.set(upper(std::move(text))); }})}; + backend.clearLog(); + backend.edit(1, "ab"); + CHECK(backend.prop(1, "text") == "ab"); + owner.runAll(); + CHECK(backend.prop(1, "text") == "AB"); + CHECK(backend.log() == Lines{"set TextInput#1 text=AB"}); +} + +// The cursor rule is what keeps that turn harmless: a re-assertion of the text the field shows leaves the cursor. +// Mutation: in Mounter::controlled, send a value the widget already shows, and in FakeTextInput::setText move the +// cursor whatever the text (the cursor jumps to the end). +TEST_CASE("ui controlled: an accepted edit keeps the cursor where the user left it", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal name{runtime, ""}; + ui::Mounted const view{ + runtime, backend, + ui::textInput({.value = [&] { return name.get(); }, .onChange = [&](std::string text) { name.set(text); }})}; + backend.edit(1, "abc"); + backend.moveCursor(1, 1); + owner.runAll(); + CHECK(backend.prop(1, "text") == "abc"); + CHECK(backend.cursor(1) == 1); +} + +// Mutation: in Mounter::controlled, return a re-assertion for a constant too (the field snaps back to "fixed"). +TEST_CASE("ui controlled: a constant value is not a slot, and the field keeps what the user typed", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Lines changes; + ui::Mounted const view{ + runtime, backend, + ui::textInput({.value = "fixed", .onChange = [&](std::string text) { changes.push_back(std::move(text)); }})}; + backend.clearLog(); + backend.edit(1, "typed"); + owner.runAll(); + CHECK(changes == Lines{"typed"}); + CHECK(backend.prop(1, "text") == "typed"); + CHECK(backend.log().empty()); +} + +// Mutation: in Mounter::inputEvent, return the bare event when the application gave no handler (the edit stays). +TEST_CASE("ui controlled: a slot with no handler is read-only", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal const name{runtime, "a"}; + ui::Mounted const view{runtime, backend, ui::textInput({.value = [&] { return name.get(); }})}; + backend.edit(1, "ab"); + owner.runAll(); + CHECK(backend.prop(1, "text") == "a"); +} + +// A handler that destroys its own field: the event finds the check gone and posts nothing. +TEST_CASE("ui controlled: an edit whose handler unmounts the field posts no turn", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal const name{runtime, "a"}; + std::optional view; + view.emplace(runtime, backend, + ui::textInput({.value = [&] { return name.get(); }, .onChange = [&](std::string) { view.reset(); }})); + backend.edit(1, "ab"); + CHECK_FALSE(view.has_value()); + CHECK_FALSE(backend.exists(1)); + CHECK(owner.runAll() == 0); +} + +// The field goes in the flush the edit caused, after the turn was posted: the turn holds the check weakly and finds +// it gone. Mutation: capture the check strongly in the posted turn (it reads the destroyed Computed and shows a value +// on a destroyed widget: a use after free under ASan, and an out-of-range id from the recording backend here). +TEST_CASE("ui controlled: a turn posted for a field that is gone by then does nothing", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal const name{runtime, "a"}; + Signal which{runtime, 1}; + ui::Mounted const view{ + runtime, backend, + ui::switchOf({.selector = [&] { return ui::Key{which.get()}; }, + .cases = {{.key = ui::Key{std::int64_t{1}}, + .node = ui::textInput({.value = [&] { return name.get(); }, + .onChange = [&](std::string) { which.set(0); }})}}})}; + int const field = backend.all("TextInput").front(); + backend.edit(field, "ab"); + CHECK_NOTHROW(owner.runAll()); + CHECK_FALSE(backend.exists(field)); + CHECK(backend.dump() == "Slot#1\n"); +} + +// Every input kind whose value is a slot snaps back to it when the application ignores the user. Mutation, per kind: +// mount its value with `bind` instead of `controlled` (or, for Tabs and Dialog, drop the reasserter), and that kind's +// check after the turn fails. +TEST_CASE("ui controlled: every input kind shows its slot again after a request the application ignores", "[ui]") { + using morph::time::Timestamp; + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal const checked{runtime, false}; + Signal> const chosen{runtime, ui::Key{std::int64_t{1}}}; + Signal const level{runtime, 5}; + Signal> const when{runtime, std::nullopt}; + Signal const path{runtime, "/a"}; + Signal const collapsed{runtime, false}; + Signal const page{runtime, 0}; + Signal const open{runtime, true}; + Signal> const picked{runtime, {}}; + int requests = 0; + auto const ignore = [&requests](auto&&...) { ++requests; }; + ui::Mounted const view{ + runtime, backend, + ui::column( + {.children = { + ui::checkbox({.label = "c", .checked = [&] { return checked.get(); }, .onToggle = ignore}), + ui::select( + {.options = std::vector{{.key = ui::Key{std::int64_t{1}}, .label = "one"}, + {.key = ui::Key{std::int64_t{2}}, .label = "two"}}, + .selected = [&] { return chosen.get(); }, + .onSelect = ignore}), + ui::slider({.value = [&] { return level.get(); }, .onChange = ignore}), + ui::dateTimeInput({.value = [&] { return when.get(); }, .onChange = ignore}), + ui::filePicker({.path = [&] { return path.get(); }, .onPicked = ignore}), + ui::panel({.title = "p", + .collapsible = true, + .collapsed = [&] { return collapsed.get(); }, + .onToggle = ignore}), + ui::tabs({.tabs = {{.label = "A", .node = ui::text({.text = "a"})}, + {.label = "B", .node = ui::text({.text = "b"})}}, + .selected = [&] { return page.get(); }, + .onSelect = ignore}), + ui::dialog({.open = [&] { return open.get(); }, + .child = ui::text({.text = "inside"}), + .onDismiss = [&requests] { ++requests; }}), + ui::table( + {{.label = "n"}}, [] { return std::vector{1, 2}; }, + [](std::int64_t row) { return ui::Key{row}; }, + [](Signal const& row) { + return std::vector{ + ui::text({.text = [&row] { return std::to_string(row.get()); }})}; + }, + {.selectionMode = ui::SelectionMode::Single, + .selection = [&] { return picked.get(); }, + .onSelectionChange = ignore}), + }})}; + int const checkbox = only(backend, "Checkbox"); + int const select = only(backend, "Select"); + int const slider = only(backend, "Slider"); + int const dateTime = only(backend, "DateTimeInput"); + int const picker = only(backend, "FilePicker"); + int const panel = only(backend, "Panel"); + int const tabBar = only(backend, "Tabs"); + int const dialog = only(backend, "Dialog"); + int const table = only(backend, "Table"); + std::string const before = backend.dump(); + + backend.toggle(checkbox); + backend.choose(select, ui::Key{std::int64_t{2}}); + backend.slide(slider, 9); + backend.setDateTime(dateTime, Timestamp{morph::time::DateTime{std::chrono::year{2026}, std::chrono::month{10}, + std::chrono::day{4}, std::chrono::hours{9}, + std::chrono::minutes{30}, std::chrono::seconds{0}}}); + backend.pick(picker, "/b"); + backend.collapse(panel, true); + backend.chooseIndex(tabBar, 1); + backend.dismiss(dialog); + backend.selectRows(table, {ui::Key{std::int64_t{2}}}); + CHECK(requests == 9); + CHECK(backend.prop(checkbox, "checked") == "true"); + CHECK(backend.prop(select, "selected") == "2"); + CHECK(backend.prop(slider, "value") == "9"); + CHECK(backend.prop(dateTime, "value") != "none"); + CHECK(backend.prop(picker, "path") == "/b"); + CHECK(backend.prop(panel, "collapsed") == "true"); + CHECK(backend.prop(tabBar, "selected") == "1"); + CHECK(backend.prop(dialog, "open") == "false"); + CHECK(backend.prop(table, "selection") == "[2]"); + + owner.runAll(); + CHECK(backend.prop(checkbox, "checked") == "false"); + CHECK(backend.prop(select, "selected") == "1"); + CHECK(backend.prop(slider, "value") == "5"); + CHECK(backend.prop(dateTime, "value") == "none"); + CHECK(backend.prop(picker, "path") == "/a"); + CHECK(backend.prop(panel, "collapsed") == "false"); + CHECK(backend.prop(tabBar, "selected") == "0"); + CHECK(backend.prop(dialog, "open") == "true"); + CHECK(backend.prop(table, "selection") == "[]"); + CHECK(backend.dump() == before); +} + +// A dialog whose content failed to mount reports itself closed through onDismiss. A document that keeps `open` true +// then has its dialog re-asserted, but there is no content to show, so it stays closed rather than opening empty, and +// the re-assertion does not try the mount again. Mutation: in Mounter's Dialog re-assertion, call setOpen whatever +// the content (the dialog opens with nothing in it). +TEST_CASE("ui controlled: a dialog whose content failed stays closed when the document keeps it open", "[ui]") { + Owner owner; + morph::testing::OwnerProbeRecorder const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + EchoingBackend backend; + RecordingBackend& recording = backend.recording(); + std::optional spare; + backend.setOnCreate(failWhenSpent(spare)); + Signal open{runtime, false}; + int dismissals = 0; + ui::Mounted const view{runtime, backend, + ui::dialog({.open = [&] { return open.get(); }, + .child = ui::text({.text = "inside"}), + .onDismiss = [&] { ++dismissals; }})}; + spare = 0; // the content's Text fails + open.set(true); + owner.runAll(); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(dismissals == 1); + CHECK(recording.prop(1, "open") == "false"); + CHECK(recording.children(1).empty()); +} diff --git a/tests/test_ui_recording_backend.cpp b/tests/test_ui_recording_backend.cpp index adae2c371..efde3123e 100644 --- a/tests/test_ui_recording_backend.cpp +++ b/tests/test_ui_recording_backend.cpp @@ -488,9 +488,10 @@ TEST_CASE("RecordingBackend: a closed dialog ignores dismiss, and so does everyt dialog->setOnDismiss([&] { ++dismissed; }); auto button = backend.createButton(dialog.get()); button->setOnClick([&] { ++clicks; }); + // The click first: a dismissal closes the dialog, as a real one does, and the click would then not reach it. auto const useBoth = [&] { - backend.dismiss(1); backend.click(2); + backend.dismiss(1); }; useBoth(); // a new dialog is closed dialog->setOpen(true); @@ -500,3 +501,35 @@ TEST_CASE("RecordingBackend: a closed dialog ignores dismiss, and so does everyt CHECK(dismissed == 1); CHECK(clicks == 1); } + +// Mutations: in FakeTextInput::setText, move the cursor whatever the text (the unchanged text loses the cursor); +// never move it (the new text keeps a cursor inside it). +TEST_CASE("RecordingBackend: setText with the text shown keeps the cursor, and another text moves it to the end", + "[ui]") { + RecordingBackend backend; + auto field = backend.createTextInput(nullptr, ui::TextInputMode::SingleLine); + backend.edit(1, "abc"); + CHECK(backend.cursor(1) == 3); + backend.moveCursor(1, 1); + CHECK(backend.cursor(1) == 1); + field->setText("abc"); + CHECK(backend.cursor(1) == 1); + field->setText("abcd"); + CHECK(backend.cursor(1) == 4); + backend.moveCursor(1, 99); + CHECK(backend.cursor(1) == 4); +} + +// Mutation: drop the setByUser in RecordingBackend::dismiss (the dialog stays open). +TEST_CASE("RecordingBackend: a dismissal closes the dialog before onDismiss runs", "[ui]") { + RecordingBackend backend; + std::string openInHandler; + auto dialog = backend.createDialog(nullptr); + dialog->setOnDismiss([&] { openInHandler = backend.prop(1, "open"); }); + dialog->setOpen(true); + backend.clearLog(); + backend.dismiss(1); + CHECK(openInHandler == "false"); + CHECK(backend.prop(1, "open") == "false"); + CHECK(backend.log().empty()); +} diff --git a/tests/test_ui_table.cpp b/tests/test_ui_table.cpp index a788fdbe8..393189da2 100644 --- a/tests/test_ui_table.cpp +++ b/tests/test_ui_table.cpp @@ -103,7 +103,9 @@ TEST_CASE("ui::table: a user's selection comes back through the selection prop", fixture.backend.selectRows(1, {intKey(2)}); fixture.owner.runAll(); CHECK(fixture.selection.peek() == std::vector{intKey(2)}); - CHECK(fixture.backend.log() == Lines{"set Table#1 selection=[2]"}); + // The table shows the user's selection already, so the binding sends nothing back. + CHECK(fixture.backend.prop(1, "selection") == "[2]"); + CHECK(fixture.backend.log().empty()); } TEST_CASE("ui::table: activating a row reports its key", "[ui]") { From 098e2f527c8ca79c34f8fca01992c762441618b7 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 10 Oct 2026 18:11:04 +0300 Subject: [PATCH 5/9] =?UTF-8?q?ui:=20the=20frontend=20seam=20=E2=80=94=20A?= =?UTF-8?q?ppContext,=20AppSource,=20Frontend=20and=20selectFrontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam of spec 1 §5b. A Frontend runs whatever an AppSource opens: a remote server or the application linked in-process. The source delivers a Bundle of verified documents, or a ConnectError, through a ready callback on the owner; switchBackend replaces the dispatch target under mounted screens; close releases what open made. Backend is the application's opaque backend handle. runApp fixes, once for every frontend, the order a run keeps: open the source, mount the shell when it is ready, run the loop, then unmount the shell and close the source before the frontend's runtime goes, whether the loop returns or throws. A second ready is ignored, a ready after the run ended does nothing, and a shell factory that throws quits with 1 and rethrows after the teardown. selectFrontend takes --ui= or --ui (the last before -- wins), then MORPH_UI, then the first usable option, and returns the frontend or a FrontendError naming the built ones. It removes the --ui arguments it used and leaves the arguments alone on an error. A Query's refreshEvery runs on AppContext::scheduler(). Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + include/morph/ui/frontend.hpp | 457 +++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_ui_frontend.cpp | 529 ++++++++++++++++++++++++++++++++++ 4 files changed, 988 insertions(+) create mode 100644 include/morph/ui/frontend.hpp create mode 100644 tests/test_ui_frontend.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 80e76b194..99a8f7a95 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -427,6 +427,7 @@ target_sources(morph include/morph/ui/view.hpp include/morph/ui/backend.hpp include/morph/ui/mount.hpp + include/morph/ui/frontend.hpp include/morph/ui/testing/recording_backend.hpp include/morph/ui/testing/backend_conformance.hpp include/morph/render/i18n.hpp diff --git a/include/morph/ui/frontend.hpp b/include/morph/ui/frontend.hpp new file mode 100644 index 000000000..f5771e441 --- /dev/null +++ b/include/morph/ui/frontend.hpp @@ -0,0 +1,457 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/executor.hpp" +#include "../reactive/runtime.hpp" +#include "../reactive/scheduler.hpp" + +/// @file +/// @brief The frontend seam: `main` picks a frontend at runtime, and the frontend runs whatever an `AppSource` +/// opens. +/// +/// Specified in `docs/spec/ui/frontend.md`. + +namespace morph::exec { +class IoLoop; +} // namespace morph::exec + +namespace morph::ui { + +/// @brief Timers on the frontend's owner executor; see `reactive::Scheduler`. +using Scheduler = reactive::Scheduler; + +/// @brief Owns one scheduled timer and cancels it when destroyed; see `reactive::TimerHandle`. +using TimerHandle = reactive::TimerHandle; + +/// @brief What a frontend lends the source it opens and the screens it mounts. +/// +/// Everything it lends (the runtime, the executor, the scheduler, the loop) outlives the screens and the +/// connections made with it: a frontend destroys those, through `runApp`, before its runtime. +class AppContext { +public: + AppContext() = default; + virtual ~AppContext() = default; + AppContext(AppContext const&) = delete; + AppContext& operator=(AppContext const&) = delete; + AppContext(AppContext&&) = delete; + AppContext& operator=(AppContext&&) = delete; + + /// @brief The runtime the screens are mounted in. Owned by the frontend. + /// @return The runtime. + virtual reactive::Runtime& runtime() = 0; + + /// @brief The runtime's owner, and the callback executor of every bridge handler the source and the screens + /// make. + /// @return The executor; the same one as `runtime().owner()`. + virtual exec::IExecutor& executor() = 0; + + /// @brief Timers that fire on the owner executor. + /// + /// It honours every guarantee of `reactive::Scheduler`, as `docs/spec/reactive/control.md` ("The `Scheduler` + /// contract") states them. + /// @return The scheduler. + virtual Scheduler& scheduler() = 0; + + /// @brief The I/O loop the frontend runs on, for sockets and timers that share its one thread. + /// @return The loop, or null when the frontend has none (Qt Quick). + virtual exec::IoLoop* ioLoop() = 0; + + /// @brief Makes `Frontend::run` return once the current event has been handled. + /// + /// Called before the loop runs (from `AppSource::open`, or from mounting the shell), it still ends the run once + /// the loop starts. + /// @param exitCode What `run` returns. + virtual void quit(int exitCode = 0) = 0; +}; + +/// @brief What an open source hands the frontend: the application's screens, verified. +/// +/// The document model and the interpreter read it (`docs/spec/ui/frontend.md`, "Bundle"); the frontend seam only +/// carries it from the source to the shell. +struct Bundle { + /// @brief The application's id, as the source's handshake names it. + std::string applicationId; + /// @brief The SHA-256 of the bundle's manifest, in lower-case hexadecimal; every document below was verified + /// against the manifest, and the manifest against this digest. + std::string manifestDigest; + /// @brief Every document of the bundle, by id, as its JSON text: the screens, the app shell, and the app document + /// (`app`) when the bundle has one. + std::map> documents; +}; + +/// @brief Why a source could not be opened. +struct ConnectError { + /// @brief What went wrong. + enum class Kind : std::uint8_t { + Unreachable, ///< The server could not be reached, or the connection broke during the handshake. + NoUiService, ///< The peer answered, but serves no UI (`unknown envelope kind: ui-hello`). + Unsupported, ///< The bundle needs a vocabulary this client does not speak; the message names it. + Unverified, ///< A file or the manifest did not match its digest. + Refused, ///< The application refused this client or this user: a sign-in, a licence, an authoriser. + }; + /// @brief What went wrong. + Kind kind = Kind::Unreachable; + /// @brief What to tell the user, UTF-8. + std::string message; +}; + +/// @brief The application's opaque handle for one of its backends. Its name is what `{"env": "backend"}` reads. +struct Backend { + /// @brief The backend's name, such as `primary`, `local` or `remote`. + std::string name; + + /// @brief Memberwise equality. + /// @return Whether the names match. + bool operator==(Backend const&) const = default; +}; + +/// @brief Where the screens and the models are: a server (remote) or the application linked in-process (local). +/// +/// `main` builds one from its arguments and hands it to `Frontend::run`. Every call is made on the context's owner +/// executor, and every callback it is given must be called there too. +class AppSource { +public: + AppSource() = default; + virtual ~AppSource() = default; + AppSource(AppSource const&) = delete; + AppSource& operator=(AppSource const&) = delete; + AppSource(AppSource&&) = delete; + AppSource& operator=(AppSource&&) = delete; + + /// @brief Connects (or, local, sets up the models), signs the user in natively if the application needs it, and + /// delivers the bundle. + /// @param context The running frontend's context; its executor is every bridge's callback executor. + /// @param ready Called once, on the owner, now or later, with the bundle or why there is none. A source that is + /// closed first never calls it. + virtual void open(AppContext& context, std::function)> ready) = 0; + + /// @brief Replaces the dispatch target under the mounted screens: `Bridge::switchBackend`, posted to the owner. + /// @param backend One of the application's backends. + /// @param done Called once, on the owner, when the switch committed, or with why it did not; a switch that + /// fails leaves the old backend in place. + virtual void switchBackend(Backend backend, std::function)> done) = 0; + + /// @brief Releases everything `open` made (connections, bridges, handlers) and drops a `ready` not yet + /// delivered. Called by `runApp` once the screens are gone and before the frontend's runtime is; the + /// source may be opened again afterwards. + virtual void close() = 0; +}; + +/// @brief A frontend: owns an event loop and a renderer, and runs one source's screens. +class Frontend { +public: + Frontend() = default; + virtual ~Frontend() = default; + Frontend(Frontend const&) = delete; + Frontend& operator=(Frontend const&) = delete; + Frontend(Frontend&&) = delete; + Frontend& operator=(Frontend&&) = delete; + + /// @brief The name `selectFrontend` matches. + /// @return The name. + [[nodiscard]] virtual std::string_view name() const = 0; + + /// @brief Builds the runtime and its executor, opens @p source, mounts the app shell and drives the loop until + /// `AppContext::quit`. + /// + /// Tears down in reverse: the screens, then the source's connections, then the runtime, then the loop and the + /// toolkit. A frontend leaves the first two to `runApp`, which fixes that order, and keeps the rest by + /// declaring its members or locals in construction order. + /// @param source Where the screens and the models are; it outlives the call. + /// @return The exit code passed to `quit`; a frontend may also end the run on its own (end of input, an + /// interrupt) with a code it documents. + virtual int run(AppSource& source) = 0; +}; + +/// @brief One frontend a binary was built with. +struct FrontendOption { + /// @brief The name `--ui=` and `MORPH_UI` match. + std::string name; + /// @brief Whether the frontend can run here (a terminal on stdin, a display); empty counts as usable. + std::function usable; + /// @brief Makes the frontend. + std::function()> make; +}; + +/// @brief Why no frontend was selected. +struct FrontendError { + /// @brief What went wrong. + enum class Kind : std::uint8_t { + MissingName, ///< A `--ui` without a name. + UnknownName, ///< `--ui` or `MORPH_UI` names a frontend this binary was not built with. + NoneUsable, ///< Nothing was named, and no built frontend is usable here. + NotMade, ///< The chosen option has no `make`, or its `make` returned null. + }; + /// @brief What went wrong. + Kind kind = Kind::NoneUsable; + /// @brief The name asked for, or the option's name for `NotMade`; empty otherwise. + std::string requested; + /// @brief The names of the frontends this binary was built with, in order. + std::vector built; + + /// @brief A message for the user, naming the built frontends. + /// @return The message. + [[nodiscard]] std::string message() const { + std::string names; + for (std::string const& name : built) { + names += names.empty() ? name : ", " + name; + } + if (names.empty()) { + names = "none"; + } + switch (kind) { + case Kind::MissingName: + return "morph::ui: --ui needs a frontend name (built: " + names + ")"; + case Kind::UnknownName: + return "morph::ui: no frontend named '" + requested + "' is built (built: " + names + ")"; + case Kind::NotMade: + return "morph::ui: frontend '" + requested + "' could not be made"; + case Kind::NoneUsable: + default: + return "morph::ui: no built frontend is usable here (built: " + names + ")"; + } + } +}; + +/// @brief Reads an environment variable: its value, or `nullopt` when it is unset. +using EnvironmentReader = std::function(std::string_view name)>; + +/// @brief The process environment, read through `std::getenv`. +/// @return A reader over the process environment. +[[nodiscard]] inline EnvironmentReader processEnvironment() { + return [](std::string_view name) -> std::optional { + std::string const key{name}; + // getenv races only with a concurrent setenv; frontend selection runs in main before any thread exists. + // MSVC deprecates getenv (C4996) in favour of _dupenv_s; a consumer built with /WX must not fail on it. + // clang-cl reports the same deprecation as -Wdeprecated-declarations, which the MSVC pragma does not reach. + // The MSVC pragma stays on the line just before the call, the only line it covers. +#if defined(__clang__) && defined(_WIN32) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#endif +#ifdef _MSC_VER +#pragma warning(suppress : 4996) +#endif + char const* const value = std::getenv(key.c_str()); // NOLINT(concurrency-mt-unsafe) +#if defined(__clang__) && defined(_WIN32) +#pragma clang diagnostic pop +#endif + if (value == nullptr) { + return std::nullopt; + } + return std::string{value}; + }; +} + +namespace detail { + +/// @brief The command line's choice of frontend, and the arguments that made it. +struct CommandLineChoice { + /// @brief The last name given, or none. + std::optional name; + /// @brief Whether a `--ui` was given without a name after it, or the last name given is empty. + bool missingName = false; + /// @brief The positions in the arguments of every `--ui=`, `--ui` and the name after it, ascending. + std::vector used; +}; + +/// @brief Reads `--ui=` and `--ui ` from the arguments, up to a `--`. +/// +/// The last one wins. An argument starting with `--` is never taken as the name after `--ui`: it is the next option, +/// and the `--ui` before it has no name. +/// @param args The arguments; a program name among them is never a `--ui` flag. +/// @return The choice. +[[nodiscard]] inline CommandLineChoice readCommandLine(std::span args) { + CommandLineChoice choice; + bool nameFollows = false; + for (std::size_t index = 0; std::string const& raw : args) { + std::string_view const arg = raw; + if (nameFollows) { + nameFollows = false; + if (arg.starts_with("--")) { + choice.missingName = true; + break; + } + choice.name = std::string{arg}; + choice.used.push_back(index); + } else if (arg == "--") { + break; + } else if (arg == "--ui") { + nameFollows = true; + choice.used.push_back(index); + } else if (arg.starts_with("--ui=")) { + choice.name = std::string{arg.substr(std::string_view{"--ui="}.size())}; + choice.used.push_back(index); + } + ++index; + } + choice.missingName = choice.missingName || nameFollows || (choice.name.has_value() && choice.name->empty()); + return choice; +} + +} // namespace detail + +/// @brief Picks the frontend to run: the last `--ui=` (or `--ui `) before a `--`, else a non-empty +/// `MORPH_UI`, else the first option in the given order whose `usable()` holds. +/// +/// A frontend named on the command line or in `MORPH_UI` is used even when its `usable()` is false: the user asked +/// for it. On success every `--ui` argument (with the name after a bare `--ui`) is removed from @p args, and the rest +/// keep their order, for the caller to pass on; on an error @p args is left as it was. +/// @param built The frontends this binary was built with, in order of preference. +/// @param args The command-line arguments, with or without the program name first. +/// @param env Reads `MORPH_UI`; the process environment by default. An empty reader reads nothing. +/// @return The frontend, made; or why there is none. +[[nodiscard]] inline std::expected, FrontendError> selectFrontend( + std::span built, std::vector& args, + EnvironmentReader const& env = processEnvironment()) { + std::vector names; + names.reserve(built.size()); + for (FrontendOption const& option : built) { + names.push_back(option.name); + } + auto const failure = [&names](FrontendError::Kind kind, std::string requested) { + return std::unexpected{FrontendError{.kind = kind, .requested = std::move(requested), .built = names}}; + }; + detail::CommandLineChoice const choice = detail::readCommandLine(args); + if (choice.missingName) { + return failure(FrontendError::Kind::MissingName, {}); + } + std::optional requested = choice.name; + if (!requested.has_value() && env) { + if (std::optional fromEnvironment = env("MORPH_UI"); + fromEnvironment.has_value() && !fromEnvironment->empty()) { + requested = std::move(fromEnvironment); + } + } + FrontendOption const* chosen = nullptr; + if (requested.has_value()) { + auto const found = std::ranges::find(built, *requested, &FrontendOption::name); + if (found == built.end()) { + return failure(FrontendError::Kind::UnknownName, *requested); + } + chosen = &*found; + } else { + auto const found = std::ranges::find_if( + built, [](FrontendOption const& candidate) { return !candidate.usable || candidate.usable(); }); + if (found == built.end()) { + return failure(FrontendError::Kind::NoneUsable, {}); + } + chosen = &*found; + } + std::unique_ptr frontend = chosen->make ? chosen->make() : nullptr; + if (frontend == nullptr) { + return failure(FrontendError::Kind::NotMade, chosen->name); + } + for (std::size_t const index : choice.used | std::views::reverse) { + args.erase(args.begin() + static_cast(index)); + } + return frontend; +} + +/// @brief What a frontend mounts once its source is open: the app shell and its screens, or a view of why the source +/// did not open. Destroying it unmounts all of it. +class MountedShell { +public: + MountedShell() = default; + virtual ~MountedShell() = default; + MountedShell(MountedShell const&) = delete; + MountedShell& operator=(MountedShell const&) = delete; + MountedShell(MountedShell&&) = delete; + MountedShell& operator=(MountedShell&&) = delete; +}; + +/// @brief Mounts the shell for what the source delivered. Called once, on the owner; it may return null to mount +/// nothing (after calling `AppContext::quit`, for example). +using ShellFactory = + std::function(AppContext& context, std::expected opened)>; + +/// @brief Runs one source inside a frontend: opens it, mounts the shell when it is ready, runs the loop, and tears +/// down in order. +/// +/// The order is fixed here so that every frontend keeps it. When the loop returns, or throws, the shell is destroyed +/// first, while the source's connections that its screens use are still open, and the source is closed next, while +/// the runtime and the executor its handlers were made with are still alive; the frontend destroys those after this +/// returns. +/// +/// The source's `ready` may come during `open` (a local source) or later from the loop (a remote one). A second +/// `ready` is ignored, and one that comes after the run has ended does nothing. A @p mountShell that throws quits the +/// context with exit code 1, and its exception leaves this call once everything is torn down. +/// @param context The frontend's context; its runtime must outlive this call. +/// @param source The source to open; closed before this returns. +/// @param mountShell Mounts the shell for what the source delivered. +/// @param loop Runs the frontend's event loop and returns the exit code once `AppContext::quit` ends it. +/// @return What @p loop returns. +/// @throws std::invalid_argument when @p mountShell or @p loop is empty; whatever `open`, @p mountShell, @p loop or +/// `close` throws, the first of them. +[[nodiscard]] inline int runApp(AppContext& context, AppSource& source, ShellFactory const& mountShell, + std::function const& loop) { + if (!mountShell || !loop) { + throw std::invalid_argument{"morph::ui::runApp: the shell factory and the loop must not be empty"}; + } + struct Run { + std::unique_ptr shell; + bool opened = false; + std::exception_ptr mountFailure; + }; + auto const run = std::make_shared(); + std::exception_ptr failure; + int exitCode = 0; + try { + source.open(context, + [weak = std::weak_ptr{run}, &context, &mountShell](std::expected opened) { + std::shared_ptr const live = weak.lock(); + if (live == nullptr || live->opened) { + return; + } + live->opened = true; + try { + live->shell = mountShell(context, std::move(opened)); + } catch (...) { + live->mountFailure = std::current_exception(); + context.quit(1); + } + }); + exitCode = loop(); + } catch (...) { + failure = std::current_exception(); + } + // The shell goes first, and the run counts as opened from here on, so a `ready` that arrives while the source + // closes mounts nothing; one that arrives after this returns finds the run gone. + std::exception_ptr const mountFailure = run->mountFailure; + run->shell.reset(); + run->opened = true; + try { + source.close(); + } catch (...) { + if (!failure) { + failure = std::current_exception(); + } + } + if (!failure) { + failure = mountFailure; + } + if (failure) { + std::rethrow_exception(failure); + } + return exitCode; +} + +} // namespace morph::ui diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e60d9cc9b..b0856c5da 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -162,6 +162,7 @@ add_executable(morph_tests test_ui_structure.cpp test_ui_foreach.cpp test_ui_table.cpp + test_ui_frontend.cpp test_ui_conformance.cpp test_cancellation_policy.cpp test_file_io_ops.cpp diff --git a/tests/test_ui_frontend.cpp b/tests/test_ui_frontend.cpp new file mode 100644 index 000000000..3c1f62c08 --- /dev/null +++ b/tests/test_ui_frontend.cpp @@ -0,0 +1,529 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "reactive_fake_server.hpp" +#include "test_support.hpp" + +namespace ui = morph::ui; + +namespace { + +using namespace std::chrono_literals; +using ui::testing::RecordingBackend; +using Events = std::vector; +using Args = std::vector; + +/// A context over a step executor and a manual scheduler, as a frontend's would be over its loop. +class TestContext : public ui::AppContext { +public: + morph::reactive::Runtime& runtime() override { return _runtime; } + morph::exec::IExecutor& executor() override { return _owner; } + ui::Scheduler& scheduler() override { return _scheduler; } + morph::exec::IoLoop* ioLoop() override { return nullptr; } + void quit(int exitCode) override { _exitCode = exitCode; } + + [[nodiscard]] morph::testing::StepExecutor& owner() noexcept { return _owner; } + [[nodiscard]] morph::reactive::testing::ManualScheduler& manualScheduler() noexcept { return _scheduler; } + [[nodiscard]] std::optional exitCode() const noexcept { return _exitCode; } + +private: + morph::testing::StepExecutor _owner; + morph::reactive::testing::ManualScheduler _scheduler; + morph::reactive::Runtime _runtime{_owner}; + std::optional _exitCode; +}; + +/// A context that records when it starts going, which is when its runtime goes: its members are destroyed right +/// after this destructor's body. +class LoggedContext final : public TestContext { +public: + explicit LoggedContext(Events& events) : _events{&events} {} + ~LoggedContext() override { + try { + _events->emplace_back("runtime"); + } catch (...) { // NOLINT(bugprone-empty-catch): a lost entry fails the test that reads the events + } + } + LoggedContext(LoggedContext const&) = delete; + LoggedContext& operator=(LoggedContext const&) = delete; + LoggedContext(LoggedContext&&) = delete; + LoggedContext& operator=(LoggedContext&&) = delete; + +private: + Events* _events; +}; + +/// A source that records what is done to it. It delivers when told to, or during `open` when `immediate` is set, +/// and drops its `ready` on `close` unless `keepReadyAfterClose` is set, as a misbehaving source would. +class FakeSource final : public ui::AppSource { +public: + using Opened = std::expected; + + explicit FakeSource(Events& events) : _events{&events} {} + + void open(ui::AppContext&, std::function ready) override { + _events->emplace_back("open"); + _ready = std::move(ready); + for (Opened const& opened : immediate) { + deliver(opened); + } + } + void switchBackend(ui::Backend backend, std::function)> done) override { + _events->push_back("switch to " + backend.name); + done({}); + } + void close() override { + _events->emplace_back("close"); + if (!keepReadyAfterClose) { + _ready = nullptr; + } + } + + /// Calls the `ready` it holds, if any, as the network would. + void deliver(Opened const& opened) const { + if (_ready) { + auto const ready = _ready; + ready(opened); + } + } + + std::vector immediate; + bool keepReadyAfterClose = false; + +private: + Events* _events; + std::function _ready; +}; + +/// A shell that mounts one Text on a recording backend and records its own unmounting. +class TracedShell final : public ui::MountedShell { +public: + TracedShell(ui::AppContext& context, RecordingBackend& backend, Events& events, std::string text) + : _events{&events}, _mounted{context.runtime(), backend, ui::text({.text = std::move(text)})} {} + ~TracedShell() override { + try { + _events->emplace_back("shell"); + } catch (...) { // NOLINT(bugprone-empty-catch): a lost entry fails the test that reads the events + } + } + TracedShell(TracedShell const&) = delete; + TracedShell& operator=(TracedShell const&) = delete; + TracedShell(TracedShell&&) = delete; + TracedShell& operator=(TracedShell&&) = delete; + +private: + Events* _events; + ui::Mounted _mounted; +}; + +ui::Bundle bundle() { + return ui::Bundle{.applicationId = "demo", .manifestDigest = "00", .documents = {{"home", "{}"}}}; +} + +/// A shell factory that mounts a `TracedShell` showing the application's id, or nothing for a connect error. +ui::ShellFactory tracedShells(RecordingBackend& backend, Events& events) { + return + [&backend, &events](ui::AppContext& context, + std::expected opened) -> std::unique_ptr { + if (!opened.has_value()) { + events.push_back("connect error: " + opened.error().message); + return nullptr; + } + events.push_back("mount " + opened->applicationId); + return std::make_unique(context, backend, events, opened->applicationId); + }; +} + +/// A frontend built as a real one is: its context, and with it the runtime, is a local of `run`, made first and +/// destroyed last; `runApp` does the rest. +class TestFrontend final : public ui::Frontend { +public: + TestFrontend(Events& events, ui::ShellFactory mountShell, std::function loop) + : _events{&events}, _mountShell{std::move(mountShell)}, _loop{std::move(loop)} {} + + [[nodiscard]] std::string_view name() const override { return "test"; } + + int run(ui::AppSource& source) override { + LoggedContext context{*_events}; + return ui::runApp(context, source, _mountShell, [this, &context] { return _loop(context); }); + } + +private: + Events* _events; + ui::ShellFactory _mountShell; + std::function _loop; +}; + +/// Sets a process environment variable for one test, and unsets it afterwards. +class ScopedVariable { +public: + ScopedVariable(char const* name, char const* value) : _name{name} { +#ifdef _WIN32 + static_cast(_putenv_s(name, value)); +#else + static_cast(setenv(name, value, 1)); // NOLINT(concurrency-mt-unsafe): the test runs on one thread +#endif + } + + /// Unsets the variable. + ~ScopedVariable() { +#ifdef _WIN32 + static_cast(_putenv_s(_name, "")); +#else + static_cast(unsetenv(_name)); // NOLINT(concurrency-mt-unsafe): the test runs on one thread +#endif + } + + ScopedVariable(ScopedVariable const&) = delete; + ScopedVariable& operator=(ScopedVariable const&) = delete; + ScopedVariable(ScopedVariable&&) = delete; + ScopedVariable& operator=(ScopedVariable&&) = delete; + +private: + char const* _name; +}; + +class NamedFrontend final : public ui::Frontend { +public: + explicit NamedFrontend(std::string name) : _name{std::move(name)} {} + [[nodiscard]] std::string_view name() const override { return _name; } + int run(ui::AppSource&) override { return 0; } + +private: + std::string _name; +}; + +ui::FrontendOption option(std::string const& name, bool usable) { + return ui::FrontendOption{ + .name = name, + .usable = [usable] { return usable; }, + .make = [name = name] { return std::unique_ptr{std::make_unique(name)}; }, + }; +} + +ui::EnvironmentReader morphUi(std::optional value) { + return [value = std::move(value)](std::string_view name) { return name == "MORPH_UI" ? value : std::nullopt; }; +} + +std::string chosen(std::vector const& built, Args args, ui::EnvironmentReader const& env) { + auto const selected = ui::selectFrontend(built, args, env); + return selected.has_value() ? std::string{(*selected)->name()} : "error: " + selected.error().message(); +} + +std::string selectionError(std::vector const& built, Args args, ui::EnvironmentReader const& env) { + auto const selected = ui::selectFrontend(built, args, env); + return selected.has_value() ? "no error" : selected.error().message(); +} + +std::vector qtThenTui(bool qtUsable = true, bool tuiUsable = true) { + return {option("qt", qtUsable), option("tui", tuiUsable)}; +} + +struct Lookup { + int id = 0; + bool operator==(Lookup const&) const = default; +}; + +} // namespace + +// ── selectFrontend ───────────────────────────────────────────────────────── + +// Mutation: read MORPH_UI before the command line in selectFrontend. +TEST_CASE("ui::selectFrontend: --ui= wins over MORPH_UI and over the order", "[ui]") { + CHECK(chosen(qtThenTui(), {"app", "--ui=tui"}, morphUi("qt")) == "tui"); +} + +TEST_CASE("ui::selectFrontend: --ui takes its name from the next argument", "[ui]") { + CHECK(chosen(qtThenTui(), {"app", "--verbose", "--ui", "tui"}, morphUi(std::nullopt)) == "tui"); +} + +TEST_CASE("ui::selectFrontend: the last --ui wins", "[ui]") { + CHECK(chosen(qtThenTui(), {"app", "--ui=qt", "--ui=tui"}, morphUi(std::nullopt)) == "tui"); +} + +TEST_CASE("ui::selectFrontend: MORPH_UI wins over the order, and an empty one is ignored", "[ui]") { + CHECK(chosen(qtThenTui(), {"app"}, morphUi("tui")) == "tui"); + CHECK(chosen(qtThenTui(), {"app"}, morphUi("")) == "qt"); +} + +TEST_CASE("ui::selectFrontend: otherwise the first usable option, in the order given", "[ui]") { + CHECK(chosen(qtThenTui(), {"app"}, morphUi(std::nullopt)) == "qt"); + CHECK(chosen(qtThenTui(false, true), {"app"}, morphUi(std::nullopt)) == "tui"); + std::vector const unconditional{{.name = "web", .usable = {}, .make = [] { + return std::unique_ptr{ + std::make_unique("web")}; + }}}; + CHECK(chosen(unconditional, {"app"}, morphUi(std::nullopt)) == "web"); +} + +TEST_CASE("ui::selectFrontend: a named frontend is used even when it is not usable", "[ui]") { + CHECK(chosen(qtThenTui(false, true), {"app", "--ui=qt"}, morphUi(std::nullopt)) == "qt"); +} + +TEST_CASE("ui::selectFrontend: an unknown or unbuilt name is an error that lists the built frontends", "[ui]") { + Args args{"app", "--ui=web"}; + auto const selected = ui::selectFrontend(qtThenTui(), args, morphUi(std::nullopt)); + REQUIRE_FALSE(selected.has_value()); + CHECK(selected.error().kind == ui::FrontendError::Kind::UnknownName); + CHECK(selected.error().requested == "web"); + CHECK(selected.error().built == Args{"qt", "tui"}); + CHECK(selected.error().message() == "morph::ui: no frontend named 'web' is built (built: qt, tui)"); + CHECK(selectionError(qtThenTui(), {"app"}, morphUi("web")) == + "morph::ui: no frontend named 'web' is built (built: qt, tui)"); +} + +TEST_CASE("ui::selectFrontend: nothing usable is an error", "[ui]") { + CHECK(selectionError(qtThenTui(false, false), {"app"}, morphUi(std::nullopt)) == + "morph::ui: no built frontend is usable here (built: qt, tui)"); + CHECK(selectionError({}, {"app"}, morphUi(std::nullopt)) == + "morph::ui: no built frontend is usable here (built: none)"); +} + +TEST_CASE("ui::selectFrontend: --ui without a name is an error", "[ui]") { + CHECK(selectionError(qtThenTui(), {"app", "--ui"}, morphUi("tui")) == + "morph::ui: --ui needs a frontend name (built: qt, tui)"); + CHECK(selectionError(qtThenTui(), {"app", "--ui="}, morphUi("tui")) == + "morph::ui: --ui needs a frontend name (built: qt, tui)"); +} + +TEST_CASE("ui::selectFrontend: an option that makes nothing, or has no make, is an error", "[ui]") { + std::vector const broken{ + {.name = "tui", .usable = [] { return true; }, .make = [] { return std::unique_ptr{}; }}}; + CHECK(selectionError(broken, {"app"}, morphUi(std::nullopt)) == "morph::ui: frontend 'tui' could not be made"); + std::vector const unmakeable{{.name = "tui", .usable = {}, .make = {}}}; + CHECK(selectionError(unmakeable, {"app"}, morphUi(std::nullopt)) == "morph::ui: frontend 'tui' could not be made"); +} + +TEST_CASE("ui::selectFrontend: an option after --ui is not its name", "[ui]") { + CHECK(selectionError(qtThenTui(), {"app", "--ui", "--verbose"}, morphUi("tui")) == + "morph::ui: --ui needs a frontend name (built: qt, tui)"); + CHECK(selectionError(qtThenTui(), {"app", "--ui", "--", "tui"}, morphUi("tui")) == + "morph::ui: --ui needs a frontend name (built: qt, tui)"); +} + +TEST_CASE("ui::selectFrontend: -- ends option parsing", "[ui]") { + CHECK(chosen(qtThenTui(), {"app", "--", "--ui=tui"}, morphUi(std::nullopt)) == "qt"); + CHECK(chosen(qtThenTui(), {"app", "--ui=tui", "--", "--ui=qt"}, morphUi(std::nullopt)) == "tui"); + CHECK(chosen(qtThenTui(), {"app", "--", "--ui"}, morphUi(std::nullopt)) == "qt"); +} + +TEST_CASE("ui::selectFrontend: an empty --ui= followed by a named one takes the named one", "[ui]") { + CHECK(chosen(qtThenTui(), {"app", "--ui=", "--ui=tui"}, morphUi(std::nullopt)) == "tui"); +} + +TEST_CASE("ui::selectFrontend: an empty environment reader is never read", "[ui]") { + CHECK(chosen(qtThenTui(), {"app"}, ui::EnvironmentReader{}) == "qt"); + CHECK(chosen(qtThenTui(), {"app", "--ui=tui"}, ui::EnvironmentReader{}) == "tui"); +} + +// Mutations: in selectFrontend, erase only the last used argument; erase them front to back (the positions shift and +// the wrong arguments go). +TEST_CASE("ui::selectFrontend: the --ui arguments it used are removed, and the rest keep their order", "[ui]") { + Args args{"app", "--verbose", "--ui", "tui", "file", "--ui=qt", "--", "--ui=web"}; + auto const selected = ui::selectFrontend(qtThenTui(), args, morphUi(std::nullopt)); + REQUIRE(selected.has_value()); + CHECK((*selected)->name() == "qt"); + CHECK(args == Args{"app", "--verbose", "file", "--", "--ui=web"}); +} + +// Mutation: in selectFrontend, erase the used arguments before the frontend is found and made. +TEST_CASE("ui::selectFrontend: an error leaves the arguments as they were", "[ui]") { + Args unknown{"app", "--ui=web", "file"}; + CHECK_FALSE(ui::selectFrontend(qtThenTui(), unknown, morphUi(std::nullopt)).has_value()); + CHECK(unknown == Args{"app", "--ui=web", "file"}); + Args unusable{"app", "--ui", "tui"}; + std::vector const broken{{.name = "tui", .usable = {}, .make = {}}}; + CHECK_FALSE(ui::selectFrontend(broken, unusable, morphUi(std::nullopt)).has_value()); + CHECK(unusable == Args{"app", "--ui", "tui"}); +} + +TEST_CASE("ui::processEnvironment: reads the process environment", "[ui]") { + constexpr char const* kVariable = "MORPH_UI_TEST_PROCESS_ENVIRONMENT"; + auto const env = ui::processEnvironment(); + CHECK_FALSE(env(kVariable).has_value()); + { + ScopedVariable const variable{kVariable, "tui"}; + CHECK(env(kVariable) == std::optional{"tui"}); + } + CHECK_FALSE(env(kVariable).has_value()); +} + +// ── runApp and the destruction order ─────────────────────────────────────── + +// Mutations: in runApp, close the source before resetting the shell ("close" comes before "shell"); keep the shell +// until runApp returns (it outlives "close"). +TEST_CASE("ui::runApp: the shell goes first, then the source's connections, then the runtime", "[ui]") { + Events events; + RecordingBackend backend; + FakeSource source{events}; + source.immediate.emplace_back(bundle()); + TestFrontend frontend{events, tracedShells(backend, events), [&](TestContext&) { + events.push_back("loop: " + backend.dump()); + return 7; + }}; + int const exitCode = frontend.run(source); + CHECK(exitCode == 7); + CHECK(events == Events{"open", "mount demo", "loop: Text#1 role=Normal text=demo\n", "shell", "close", "runtime"}); + CHECK(backend.all("Text").empty()); +} + +// Mutation: in runApp, let the loop's exception leave before the teardown. +TEST_CASE("ui::runApp: a loop that throws still tears down in order, then rethrows", "[ui]") { + Events events; + RecordingBackend backend; + FakeSource source{events}; + source.immediate.emplace_back(bundle()); + TestFrontend frontend{events, tracedShells(backend, events), [&](TestContext&) -> int { + events.emplace_back("loop"); + throw std::runtime_error{"loop failed"}; + }}; + CHECK_THROWS_AS(frontend.run(source), std::runtime_error); + CHECK(events == Events{"open", "mount demo", "loop", "shell", "close", "runtime"}); +} + +// A source that connects later delivers from inside the loop, on the owner. +TEST_CASE("ui::runApp: a source that delivers from the loop mounts the shell there", "[ui]") { + Events events; + RecordingBackend backend; + FakeSource source{events}; + TestFrontend frontend{events, tracedShells(backend, events), [&](TestContext&) { + events.emplace_back("loop starts"); + source.deliver(bundle()); + events.push_back("loop: " + backend.dump()); + return 0; + }}; + CHECK(frontend.run(source) == 0); + CHECK(events == Events{"open", "loop starts", "mount demo", "loop: Text#1 role=Normal text=demo\n", "shell", + "close", "runtime"}); +} + +// Mutation: in runApp's ready, hold the run's state strongly (the late delivery mounts into a dead runtime). +TEST_CASE("ui::runApp: a ready that comes after the run ended mounts nothing", "[ui]") { + Events events; + RecordingBackend backend; + FakeSource source{events}; + source.keepReadyAfterClose = true; + TestFrontend frontend{events, tracedShells(backend, events), [](TestContext&) { return 0; }}; + CHECK(frontend.run(source) == 0); + source.deliver(bundle()); + CHECK(events == Events{"open", "close", "runtime"}); + CHECK(backend.log().empty()); +} + +// Mutation: in runApp's ready, drop the `opened` check (the second delivery replaces the shell). +TEST_CASE("ui::runApp: a second ready is ignored", "[ui]") { + Events events; + RecordingBackend backend; + FakeSource source{events}; + source.immediate.emplace_back(bundle()); + source.immediate.emplace_back(ui::Bundle{.applicationId = "other"}); + TestFrontend frontend{events, tracedShells(backend, events), [](TestContext&) { return 0; }}; + CHECK(frontend.run(source) == 0); + CHECK(events == Events{"open", "mount demo", "shell", "close", "runtime"}); +} + +TEST_CASE("ui::runApp: a source that cannot connect hands the shell factory the error", "[ui]") { + Events events; + RecordingBackend backend; + FakeSource source{events}; + source.immediate.emplace_back( + std::unexpected{ui::ConnectError{.kind = ui::ConnectError::Kind::NoUiService, .message = "no UI here"}}); + TestFrontend frontend{events, tracedShells(backend, events), [](TestContext&) { return 3; }}; + CHECK(frontend.run(source) == 3); + CHECK(events == Events{"open", "connect error: no UI here", "close", "runtime"}); +} + +// Mutation: in runApp's ready, let the factory's exception escape into the source. +TEST_CASE("ui::runApp: a shell factory that throws quits with 1, and its exception leaves after the teardown", + "[ui]") { + Events events; + FakeSource source{events}; + source.immediate.emplace_back(bundle()); + std::optional quitWith; + TestFrontend frontend{ + events, + [](ui::AppContext&, std::expected) -> std::unique_ptr { + throw std::runtime_error{"no shell"}; + }, + [&](TestContext& context) { + quitWith = context.exitCode(); + events.emplace_back("loop"); + return 0; + }}; + std::string caught; + try { + static_cast(frontend.run(source)); + } catch (std::runtime_error const& error) { + caught = error.what(); + } + CHECK(caught == "no shell"); + CHECK(quitWith == std::optional{1}); + CHECK(events == Events{"open", "loop", "close", "runtime"}); +} + +TEST_CASE("ui::runApp: an empty shell factory or loop is refused before the source is opened", "[ui]") { + Events events; + FakeSource source{events}; + TestContext context; + CHECK_THROWS_AS(ui::runApp(context, source, ui::ShellFactory{}, [] { return 0; }), std::invalid_argument); + RecordingBackend backend; + CHECK_THROWS_AS(ui::runApp(context, source, tracedShells(backend, events), std::function{}), + std::invalid_argument); + CHECK(events.empty()); +} + +TEST_CASE("ui::AppSource: switchBackend reports through done", "[ui]") { + Events events; + FakeSource source{events}; + std::optional switched; + source.switchBackend(ui::Backend{.name = "local"}, + [&switched](std::expected done) { switched = done.has_value(); }); + CHECK(switched == std::optional{true}); + CHECK(events == Events{"switch to local"}); + CHECK(ui::Backend{.name = "local"} == ui::Backend{.name = "local"}); +} + +// ── The frontend's scheduler drives a query's timed refresh ──────────────── + +// A frontend's scheduler is what a query's `refreshEvery` runs on. Mutations: build the query without the context's +// scheduler (it is refused); fetch on a tick whatever is in flight (a fourth call while the third is pending). +TEST_CASE("ui::AppContext: refreshEvery runs on the frontend's scheduler and skips a tick while pending", "[ui]") { + TestContext context; + morph::testing::FakeServer server{context.executor()}; + morph::reactive::Query const query{ + context.runtime(), server.via(), [] { return std::optional{Lookup{1}}; }, + morph::reactive::QueryOptions{.scheduler = &context.scheduler(), .refreshEvery = 1000ms}}; + REQUIRE(server.calls() == 1); + server.resolve(0, "one"); + context.owner().runAll(); + context.manualScheduler().advance(1000ms); + CHECK(server.calls() == 2); // the tick re-fetched + context.manualScheduler().advance(1000ms); + CHECK(server.calls() == 2); // the second call is still in flight: the tick waits + server.resolve(1, "two"); + context.owner().runAll(); + CHECK(query.value() == std::optional{"two"}); + context.manualScheduler().advance(1000ms); + CHECK(server.calls() == 3); +} From 475ab2984c4f53757cda2289a27b95ce2e1c9e7e Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 10 Oct 2026 18:14:05 +0300 Subject: [PATCH 6/9] ui: specify the view tree, the backend contract and the frontend seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/spec/ui/view_tree.md, backend_contract.md and frontend.md state what the headers do: slots with ids, the base palette and where the document's further kinds go, child scopes and owner order, controlled inputs, the setter rule a text cursor depends on, AppSource and runApp's teardown order, and selectFrontend's argument handling. The spec map, ARCHITECTURE and the changelog list the new subsystem. Three spec amendments the part needed, in spec 1: AppSource gains close(), so a run can end the source's connections before its runtime goes; §5 says which palette the view tree carries now and how the rest joins; §2 says host.hpp lands with hosting, since its ParamMap holds the document's Value. Spec 4's main sketch follows spec 1's selectFrontend. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 + docs/ARCHITECTURE.md | 12 + docs/spec/README.md | 7 + docs/spec/ui/backend_contract.md | 312 +++++++++++++ docs/spec/ui/frontend.md | 169 +++++++ docs/spec/ui/view_tree.md | 431 ++++++++++++++++++ .../2026-10-04-declarative-ui-tui-design.md | 17 +- .../2026-10-04-examples-migration-design.md | 5 +- tests/test_ui_controlled.cpp | 2 +- 9 files changed, 974 insertions(+), 4 deletions(-) create mode 100644 docs/spec/ui/backend_contract.md create mode 100644 docs/spec/ui/frontend.md create mode 100644 docs/spec/ui/view_tree.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 694b68132..abefeaf3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,29 @@ API surface). ### Added +- **`morph::ui`: the view tree, its mount and the frontend seam.** Header-only, in the base `morph` + target. + - An immutable view tree of the UI document's base palette (`view.hpp`): every property a constant + or a slot, a reactive value with an optional stable id (`ui::slot`), and every event a command + sink; keyed `forEach` and `table` over any row type. + - The backend contract (`backend.hpp`): one widget interface per kind and an `IViewBackend` with a + typed factory each. No setter calls a handler, a setter with the value a widget shows changes + nothing, and a widget the user cannot reach takes no input. + - `Mounted` (`mount.hpp`) builds a tree's widgets once and keeps them current through + equality-gated bindings. Switch cases, Tabs pages, Dialog content and ForEach/Table rows mount + all or nothing into child scopes, torn down child-first, with owners' bindings running first; + rows keep their identity per key and reorder with the fewest moves. Input widgets are + controlled by their slots: a refused edit snaps back, an accepted one costs no setter call. + - The frontend seam (`frontend.hpp`): `AppContext`, `AppSource` (`open`, `switchBackend`, + `close`), `Bundle`, `ConnectError`, `Backend`, `Frontend`, and `selectFrontend`, which picks a + frontend from `--ui`, `MORPH_UI` or the first usable one and returns a `FrontendError` naming the + built ones; `runApp` fixes the order a frontend opens a source, mounts its shell and tears both + down. + - `testing::RecordingBackend`, a headless reference backend with an operation log, a golden dump + and user-interaction helpers, and the conformance cases every renderer runs through its own + `ConformanceProbe`. + + See `docs/spec/ui/view_tree.md`, `backend_contract.md` and `frontend.md`. - **Model-free dispatch.** `RawHandler` binds a model by its registered type id and dispatches actions by id with a JSON body, for a client compiled without the model's type. Calls carry the default session, the execute diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9b5b43792..f0f7b3532 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,6 +50,7 @@ The public surface is split per topic so callers always know whether a name is p | `morph::time` | UTC timestamps for actions | `DateTime`, `Timestamp` | | `morph::forms` | JSON-Schema generation for auto-built GUIs | `schemaJson()`, `allRequiredEngaged()`, `Choice`, `FixedString`, `isChoice`, `EmptyCapableField` | | `morph::reactive` | Signal graph, view state and declarative control (see `docs/spec/reactive/`) | `Runtime`, `RuntimeOptions`, `Signal`, `Computed`, `Effect`, `Scope`, `EqualityPolicy`, `kEqualityUsable`, `Store`, `ExhaustiveUpdate`, `request()`, `Query`, `QueryOptions`, `InvalidationLink`, `Mutation`, `MutationOptions`, `Concurrency`, `Subscription`, `Refetchable`, `errorMessage`, `Scheduler`, `TimerHandle`; `testing::ManualScheduler` | +| `morph::ui` | View tree, mount and the frontend seam (see `docs/spec/ui/`) | `Node`, `Prop`, `SlotId`, `slot()`, `Key`, the node kinds and their builders, `forEach`, `table`, `switchOn`; `Widget`, `ContainerWidget`, the widget interfaces, `IViewBackend`; `Mounted`; `AppContext`, `AppSource`, `Bundle`, `ConnectError`, `Backend`, `Frontend`, `FrontendOption`, `FrontendError`, `EnvironmentReader`, `processEnvironment`, `selectFrontend`, `MountedShell`, `ShellFactory`, `runApp`; `testing::RecordingBackend`, `testing::ConformanceProbe`, `testing::conformanceCases` | | `morph::testing` | The installed owner test kit | `StepExecutor`, `OwnerProbeRecorder` | | `morph::qt` | Qt integration (built only when `MORPH_BUILD_QT=ON`) | `QtExecutor`, `QtWebSocketBackend`, `QtWebSocketServer` | @@ -707,6 +708,17 @@ folder. | `reactive/testing/manual_scheduler.hpp` | `testing::ManualScheduler` — a `Scheduler` whose clock moves only when a test advances it | | `reactive/detail/graph.hpp` | `RuntimeCore`, `Node`, `TrackingFrame`, `Colour`, `FlushBounds`, the misuse site names, `kDefaultMaxEffectRunsPerFlush`, `kDefaultMaxThrowReposts` (`morph::reactive::detail::`) | +#### `ui/` — view tree, mount, frontend seam + +| Header | Responsibility | +|---|---| +| `ui/view.hpp` | `Node`, `NodeData`, `Prop`, `SlotId`, `SlotBinding`, `slot()`, `Key`, `Common`, `Sizing`, `LayoutHints`, every node kind and its builder, `switchOn`, `forEach`, `table` (`morph::ui::`) — the immutable view tree whose properties are constants or slots. See `docs/spec/ui/view_tree.md`. | +| `ui/backend.hpp` | `Widget`, `ContainerWidget`, one widget interface per kind, `IViewBackend` — the retained widgets a renderer implements. See `docs/spec/ui/backend_contract.md`. | +| `ui/mount.hpp` | `Mounted` — builds a tree's widgets once, keeps them current through bindings, mounts content in child scopes, keeps keyed rows, controls inputs by their slots | +| `ui/frontend.hpp` | `AppContext`, `AppSource`, `Bundle`, `ConnectError`, `Backend`, `Frontend`, `FrontendOption`, `FrontendError`, `selectFrontend`, `MountedShell`, `ShellFactory`, `runApp` — the seam `main` picks a frontend through. See `docs/spec/ui/frontend.md`. | +| `ui/testing/recording_backend.hpp` | `testing::RecordingBackend` — the headless reference backend: operation log, golden dump, interaction helpers | +| `ui/testing/backend_conformance.hpp` | `testing::ConformanceProbe`, `testing::ConformanceCase`, `testing::conformanceCases()` — the scripted cases every backend passes | + #### `testing/` — the installed owner test kit | Header | Responsibility | diff --git a/docs/spec/README.md b/docs/spec/README.md index f6b65885f..00ca207db 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -133,6 +133,13 @@ server [`reactive/control.md`](reactive/control.md) — signals, view state, and server interaction a controller declares instead of sequencing +**Declarative UI** +[`ui/view_tree.md`](ui/view_tree.md) · +[`ui/backend_contract.md`](ui/backend_contract.md) · +[`ui/frontend.md`](ui/frontend.md) — the view tree a screen mounts into, +the widgets every renderer implements, and the seam that picks a renderer +at runtime + **Exact values on the wire** [`util/rational.md`](util/rational.md) · [`util/quantity_type.md`](util/quantity_type.md) · diff --git a/docs/spec/ui/backend_contract.md b/docs/spec/ui/backend_contract.md new file mode 100644 index 000000000..b3cc5355e --- /dev/null +++ b/docs/spec/ui/backend_contract.md @@ -0,0 +1,312 @@ +# Backend contract — design + +Design spec for `include/morph/ui/backend.hpp` — the retained widgets a mount drives and the +`IViewBackend` that makes them — and for the two test instruments that fix the contract: +`ui::testing::RecordingBackend` (`include/morph/ui/testing/recording_backend.hpp`) and the +conformance cases (`include/morph/ui/testing/backend_conformance.hpp`). + +Read this before implementing a backend for a frontend, and before changing a widget interface. +What the mount does with these widgets is [`view_tree.md`](view_tree.md). + +## Contents + +- [Widgets](#widgets) +- [Handlers](#handlers) +- [Containers](#containers) +- [Interfaces](#interfaces) +- [Selection by key](#selection-by-key) +- [Drag and drop](#drag-and-drop) +- [RecordingBackend](#recordingbackend) +- [The conformance suite](#the-conformance-suite) +- [Design decisions](#design-decisions) + +## Widgets + +- `IViewBackend` has one factory per kind. Each makes the widget, appends it as the last child of + `parent` — or makes a root when `parent` is null — and never returns null. A kind's fixed + parameters (a TextInput's mode, a stack's or scroll area's axis, a DateTimeInput's mode and + display zone, a Select's style, a FilePicker's mode) are factory arguments. +- **A new widget is visible, enabled, content-sized, not draggable and not a drop target. A new + dialog is closed.** The mount calls the `Widget` setters for those defaults (`setVisible`, + `setEnabled`, `setLayout`, `setDragKey`, `setDropHandler`) only to change them; it calls a + dialog's `setOpen` at mount whatever the value, `false` included. +- Every setter takes UTF-8. **A setter called with the value the widget already shows changes + nothing**: in particular a text input keeps its cursor, its selection and an input method's + composition. A controlled input's re-assertion, or another renderer, may make such a call. +- **Input widgets are controlled.** A user's action changes what the widget shows, as the toolkit + does by itself, and reports the request through the handler; the mount then shows what the + application's slot says, in a turn posted after the request's flush, so a request the application + refuses snaps back ([`view_tree.md`, "Controlled inputs"](view_tree.md#controlled-inputs)). A user's + dismissal closes a dialog before `onDismiss` runs; a dialog the application keeps open is opened + again. +- **No setter calls a handler.** Setting a value, replacing options, items or rows, requesting a + selection, opening or closing, collapsing — none of it is a user's doing, so none of it reaches + `onChange`, `onToggle`, `onSelect`, `onSelectionChange` or any other handler. That covers what a + widget does by itself in response to a setter: a Select re-marking its requested key after + `setOptions`, a Table marking or unmarking a row that arrives or goes. A binding that writes back + what the user typed therefore cannot loop. +- `setVisible(false)` hides the widget and everything inside it; `setEnabled(false)` stops input to + the widget and everything inside it. **A widget inside a hidden, disabled or collapsed container, + or inside a closed dialog, takes no input**, however deep it sits; it takes input again once + every container around it is shown, enabled, expanded and open. A collapsed panel's own collapse + control still works. +- A widget's destructor detaches it from its parent and frees its native resources. The mount + destroys children before parents. + +## Handlers + +- Handlers are handed over once (`setOnClick`, `setOnChange`, …); an empty one does nothing. The + mount wraps each in `Runtime::widgetEvent`, so a backend calls them directly from its native + event handler. +- **A handler may destroy its own widget.** Every backend keeps a handler alive for the duration + of its own call — for example by calling a copy of it — as `RecordingBackend` does. A backend + that calls the stored `std::function` in place frees the running closure when the handler + destroys its widget. +- **No handler runs after its widget's destructor returns**, even for input that arrived before. + A backend whose input can be delivered later than the event that caused it (a queued connection) + drops what is still pending for a destroyed widget. +- A backend may assume a handler never throws, a drop's `accepts` predicate included: the mount + catches and reports what one throws ([`view_tree.md`, "Callbacks"](view_tree.md#callbacks)). + +## Containers + +`ContainerWidget::moveChild(child, index)` removes `child` and inserts it before the child now at +`index`; an index past the end means last. **The child is moved, not recreated**: it keeps its +native state — properties, handlers, focus, selection, the text a user typed into it. A backend +whose toolkit can only remove and re-add a child must carry that state across. Only the mount +reorders children, and only through this. + +| Container | Children | +|---|---| +| `StackWidget` | In order along the axis fixed at creation, `setGap` apart | +| `GridWidget` | Row-major in `setColumns` columns, `setGap` apart; `setSpan(child, n)` widens one, and a child spans 1 until told | +| `PanelWidget` | Inside the frame, `setPadding` in; a collapsed panel hides its content, which takes no input | +| `ScrollWidget` | One scrolled viewport, on the axis fixed at creation, that keeps the focused child visible | +| `SlotWidget` | Shown as they are; hosts a Switch case or a Tabs page | +| `TabsWidget` | Page slots in the order first shown; the mount keeps exactly the selected page's slot visible, and `setSelected` moves the bar's highlight only | +| `DialogWidget` | The overlay's content; a focus trap while open; a closed dialog shows nothing and takes no input, its children included | +| `TableWidget` | Rows, each a horizontal stack of cells in column order; `setRowKey(row, key)` once per row, right after it is built. A hidden cell keeps its column: the cells after it stay under their own headers | + +## Interfaces + +| Interface | Setters | Handlers | +|---|---|---| +| `Widget` | `setVisible`, `setEnabled`, `setLayout(LayoutHints)`, `setDragKey(optional)` | `setDropHandler(accepts, onDrop)` | +| `ContainerWidget` | `moveChild(child, index)` | — | +| `TextWidget` | `setText`, `setRole` | — | +| `ButtonWidget` | `setLabel` | `setOnClick` (Enter, Space or a click) | +| `TextInputWidget` | `setText` (with the text shown, it keeps the cursor), `setPlaceholder` | `setOnChange` (the whole new text after each user edit), `setOnSubmit` (Enter in single-line mode) | +| `CheckboxWidget` | `setLabel`, `setChecked` | `setOnToggle(bool)` | +| `SelectWidget` | `setOptions`, `setSelected(optional)` | `setOnSelect(Key)` | +| `MenuWidget` | `setItems(labels)` | `setOnActivate(index)` | +| `StackWidget` | `setGap` | — | +| `GridWidget` | `setColumns` (at least one), `setGap`, `setSpan(child, span)` | — | +| `SpacerWidget` | — | — | +| `PanelWidget` | `setTitle`, `setPadding`, `setCollapsible`, `setCollapsed` | `setOnToggle(collapsed)`, with the state the user asked for | +| `ScrollWidget`, `SlotWidget` | — | — | +| `TabsWidget` | `setTabs(labels)`, `setSelected(index)` (past the last highlights none) | `setOnSelect(index)` | +| `DialogWidget` | `setOpen`, `setTitle` | `setOnDismiss` (Esc on the TUI), for an open dialog, which the dismissal closes first | +| `BusyWidget` | `setActive`, `setLabel` | — | +| `TableWidget` | `setColumns(vector)`, `setSelectionMode`, `setRowKey(row, key)`, `setSelection(keys)` | `setOnSelectionChange(keys)`, `setOnActivate(Key)` (Enter or a double click) | +| `DateTimeInputWidget` | `setValue(optional)` (`nullopt` or an empty `Timestamp` shows an empty field) | `setOnChange(optional)` (`nullopt` for a cleared field) | +| `SliderWidget` | `setRange(minimum, maximum, step)`, `setValue` | `setOnChange(value)` | +| `FilePickerWidget` | `setPath` | `setOnPicked(path)` | + +## Selection by key + +Both selecting widgets keep what was **requested** apart from what they **mark**, so a value and +the list it refers to may arrive in either order. + +- **Select.** The widget keeps the key last requested by `setSelected` or chosen by the user, and + marks the option with that key whenever the current options contain one. A key outside the + options marks none and stays requested: options that bring it back mark it again. +- **Table.** The widget keeps the keys last requested by `setSelection` or selected by the user, and + marks the current rows that have one of them. A key with no row marks nothing and stays + requested, so a row that arrives with it later is marked, a row that goes is unmarked, and a + reorder keeps the marks. The selection mode limits what the user can select, not what + `setSelection` marks. +- A Table shows the user's selection whether or not the application binds `selection`. A user's + selection replaces the requested keys, and `onSelectionChange` then gets exactly the keys of the + existing rows now selected, in any mode: a key still waiting for its row is dropped by a user's + change, while `setSelection` keeps it waiting. A backend may report each step of the user's + picking (a click, then a Ctrl-click); the last report is the whole selection, and no report names + a key without a row. + +## Drag and drop + +A widget with a drag key can be dragged; a widget with a drop handler is a target. While dragging, +the backend asks the target's `accepts(key)` before highlighting it and before a drop. A drop it +refuses delivers nothing; an accepted one calls `onDrop(key)` once. The mount always passes a +predicate — an accept-everything one when the node's `accepts` is empty — so a backend never sees +an empty one. A source or target the user cannot reach (hidden, disabled, or inside such a +container) takes part in no drag. + +## RecordingBackend + +The headless reference backend and the test double for everything above the contract. + +- **Ids and kinds.** Widget ids are creation numbers from 1. A stack's kind is `Column` or `Row` by + its axis; every other kind is the interface name without `Widget` (`Text`, `Slot`, `Table`, …). +- **Operation log** (`log()`, `clearLog()`), one line per operation, oldest first: + `create Kind#id in Parent#id` (`in root` for a root), `set Kind#id name=value`, + `move Kind#id to n`, `destroy Kind#id`. Factory parameters are recorded as properties without a + log line. Handlers are stored without a log line, except that `setDropHandler` records the + property `drop=handler`. What a widget changes by itself — an interaction helper's effect, a + Select or Table re-marking its selection — changes a property without a log line. +- **Golden dump** (`dump()`): one line per live widget, `Kind#id name=value …`, properties in name + order, children indented two spaces per depth, every line ending in a newline. A child whose + parent was destroyed first becomes a root. +- **Properties.** Every kind records `visible`, `enabled`, `layout`, `dragKey` and `drop` when their + setters run, and its own: `text`, `role` (Text); `label` (Button); `mode`, `text`, `placeholder` + (TextInput); `label`, `checked` (Checkbox); `style`, `options`, `selected` (Select, where + `selected` is the option it marks, else `none`); `items` (Menu); `gap` (Column, Row); `columns`, + `gap`, and `span` on a child (Grid); `title`, `padding`, `collapsible`, `collapsed` (Panel); + `axis` (Scroll); `tabs`, `selected` (Tabs); `open`, `title` (Dialog); `active`, `label` (Busy); + `columns`, `selectionMode`, `selection` (the requested keys it marks, in the order requested), + and `rowKey` on a row (Table); `mode`, `offset`, `value` (DateTimeInput); `range`, `value` + (Slider); `mode`, `path` (FilePicker). +- **Values.** `true`/`false`; an enumerator by its name; a key as a decimal integer or a + double-quoted string (`7` and `"7"` differ); `none` for an absent key or date; a date as an + ISO-8601 UTC instant with milliseconds, `empty` for an empty `Timestamp`; sizing as `content`, + `fixed(n)` or `stretch(n)`, a layout as `width/height`; lists as `[a,b]`; options as + `[key:label,…]`; columns as `[label:sizing,…]`; a slider's range as `minimum..maximum/step`. +- **Escaping.** Free text is escaped so that every value stays on its line and reads as one field: + a line feed becomes `\n`, a carriage return `\r`, and a backslash or `=` is preceded by a + backslash. Text inside a list also escapes `,`, `[` and `]`, and a string key also escapes `"`. +- **Lookups.** `find(kind, name, value)` is the first live widget, by id, of that kind whose + property equals `value`, compared against the escaped form; `all(kind)` lists them in creation + order; `prop(id, name)` is the escaped value, or empty when never set, and `hasProp(id, name)` + tells the two apart; `exists(id)`; `idOf(widget)`, `kindOf(id)`, `children(id)` and + `widget(id)`. `prop`, `hasProp`, `kindOf`, `children` and `widget` throw `std::out_of_range` + for an id that names no live widget, where `exists` returns false; `idOf` throws it for a widget + that is not one of this backend's live widgets. +- **Interaction helpers** act as the user would: `click`, `edit`, `submit`, `moveCursor`, `toggle`, + `choose`, `chooseIndex`, `collapse`, `dismiss`, `selectRows`, `activateRow`, `setDateTime`, `slide`, + `pick` and `drag`. A helper first changes what the native widget would change by itself — the + field's text and cursor, the box's check mark, the marked option or rows, the highlighted tab, the + collapsed state, a dismissed dialog's `open`, the value or path — and then calls the stored + handler. It calls a copy of the handler, so a handler may + destroy its own widget. + - A widget the user cannot reach ignores every helper: one that is hidden or disabled, or inside a + container that is hidden, disabled or collapsed, or a closed dialog or anything inside one. + `activateRow` asks that of the row, `drag` of both widgets. + - A helper used on a kind it does not apply to throws `std::logic_error`, and so does an action + no user could take: choosing a key the Select does not offer, collapsing a panel that is not + collapsible, selecting a key with no row, a row twice, any row in a `None` table or more than + one in a `Single` one, activating a key with no row, dragging a widget onto itself. That is a + mistake in the test. + - `drag(source, target)` returns true when the source has a drag key, the target a drop handler, + and the target's predicate accepted the key; then `onDrop` has run. An empty predicate accepts + nothing here, so a drop that lands on a mounted target without `accepts` shows the mount's + accept-everything default at work. + +- **Text cursor.** A TextInput keeps a cursor, a byte offset into its text, read with `cursor(id)`. + `edit` and `submit` leave it at the end of the new text, `moveCursor(id, position)` moves it + (clamped to the text), `setText` with the text the field shows leaves it alone, and `setText` with + another text puts it at the end. + +`tests/test_ui_recording_backend.cpp` tests the log, the dump, the lookups and the helpers. + +## The conformance suite + +`conformanceCases()` returns every case, in a fixed order, as `ConformanceCase{name, run}`; each +`run` takes a `ConformanceProbe` and returns `nullopt` for a pass or the failure message. A +backend's test suite runs every case on a fresh probe. `RecordingBackend` passes them in +`tests/test_ui_conformance.cpp`. + +### The probe + +A backend author implements one `ConformanceProbe`. Its driving operations act as a user would, +through the backend's own input path where it has one, and each may run pending work before and +after it; its reading operations report what the widget shows now. + +| Operation | Meaning | +|---|---| +| `backend()` | The backend under test | +| `runtime()` | The runtime the cases' signals and mounts belong to | +| `settle()` | Runs posted flushes and pending native events until nothing is left | +| `textOf(widget)` | A Text's or TextInput's text, a Button's or Checkbox's label, the label of the option a Select marks (empty when it marks none) | +| `visibleOf(widget)` | The widget's own flag as its last `setVisible` left it: a hidden ancestor, a collapsed panel or a closed dialog around it does not make it false | +| `enabledOf(widget)` | The widget's own flag as its last `setEnabled` left it | +| `childCount(container)` | How many children a container has | +| `childAt(container, index)` | A child, or null past the end; a case drives it as it drives a root | +| `click(widget)` | Activates a button | +| `type(widget, text)` | Replaces a field's text as typing would: the field is cleared first, so it ends up holding exactly `text`; `onChange` may see intermediate texts | +| `drag(source, target)` | Drags one widget onto another | +| `dismiss(dialog)` | Dismisses a dialog (Esc on the TUI) | +| `selectRows(table, rows)` | Selects rows by position, in the order the user picks them — at least one, and only one in a `Single` table — so the user's selection is exactly those rows | +| `selectedRows(table)` | The positions of the rows the table shows as selected, ascending | +| `moveCursor(field, position)` | Moves a text field's cursor as the arrow keys or a click would, without changing the text | +| `cursorOf(field)` | Where a text field's cursor is, as a byte offset into its text | + +A case reaches widgets only through `Mounted::root()`, `childAt` and further roots it mounts, or +through widgets it makes with `backend()` itself, so a probe needs no lookup by name. + +### The cases + +Each case is named by the rule it checks: + +1. A Text shows its constant text. +2. A bound Text updates once per batch, and not for an equal write. +3. `visible` and `enabled` follow their bindings. +4. A click runs the button's action once, and what it wrote reaches the screen. +5. Typing reaches `onChange`, and a value set by the application is shown and not echoed. +6. A Switch shows the selected case, and nothing for a key without one. +7. Tabs mount a page on its first selection, keep it, and show only the selected one. +8. A ForEach updates a kept row in place: the same widget, its view not built again. +9. A ForEach follows inserts, removals and reorders. +10. A moved widget keeps its native state: the text a user typed into it. +11. A Dialog holds its content only while open. +12. A new Dialog is closed, and a closed Dialog takes no input — neither a dismissal nor a click on + a child. +13. A widget inside a hidden, disabled or collapsed container takes no click, typing, drag or row + selection, with each gate at least two levels above the widget, and takes them again once the + gate is lifted. +14. A setter given the text a field already shows keeps the cursor where the user left it. +15. A text field shows what the application makes of an edit: a refused edit snaps back, an + upper-cased one shows the upper case. +16. A dismissal the application refuses leaves the dialog open, with its content reachable. +17. No setter calls a handler: values, options, menu items, rows, selections, tab indices, opening + and collapsing, including a Select re-resolving its request after new options and a Table + re-marking rows that arrive or go. +18. A Select marks the requested key whenever its options contain it, whichever arrives first, and + remembers it across options that lack it. +19. A Table in `Multiple` mode reports a user's change as exactly the existing rows selected; the + application writes it back to the selection slot, as a controlled table needs. +20. A Table shows a user's selection with no selection bound. +21. A hidden Table cell keeps its column in its row. +22. No handler runs after its widget was destroyed. +23. A handler may destroy its own widget; the backend keeps the running handler alive and stays + usable afterwards. +24. A drag onto an accepting target delivers the key once. +25. A drag onto a refusing target delivers nothing. +26. A drop target without `accepts` takes every key. + +### What the probe cannot reach + +The probe drives clicks, typing, drags, dismissals and row selection only, so some rules of this +contract are each backend's own tests to make: + +- that a widget the user cannot reach takes no `choose`, `toggle` or row activation, and that a + drag onto an unreachable target delivers nothing; +- that rules 22 and 23 hold for handler kinds other than a click; +- that a hidden Table cell's neighbours are drawn under their own headers — rule 21 checks the + row's structure only; +- that a controlled input other than a text field and a dialog snaps back — `RecordingBackend`'s own + tests drive every kind. + +Rule 22 is observable only for a backend whose input can be delivered later than the driving call, +such as a queued connection; a probe whose `click` delivers before it returns passes it without +exercising it. + +## Design decisions + +| Decision | Why | +|---|---| +| Typed factories, one interface per kind | A backend implements exactly what each node needs; a missing setter is a compile error, not a runtime lookup. | +| Setters never call handlers | Two-way bindings (a field bound to the signal its `onChange` writes) cannot loop, on any toolkit. | +| A setter with the shown value changes nothing | A controlled input is re-asserted after every edit; a cursor or a composition that jumped on each would make typing impossible. | +| Defaults stated by the contract | The mount skips the setters that would only restate them, which keeps logs and native call counts to what a node actually says. | +| Selection kept as a request, marked against what exists | The value and the list it names come from different bindings, in either order; neither order may lose the selection. | +| The backend keeps a running handler alive | A button that closes its own dialog or removes its own row is ordinary UI, not an edge case. | +| A recording backend as the reference | Every mount behaviour is asserted as a log or a dump without a toolkit, and the real backends are held to the same cases through the probe. | diff --git a/docs/spec/ui/frontend.md b/docs/spec/ui/frontend.md new file mode 100644 index 000000000..361d77fd1 --- /dev/null +++ b/docs/spec/ui/frontend.md @@ -0,0 +1,169 @@ +# Frontend seam — design + +Design spec for `include/morph/ui/frontend.hpp`: how the generic client runs an application's screens on +whichever frontend `main` picks at runtime. A frontend owns an event loop, a reactive runtime and a renderer; it +runs whatever an `AppSource` opens, and nothing above the seam names a toolkit. + +Read this before writing a client's `main`, an `AppSource`, or a frontend. The view a frontend mounts is +[`view_tree.md`](view_tree.md); the widgets its renderer makes are [`backend_contract.md`](backend_contract.md). + +## Contents + +- [Shape](#shape) +- [`AppSource`](#appsource) +- [`runApp`](#runapp) +- [What `run` guarantees](#what-run-guarantees) +- [Choosing a frontend](#choosing-a-frontend) +- [Design decisions](#design-decisions) + +## Shape + +```cpp +int main(int argc, char** argv) { + std::vector args{argv, argv + argc}; + auto source = makeSource(args); // remote (--server) or local (--app), the client's own code + std::vector built{qt_quick::frontendOption(), tui::frontendOption()}; + auto frontend = ui::selectFrontend(built, args); // removes the --ui arguments it used + if (!frontend) { std::cerr << frontend.error().message() << '\n'; return 2; } + return (*frontend)->run(*source); +} +``` + +- `ui::AppContext` is what a frontend lends the source it opens and the screens it mounts. Everything it lends + outlives both: a frontend destroys the screens and the source's connections, through `runApp`, before its + runtime. + +| Member | Meaning | +|---|---| +| `runtime()` | The reactive runtime the screens are mounted in; owned by the frontend | +| `executor()` | The runtime's owner — the same executor as `runtime().owner()` — and the callback executor of every bridge handler the source and the screens make | +| `scheduler()` | Timers that fire on the owner executor (`ui::Scheduler` is `reactive::Scheduler`, `ui::TimerHandle` is `reactive::TimerHandle`); a `Query`'s `refreshEvery` and `debounce` run on it. It honours every guarantee of [the `Scheduler` contract](../reactive/control.md#the-scheduler-contract) | +| `ioLoop()` | The I/O loop the frontend runs on, for sockets and timers that share its one thread; null when the frontend has none (Qt Quick) | +| `quit(exitCode = 0)` | Makes `run` return `exitCode` once the current event has been handled. Called before the loop runs — from `open`, or from mounting the shell — it still ends the run once the loop starts | + +- `ui::Frontend` is a frontend: `name()`, the name `selectFrontend` matches, and `run(AppSource&)`. +- `ui::FrontendOption{name, usable, make}` is one frontend a binary was built with: the name `--ui=` and + `MORPH_UI` match, whether it can run here (a terminal on stdin, a display; empty counts as usable), and the + function that makes it. + +## `AppSource` + +Where the screens and the models are. `main` builds one from its arguments; the frontend opens it. + +- **Remote**: connect, handshake, catalog, verified bundle (UI document design, §12). +- **Local**: the application's models and screens linked in-process, dispatching to `LocalBackend`. + +| Member | Meaning | +|---|---| +| `open(ctx, ready)` | Connects (or sets up the local models), signs the user in natively when the application needs it — on the owner, before the catalog is requested — and calls `ready` once, on the owner, now or later, with a `Bundle` or a `ConnectError` | +| `switchBackend(backend, done)` | Replaces the dispatch target under the mounted screens: `Bridge::switchBackend` posted to the owner. `done` is called once, on the owner, when the switch committed, or with why not; a failed switch leaves the old backend | +| `close()` | Releases what `open` made — connections, bridges, handlers — and drops a `ready` not yet delivered. `runApp` calls it once the screens are gone and before the runtime is; the source may be opened again | + +Every call is made on the context's owner, and every callback is called there. + +- `ui::Bundle` carries the application's id, the manifest's SHA-256 digest, and every document — the screens, the + app shell, and the app document (`app`) when there is one — by id, as verified JSON text. The document model and + the interpreter read it; the seam only carries it from the source to the shell. +- `ui::ConnectError{kind, message}`: `Unreachable`, `NoUiService` (the peer answers `unknown envelope kind: + ui-hello`), `Unsupported` (a vocabulary the client does not speak, named in the message), `Unverified` (a digest + mismatch), `Refused` (a sign-in, a licence or an authoriser refused). The frontend shows it before any screen + exists. +- `ui::Backend{name}` is the application's opaque handle for one of its backends; its name (`primary`, `local`, + `remote`) is what `{"env": "backend"}` reads. + +## `runApp` + +`ui::runApp(context, source, mountShell, loop)` runs one source inside a frontend, and every frontend's `run` +calls it: + +1. It calls `source.open(context, ready)`. +2. `ready`, whenever it comes, calls `mountShell(context, opened)` with the bundle or the connect error, and keeps + the `MountedShell` it returns (null mounts nothing). A local source delivers during `open`; a remote one + delivers later, from the loop. +3. It calls `loop`, which runs the frontend's event loop and returns the exit code once `AppContext::quit` ends + it. +4. It destroys the shell, then calls `source.close()`, and returns what `loop` returned. + +The order is fixed here so that every frontend keeps it. The shell goes first, while the source's connections its +screens use are still open; the source is closed next, while the runtime and the executor its handlers were made +with are still alive; the frontend destroys those after `runApp` returns. The order holds whether `loop` returns or +throws. + +| Case | What happens | +|---|---| +| A second `ready` | Ignored | +| A `ready` during `close`, or after `runApp` returned | Mounts nothing: the run counts as opened from the teardown on, and the callback holds the run weakly | +| `mountShell` throws | The context quits with exit code 1; the exception leaves `runApp` after the teardown | +| `open`, `loop` or `close` throws | The teardown still runs; the first exception leaves `runApp` | +| An empty `mountShell` or `loop` | `std::invalid_argument`, before the source is opened | + +`tests/test_ui_frontend.cpp` pins the order with a frontend whose context, and with it the runtime, is a local of +`run`, a source that records `open` and `close`, and a shell that records its unmounting: `open`, mount, loop, +shell, close, runtime — after a loop that returns, and after one that throws. + +## What `run` guarantees + +1. The runtime and its owner executor exist before the source is opened. +2. The shell is mounted once, from the source's `ready`. +3. The loop runs until `AppContext::quit`; `run` returns the exit code passed to it. A frontend may also end the + run on its own — end of input, an interrupt — with a code it documents. +4. Teardown is the reverse, in this order: the screens, then the source's connections, then the runtime, then the + loop and the toolkit. A binding or a handler never outlives what it points at. + +A frontend keeps that order by leaving the first two to `runApp` and by declaring its members or locals in +construction order. A host with its own shell mounts screens through `ScreenHost` instead (application hosting +design, §4), which lands with hosting. + +## Choosing a frontend + +`selectFrontend(built, args, env = processEnvironment())` picks from the frontends a binary was built with, listed +in order of preference: + +1. the last `--ui=` or `--ui ` on the command line; +2. else `MORPH_UI`, when it is set and non-empty; +3. else the first option, in the order given, whose `usable()` holds; an empty `usable` counts as usable. + +It returns `std::expected, FrontendError>`. + +Command-line parsing: + +- `args` may hold the program name first; it is never a `--ui` flag. `--` ends option parsing: a `--ui` after it is + an operand, not a choice. +- After `--ui`, the next argument is the name unless it starts with `--`. A `--ui` with no name — followed by such + an argument, by `--`, or by nothing — is an error whatever comes after it: parsing stops there. +- Otherwise the last `--ui` wins, so an empty `--ui=` followed by a named one takes the named one, and a named one + followed by an empty `--ui=` is an error. +- Names match exactly, case included. +- On success, every `--ui=`, `--ui` and the name after it that parsing read is removed from `args`, and the + rest keep their order, for the caller to pass on (to a toolkit, or its own option parser). On an error `args` is + left as it was. + +A frontend named on the command line or in `MORPH_UI` is used even when its `usable()` is false: the user asked for +it. An empty `env` is never read. A `FrontendError` carries its `kind`, the `requested` name, and the `built` names; +`message()` joins the built names with a comma and a space, or says `none`: + +| Kind | Message | +|---|---| +| `UnknownName` | `morph::ui: no frontend named '' is built (built: qt, tui)` | +| `MissingName` | `morph::ui: --ui needs a frontend name (built: qt, tui)` | +| `NoneUsable` | `morph::ui: no built frontend is usable here (built: qt, tui)` | +| `NotMade` (no `make`, or it returned null) | `morph::ui: frontend '' could not be made` | + +`EnvironmentReader` (`std::function(std::string_view)>`) is the seam a test replaces. +`processEnvironment()` reads the process environment through `std::getenv`, which races only with a concurrent +`setenv`; selection runs in `main` before any thread exists. MSVC deprecates `getenv` (C4996), and clang-cl +reports the same deprecation as `-Wdeprecated-declarations`; both are suppressed at that one call, so a consumer +built with warnings as errors does not fail on a morph header. + +## Design decisions + +| Decision | Why | +|---|---| +| The frontend is injected, the source is data the frontend opens | Client code never names a toolkit; one binary carries every frontend it was built with, and the same source runs on each. | +| The frontend owns the runtime and the loop | The runtime's owner must be the loop's thread, and only the frontend knows which thread that is. | +| `AppSource::close` | The source is `main`'s and outlives `run`, but its connections are made with the run's executor; `run` must be able to end them before the runtime goes. | +| `runApp` fixes the teardown order | Every frontend gets "shell, then connections, then runtime" from one function instead of re-deriving it. | +| The shell factory receives the connect error too | Showing why a source did not open is the frontend's view, mounted like any other. | +| Command line, then environment, then the first usable option | An explicit request wins; a default that works where the binary runs needs no request at all. | +| A named frontend is used even when not usable | `usable()` is a guess made for the default; the user's explicit choice is not second-guessed. | +| Errors are values that name the built frontends | `main` decides how to report them; "not built" and "misspelled" look the same to the user, and the list answers both. | diff --git a/docs/spec/ui/view_tree.md b/docs/spec/ui/view_tree.md new file mode 100644 index 000000000..3159fc0b6 --- /dev/null +++ b/docs/spec/ui/view_tree.md @@ -0,0 +1,431 @@ +# View tree and mount — design + +Design spec for `morph::ui`'s view tree (`include/morph/ui/view.hpp`) and its mount +(`include/morph/ui/mount.hpp`): a screen is described once, as immutable nodes whose properties are +constants or slots — reactive values with a stable id — and `Mounted` turns that description into +retained widgets on any backend and keeps them current. The document interpreter builds the tree from +a UI document, one node per document node; C++ code that uses the reactive core directly (the forms +engine, tests, local tools) builds it with the same builders. A renderer that generates code instead +of driving widgets (Qt Quick) reads the same tree and the same slots, and does not use `Mounted`. + +Read this before writing a view, and before changing what the mount does with a node. The widgets +the mount drives are [`backend_contract.md`](backend_contract.md); the frontend around a mount is +[`frontend.md`](frontend.md); the reactive graph underneath is +[`../reactive/signals.md`](../reactive/signals.md). + +## Contents + +- [Nodes](#nodes) +- [Props](#props) +- [The palette](#the-palette) +- [Keyed collections](#keyed-collections) +- [Mount](#mount) +- [Controlled inputs](#controlled-inputs) +- [Failures and misuse](#failures-and-misuse) +- [Design decisions](#design-decisions) +- [Out of scope](#out-of-scope) + +## Nodes + +- A node is an aggregate (`ui::Text`, `ui::Column`, …) wrapped by its builder (`ui::text`, + `ui::column`, …) into `ui::Node`, a `std::shared_ptr`. `NodeData::kind` is a + `std::variant` over every node type. Nodes are immutable and shared: a subtree may appear in + several places, and mounting never changes it. +- Nodes are not templated on a message type. Events are command sinks: `ui::Action` + (`std::function`) or typed callbacks — `onChange(std::string)`, `onToggle(bool)`, + `onSelect(Key)` and the like. The interpreter makes each one run a command list. +- Strings are UTF-8; a backend converts. +- `ui::Key` is `std::variant`. It identifies Switch cases, Select + options, ForEach and Table rows and drag payloads. It is never a floating-point number: morph ids + can exceed 2^53. The integer `7` and the string `"7"` are different keys. +- Every node carries `Common`: + +| Field | Type | Default | Meaning | +|---|---|---|---| +| `visible` | `Prop` | `true` | Shown | +| `enabled` | `Prop` | `true` | Accepts input | +| `layout` | `LayoutHints{width, height}` | content × content | Set once. `Sizing::content()`, `fixed(units)` or `stretch(weight)` per dimension, in backend units (a character cell on the TUI) | +| `dragKey` | `Prop>` | `nullopt` | Engaged: the widget can be dragged, and a drop delivers this key | +| `accepts` | `std::function` | empty | Which keys a drop target takes; empty takes every key. Used only when `onDrop` is set | +| `onDrop` | `std::function` | empty | Set: the widget is a drop target, called with the dropped key | + +## Props + +`Prop` holds a constant or a slot: + +| Constructed from | Is | At mount | +|---|---|---| +| nothing | the value-initialised constant | as any constant | +| a non-callable value `U` with `detail::ConstantConvertible` | a constant | the setter is called once; no reactive node is made | +| a callable taking no arguments and returning something convertible to `T` — a lambda, a function pointer, a `std::function` | a slot without an id | an equality-gated `Computed` plus an `Effect` that calls one setter; a Tabs index and a Dialog's `open` are an `Effect` alone ([why](#switch-tabs-dialog)) | +| `ui::slot(id, read)`, a `SlotBinding{id, read}` | a slot with that id | as a slot without an id | + +- **A slot is a reactive value with a stable id.** `ui::SlotId` is a `std::uint32_t`; whoever builds + the tree assigns it. The interpreter numbers every bound property of a document, and a renderer that + generates code names the value it reads after it (`v.s17`). `slotId()` returns the id, or `nullopt` + for a constant and for a slot made from a bare callable. `Mounted` does not read ids. + +- **A callable is never a constant**, even when it converts to `T`: a captureless lambda converts + to a function pointer and that to `bool`, so without the rule `Prop{[] { return false; }}` + would be the constant `true`. +- **A constant may not silently change the kind of value.** `detail::ConstantConvertible` admits an + implicit conversion except two that compile and then show something else: a pointer, a string + literal included, to `bool` (`Prop{"false"}` would be `true`), and a floating-point value + to an integral type (`Prop{2.9}` would be `2`). Other narrowing is kept: + `Prop count = 0` is the ordinary way to write a count. +- **An empty binding is refused where it is written**: an empty `std::function` or a null function + pointer throws `std::invalid_argument` from the constructor, not when a mount first calls it. +- **A braced list cannot initialise a `Prop`**, because the constant constructor deduces its + argument's type. Name the type: `.options = std::vector{{…}, {…}}`. +- `isBound()`, `constant()`, `binding()` (the slot's read) and `slotId()` read the prop; + `evaluate()` returns the constant or reads the slot once. + +## The palette + +The node kinds are the base palette of the UI document design (§9): text, button, text input, +checkbox, select, menu, column, row, grid, spacer, panel, scroll, switch, tabs, dialog, busy, +forEach, table, date-time input, slider and file picker, with the properties a widget needs to show +and edit them. The document's further kinds (banner, badge, progress, …, boundary), its extra common +properties (`a11y`, `testId`, `tooltip`, `keys`, `autofocus`, `surface`), its input decorations +(`readonly`, `required`, `errors`, `stale`) and richer menu items join the tree, and `IViewBackend`, +with the interpreter that maps them; until then the interpreter lowers a further kind to its fallback +subtree of base kinds. + +| Node | Builder | Props and callbacks | +|---|---|---| +| `Text` | `text` | `text`, `role` (`TextRole{Normal, Muted, Heading, Error, Success}`) | +| `Button` | `button` | `label`, `onClick` | +| `TextInput` | `textInput` | `value`, `onChange` (the whole text after each user edit), `onSubmit`, `placeholder`, `mode` (`TextInputMode{SingleLine, Multiline, Password}`, set once) | +| `Checkbox` | `checkbox` | `label`, `checked`, `onToggle` | +| `Select` | `select` | `options` (`std::vector`), `selected` (`std::optional`; a key outside the options marks none), `onSelect`, `style` (`SelectStyle{Dropdown, Radio}`, set once) | +| `Menu` | `menu` | `items` (`MenuItem{label, onSelect}`: each label beside its handler, so there are no parallel lists; the list is set once, each label may be bound) | +| `Column`, `Row` | `column`, `row` | `children` (a null child is skipped), `gap` (set once) | +| `Grid` | `grid` | `columns`, `cells` (`GridCell{node, span}`; a null node leaves no cell), `gap` — all set once | +| `Spacer` | `spacer` | none | +| `Panel` | `panel` | `title`, `padding` (set once), `child`, `collapsible` (set once), `collapsed`, `onToggle` — a collapsible panel is an accordion section | +| `Scroll` | `scroll` | `child`, `axis` (`Axis{Vertical, Horizontal}`, set once) | +| `Switch` | `switchOf`, `switchOn` | `selector` (`Prop`), `cases` (`SwitchCase{key, node}`; the first case with the key wins), `fallback` | +| `Tabs` | `tabs` | `tabs` (`Tab{label, node}`, set once), `selected` (`Prop`), `onSelect` | +| `Dialog` | `dialog` | `open`, `title`, `child`, `onDismiss` | +| `Busy` | `busy` | `active`, `label` | +| `ForEach` | `forEach` | rows, key function, row view, `axis`, `gap` ([below](#keyed-collections)) | +| `Table` | `table` | `columns` (`TableColumn{label, width}`, set once), rows, key function, cells, and `TableOptions{selectionMode, selection, onSelectionChange, onActivate, common}` ([below](#keyed-collections)) | +| `DateTimeInput` | `dateTimeInput` | `value` (`std::optional`; `nullopt` or an empty `Timestamp` shows an empty field), `onChange` (`nullopt` for a cleared field), `mode` (`DateMode{Date, DateTime}`), `offsetMinutes` (the display zone's offset from UTC) — the last two set once | +| `Slider` | `slider` | `value` (`std::int64_t`), `minimum`, `maximum`, `step` (the three set once), `onChange` | +| `FilePicker` | `filePicker` | `path`, `mode` (`FilePickerMode{Open, Save}`, set once), `onPicked` | + +The `Prop` fields — the ones a binding may drive — are `Common`'s `visible`, `enabled` and +`dragKey`, and: Text `text` and `role`; Button `label`; TextInput `value` and `placeholder`; Checkbox +`label` and `checked`; Select `options` and `selected`; a MenuItem's `label`; Panel `title` and +`collapsed`; Switch `selector`; Tabs `selected`; Dialog `open` and `title`; Busy `active` and +`label`; Table `selection`; DateTimeInput `value`; Slider `value`; FilePicker `path`. Every other +field is fixed when the node is built. + +The builder for a Switch is `switchOf` because `switch` is a keyword. `switchOn(selector, cases, +fallback)` is the enumeration form: `selector` is a `std::function`, `cases` pairs enumerators +with nodes, and each enumerator becomes the `int64` key of its case. An unsigned enumerator above +`INT64_MAX` wraps to a negative key; distinct enumerators still give distinct keys. An empty +selector throws `std::invalid_argument` from `switchOn`. + +## Keyed collections + +`forEach(rows, keyOf, rowView, axis, gap)` mounts one widget per row and keeps it while its +key stays: + +- `rows` is a binding, `std::function()>`, so a `Query`'s `value()` works; every + signal it reads is a dependency of the ForEach. The overload taking a + `Signal> const&` reads that signal; it must outlive every mount of the node. +- `keyOf` gives a row's `Key`. `rowView` builds a row's view **once**, from a + `Signal const&` that the mount keeps equal to the row. The view reads that signal inside + bindings: what it reads directly while it is built is read once, and is a dependency of nothing. + A null view mounts no widget for its row; the other rows keep their order. +- `RowT` is copyable. With an `operator==`, a kept row whose new entry is equal notifies nobody; + without one, every new snapshot notifies every kept row, and only the row's own equality-gated + bindings keep the setters quiet. +- An empty `rows`, `keyOf` or `rowView` throws `std::invalid_argument` from `forEach`. + +`table(columns, rows, keyOf, cells, options)` is the same machinery. `rows` is a binding; +`cells` builds a row's cells once, from the row's signal. The row view is a `Row` whose children +are those cells, so each row is a horizontal stack of exactly one cell per column. A row whose +cells are not one non-null node per column would shift every column after it, so it is refused as +a row that fails to mount is ([below](#failures-and-misuse)). An empty `rows`, `keyOf` or `cells` +throws `std::invalid_argument` from `table`. + +A Table's selection is by key. `TableOptions::selection` is the requested keys; a key whose row is +not there yet selects nothing until such a row appears. `onSelectionChange` reports the keys of the +rows the user selected, and `onActivate` the key of a row the user activated (Enter or a double +click). How the widget keeps and shows the selection is +[`backend_contract.md`, "Selection by key"](backend_contract.md#selection-by-key). + +The row type is erased behind `detail::ForEachModel`. Each mount opens a `detail::ForEachSession` +from it, which keeps the latest snapshot of the rows (`pull()`, which returns their keys) and makes +one `detail::RowSlot` per mounted row (`makeRow(index)`). A slot owns the row's `Signal` and +the view built from it; `assign(index)` sets the signal to an entry of the latest snapshot. A +`ForEach` or `Table` whose model is null shows no rows. + +## Mount + +`ui::Mounted(runtime, backend, root, parent = nullptr, depth = 0)` builds the tree once into a +`reactive::Scope` it owns, at scope depth `depth`, appending the root widget to `parent`, or making a +backend root when `parent` is null. It holds `root` as long as it lives; a null root throws `std::invalid_argument`. +`root()` is the root node's widget, valid as long as the `Mounted`. It is neither copyable nor +movable: bindings point into it. + +The build is untracked, so a `Mounted` constructed inside an Effect does not subscribe that Effect +to anything the mount reads. + +The runtime, the backend and every signal a binding reads outlive the `Mounted`, and the runtime's +owner constructs and destroys it. A mount must run to completion: nothing it calls — a backend +factory or setter, a binding's evaluation — may destroy the `Mounted`, its runtime or its backend. +Widget callbacks are events, not part of a mount, and may ([below](#callbacks)). + +### Per node + +1. The widget is made by its factory, under its parent, with the node's fixed parameters (a + TextInput's mode, a Select's style, a stack's or scroll area's axis, a DateTimeInput's mode and + zone, a FilePicker's mode), and adopted into the scope **first**. +2. `Common`, in this order. `visible` and `enabled` only when bound or `false`; `layout` only when + it is not content × content; `dragKey` only when bound or engaged; the drop handler only when + `onDrop` is set, with an accept-everything predicate when `accepts` is empty. A new widget is + already visible, enabled, content-sized and neither draggable nor a drop target, so a node that + says nothing more costs no call. +3. The kind's own props and callbacks. Every `Prop` is applied, even one that holds its default: a + constant calls its setter once; a binding makes a `Computed` (equality-gated, so an unchanged + result calls nothing) and an `Effect` that calls one setter — except a Tabs index and a + Dialog's `open`, whose `Effect` reads the binding itself ([below](#switch-tabs-dialog)). Fields + set once (a stack's gap, a grid's columns and gap, a panel's padding and collapsibility, a + slider's range, a Tabs' labels, a Table's columns and selection mode) are passed to their + setters once. Each callback is handed over once, as an empty function when the node has none — + except a Menu's `setOnActivate`, which always gets the mount's dispatcher over its items. Then + the exceptions: + - a Grid child's span is set only when it is not 1; + - a Panel's `collapsed` and `onToggle` are applied only when it is collapsible; + - a Menu sends all its labels through one `setItems`; when any label is bound, one binding over + all of them sends every label again whenever one changes. Choosing an entry runs that entry's + `onSelect`; an index past the last entry runs nothing. +4. Its children, appended in order. + +Destroying the scope destroys in reverse creation order: every binding before the widget it +drives, every child before its parent. + +### Owner order + +The reactive runtime runs queued Effects shallowest scope first. The tree's own bindings are at the +depth the `Mounted` was given; a Switch case, a Tabs page, a Dialog's content and every ForEach or +Table row are in a scope one level deeper than the binding that mounts them, and their nested +content one level deeper again. So an Effect that takes content away runs before that content's own +bindings in the same flush, whichever was made first, and a binding never evaluates against state +its owner is removing. A mount placed under another scope — a screen's, a row's — passes that +scope's depth plus one, so the outer scope's Effects run first too. + +### Callbacks + +Every callback the mount hands a widget, a drop's `accepts` predicate included, runs: + +- **inside `Runtime::widgetEvent`**: one batch, and a flush that falls due while it runs — from a + nested event loop pumping the owner inside the callback — waits until the outermost widget event + returns and is posted then ([`../reactive/signals.md`](../reactive/signals.md)). Flushes are always + posted, so a write inside a handler never destroys the handler's own widget while it runs, even + from a handler that pumps the loop itself. +- **untracked**: should a backend call a handler from inside a setter, and so from inside the + binding Effect that called the setter, that Effect does not subscribe to what the handler reads. +- **with its exceptions contained**: one the callback throws is reported + (`ui::detail::site::kCallbackThrew`) and goes no further. The backend's call returns normally, a + predicate that threw refuses the drop, and what the callback wrote before throwing still flushes + when the batch closes. + +A callback may destroy what holds its own widget — remove its row, switch its case away, close +its dialog, or destroy the whole `Mounted`. The write only queues a flush, which runs after the +callback has returned; a callback that destroys the `Mounted` directly is safe as long as the +backend keeps the running handler alive, which +[the backend contract requires](backend_contract.md#handlers). + +### Switch, Tabs, Dialog + +Content a binding mounts — a Switch's case, a Tabs page, a Dialog's content — is built untracked, +in a `Scope` of its own, and the kind takes that scope only once the content is complete. Content +is therefore all or nothing ([below](#failures-and-misuse)), the binding's Effect depends on its own +prop alone, and destroying the content destroys its bindings before its widgets. + +- **Switch** is a slot widget. A new key first tears the old case down, newest first, and then + mounts the case with that key, or the fallback when no case has it. A key with neither shows + nothing. The selector's `Computed` is equality-gated, so an unchanged key never remounts, and + neither does a key that changes and changes back before the flush runs. +- **Tabs** passes its labels to the tab bar once. It mounts a page the first time its tab is + selected, in a page slot under the bar, and keeps it: selecting another tab hides the old page's + slot and shows the new one, so a page's widgets keep their focus, scroll position and half-typed + text. A new page is complete before the old one is hidden. An index past the last tab hides the + shown page and shows none; a null page is an empty slot. `setSelected` follows the index every + time. The pages are destroyed newest-shown first, after the binding that shows them and before + the tab bar. +- **Dialog** mounts its content when `open` becomes true and then opens the overlay; when `open` + becomes false it destroys the content and then closes the overlay. A closed dialog holds no + content. + +A bound Tabs index and a bound Dialog `open` are not equality-gated: their `Effect` reads the +binding itself rather than a `Computed`. When content fails to mount, these two kinds tell the +application through a callback — `onSelect`, `onDismiss` ([below](#content-that-fails)) — whose +write lands inside the binding's own Effect run. An Effect's own write never re-runs it, and it +stays subscribed to the signals the binding read, so the application's next change — picking the +failed tab, opening the dialog — is seen as the change it is. A `Computed` in between would have +cached the failed value without seeing the write, and would find that next change equal to it. +The cost: when something the binding reads changes but its result does not, the Effect runs +anyway. While the value names mounted content, a Tabs only re-sends `setSelected` for the page +already shown, and a Dialog only re-sends `setOpen`; the backend contract allows a setter with an +unchanged value. While it names content that failed — a Tabs index on a page that failed, a +Dialog `open` still true over content that failed — that run tries the mount again +([below](#content-that-fails)). + +### ForEach and Table rows + +The container is a stack along the ForEach's `axis` with its `gap`, or the Table widget, which +gets its columns and selection mode first. The mount then adopts the model, opens a session, +makes the row list and makes one `Effect`, in that order, so they are destroyed in reverse: the +Effect stops first, then the rows go — last first, as a container's children are — and the +session outlives every slot that points into its snapshot. The Effect pulls the keys, tracked (the +rows binding is its only source), and reconciles untracked, so what a row view reads while it is +built, or what a kept row's update touches, is not a dependency of the whole list: + +1. The first occurrence of each key is kept; every later duplicate is reported + (`ui::detail::site::kDuplicateKey`) and refused. The first occurrence decides the row's value + and place. +2. A row whose key is gone is unmounted, last first. A row whose key stays is updated in place: its slot's + signal is set to the new snapshot entry, and the row's own bindings run later in the same + flush. Its widget, focus and selection survive. +3. A new key gets a row `Scope` — the slot adopted first, then the row's widgets — and its widget + is appended to the container. A Table then calls `setRowKey(rowWidget, key)`. +4. The container is brought into snapshot order with `ContainerWidget::moveChild`. The widgets on + one longest run already in order stay; every other widget moves once, directly before its + successor in the target order, which is n minus the length of a longest increasing run: the + fewest moves possible. An append or a removal moves nothing; an insert at the front, or moving + one row, is one move; reversing three rows is two. + +A Table's `selection` binding is made after its rows. The widget marks a requested key whenever +its row arrives, so the order only shapes the log: the first `setSelection` already finds the +rows. + +## Controlled inputs + +The document is the single source of truth: a widget shows what its slot says, and a user's action +is a request the application may refuse. An input whose value is a slot is controlled: + +| Kind | Value | Request | +|---|---|---| +| TextInput | `value` | `onChange`, `onSubmit` (the text) | +| Checkbox | `checked` | `onToggle` | +| Select | `selected` | `onSelect` (the key) | +| Slider | `value` | `onChange` | +| DateTimeInput | `value` | `onChange` | +| FilePicker | `path` | `onPicked` | +| Panel (collapsible) | `collapsed` | `onToggle` | +| Tabs | `selected` | `onSelect` (the bar's highlight) | +| Dialog | `open` | `onDismiss` (a request to close) | +| Table | `selection` | `onSelectionChange` | + +The widget shows the request already — that is what the toolkit does when the user acts. The mount +records what each controlled widget shows: the last value it set, or the user's request since. After +the application's handler, the event posts **one turn** to the owner, behind the flush the event +caused; that turn reads the slot untracked and, when the widget shows anything else, calls the +setter with the slot's value. So: + +- a request the application refuses snaps back after the turn; +- an accepted request costs no setter call at all: the binding sees the value the widget already + shows and sends nothing; +- a request the application transforms (trims, clamps, upper-cases) shows the raw request until the + flush, then the transform. The cursor rule of the backend contract keeps that one turn harmless for + a text input and an input method. + +A slot without a handler is read-only: the event is installed anyway, and the request snaps back. A +constant is not a slot: the widget keeps what the user did, as a QML literal does once a control is +edited. A Tabs is re-asserted to the page it shows; a Dialog whose content failed to mount stays +closed when the application keeps `open` true, rather than opening empty, and the re-assertion does +not retry the mount. The turn holds its check weakly, so a turn posted for content that is gone by +then does nothing; an event whose handler destroyed its own widget posts no turn. + +## Failures and misuse + +Misuse and failures in this layer are reported through the runtime's owner probe and then refused, +as in [`../reactive/signals.md`, "Misuse"](../reactive/signals.md#misuse-reported-then-refused). A +test that triggers one installs `morph::testing::OwnerProbeRecorder`. + +| What happens | Site | Refusal | +|---|---|---| +| A binding throws, at mount or in a flush | `reactive::detail::site::kEffectThrew` | Its setter is not called, so the widget keeps the last value it was given, or its default. The mount goes on with the rest of the node. The next change of what the binding read evaluates it again. | +| Content a binding mounts throws partway: a Switch case, a Tabs page, a Dialog's content, a row | `reactive::detail::site::kEffectThrew` | Nothing of the content is left behind. A Switch shows nothing, a Tabs keeps the previous page, a Dialog stays closed, a row is left out ([below](#content-that-fails)). | +| A Table row's cells are not exactly one non-null node per column | `reactive::detail::site::kEffectThrew` | The row is left out, as a row that fails to mount is. | +| A ForEach or Table snapshot repeats a key | `ui::detail::site::kDuplicateKey`, once per later occurrence | The later row is skipped; the first decides value and place. | +| A widget callback or a drop's `accepts` throws | `ui::detail::site::kCallbackThrew` | It goes no further; the backend's call returns normally; a predicate that threw refuses the drop; earlier writes still flush. | + +A report repeats on every run that meets the same condition: a snapshot that still repeats a key, +or a row that still fails, is reported again at the next change of the rows. + +Mistakes in the description itself throw `std::invalid_argument` where they are written, not when +the tree is mounted: an empty binding in a `Prop`, an empty selector in `switchOf`'s enumeration +form `switchOn`, an empty function in `forEach` or `table`, and a null root in `Mounted`. + +### Content that fails + +A mount that throws goes no further than the content it was building, and leaves none of it +behind. + +- **A node mounted directly** — the tree outside any content a binding mounts, including content a + *constant* Switch selector, Tabs index or Dialog `open` mounts — throws out of the `Mounted` + constructor. +- **Content a binding mounts** is built in an Effect, also during the constructor, so its failure + is reported as `kEffectThrew` instead. When the mount is tried again depends on the kind. + - A Switch shows nothing for that key: it tore the old case down before mounting the new one, + and an old case shown for a new key would be stale. Its selector is equality-gated, so the + next change of the key mounts again. + - A Tabs keeps the previous page shown and selected, and puts the bar's highlight, which the + user's pick may already have moved, back on it. It then calls its `onSelect` with the page + still shown, as a widget callback, so the application's index follows the widget and selecting + the failed tab again is a change that mounts again. All of this needs a page that was already + shown: when the first page a Tabs mounts fails, there is nothing to restore — `onSelect` is not + called and `setSelected` does not run, so the bar keeps whatever highlight it has. + - A Dialog stays closed, and calls its `onDismiss`, as a widget callback, so the application's + `open` follows the widget and opening the dialog again is a change that mounts again. + - A Tabs index or a Dialog `open` that still names the failed content — a Tabs without + `onSelect`, a first page that failed, a Dialog without `onDismiss`, or a callback that did not + move the application's value — is not gated: every run of its binding tries the mount again, + including a run caused by another signal the binding reads. That is at most one mount attempt + per run, each failure reported again and `onSelect` or `onDismiss` called again. Without such a + run, a Tabs keeps showing the previous page and a Dialog stays closed. An application whose + callback puts the failed value back — posting `open = true` again, say — retries once per + owner turn; how often to retry is that application's policy. + - `onSelect` and `onDismiss` are called last: a widget callback may destroy the `Mounted` + ([Callbacks](#callbacks)), and nothing of the mount is touched after them. A *constant* index or + `open` has no application state to bring in line; its content's failure throws out of the + constructor, and neither callback is called. + - A ForEach or Table row is reported, left out, and mounted again at the next change of the + rows. The other rows are mounted and ordered all the same, and the row list stays equal to the + container's order even when a move throws. Only the first failure of one run is reported. + +A row view that writes the signal its ForEach's rows binding reads directly, while it is being +built, does not make the ForEach run again for that write: the write lands during the ForEach's +own Effect run, which an Effect's write to a signal it reads never re-runs +([`../reactive/signals.md`, "The algorithm"](../reactive/signals.md#the-algorithm)). The written +rows appear at the next change of the rows. + +## Design decisions + +| Decision | Why | +|---|---| +| Fine-grained bindings, no virtual tree and no diff | A change reaches exactly the setter that depends on it; widget identity, focus and selection survive. | +| A constant creates no reactive node | Most props of most screens never change; they cost one setter call and nothing after. | +| A callable is always a binding, and a pointer or a floating-point value is never a lossy constant | Both mistakes compile and then show something else; the type rules make them a compile error or a binding. | +| Content in child scopes, taken only once complete | One mechanism gives "bindings before widgets, children before parents" and all-or-nothing content for a case, a page, a dialog and a row. | +| Content scopes one level deeper than their owner | Owner-first Effect order then holds across mounted content and the scopes a mount is placed under, whatever the creation order. | +| Slots carry an optional id | A code-generating renderer refers to a value by number; a C++ author who builds a tree for `Mounted` alone need not number anything. | +| Controlled inputs re-assert in a posted turn, against what the widget shows | Re-asserting inside the event would show a transform before the flush and fight an input method; comparing with what the widget shows makes an accepted edit free. | +| Lazy, kept Tabs pages | A tab that is never opened costs nothing; a tab left keeps its state. | +| Failed Tabs and Dialog content is reported to the application through `onSelect` / `onDismiss`, over an ungated binding | The application's state then matches what the widget shows, so the user's next pick or opening retries, with no mechanism beyond the callbacks the application already handles. | +| Keyed rows with a longest-run reorder | Keys keep identity across updates; the run keeps a reorder to the fewest moves a backend has to animate or re-lay out. | +| Row construction untracked | A row view that reads a signal directly must not make the whole list a dependent of that signal. | +| Callbacks contained by the mount | A backend need not be exception-safe around a handler, and a failing handler cannot take a native event loop down with it. | +| `switchOf`, not `switch_` | A keyword cannot be a function name; the spelling reads as a phrase at the call site. | + +## Out of scope + +Not in the palette: themes beyond `TextRole`, images, charts, and translation catalogues — a +binding produces translated text. The document's further kinds and properties join with the +interpreter ([The palette](#the-palette)). diff --git a/docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md b/docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md index 7efd365eb..a0d653037 100644 --- a/docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md +++ b/docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md @@ -107,7 +107,9 @@ not meet, and each has a test in §7. `view.hpp` (the view tree), `backend.hpp` (widget interfaces, `IViewBackend`), `mount.hpp`, `frontend.hpp` (§5b: `AppContext`, `AppSource`, `Frontend`, `FrontendOption`, `EnvironmentReader`, `Bundle`, `ConnectError`, `FrontendError`, `Backend`), `host.hpp` (spec 6 §4: `ScreenHost`, - `ScreenHandle`, `ParamMap`, `MountError`), `author.hpp` (the server-side builders of spec 5 §11), + `ScreenHandle`, `ParamMap`, `MountError`; it lands with hosting, because `ParamMap` holds the + document's `Value` of spec 5 §3 and its implementers are the Qt Quick renderer and the hosting + layer), `author.hpp` (the server-side builders of spec 5 §11), `testing/recording_backend.hpp` and `testing/backend_conformance.hpp`. The test kit they need (`StepExecutor`, `OwnerProbeRecorder`) is provided under `include/morph/testing/`, so `RecordingBackend` depends on nothing outside the installed headers. @@ -294,6 +296,14 @@ every property either a constant or a **slot** — a reactive value with a stabl a command sink. Node kinds, their properties and their events are spec 5 §9's palette; `Key` is `std::variant`, never a double. +The view tree lands with spec 5 §9's base kinds (text, button, text input, checkbox, select, menu, +column, row, grid, spacer, panel, scroll, switch, tabs, dialog, busy, forEach, table, date-time +input, slider, file picker) and the properties a widget needs to show and edit them. The further +kinds, the extra common properties (`a11y`, `testId`, `tooltip`, `keys`, `autofocus`, `surface`), +the input decorations (`readonly`, `required`, `errors`, `stale`) and richer menu items each add a +setter or a factory to `IViewBackend`, and join it with the interpreter that maps them or the +renderer that needs them; until then the interpreter lowers a further kind to its fallback. + **Mount:** `ui::Mounted(reactive::Runtime&, IViewBackend&, Node)` builds retained widgets through typed factories, once. @@ -342,6 +352,7 @@ public: virtual ~AppSource() = default; virtual void open(AppContext& ctx, std::function)> ready) = 0; virtual void switchBackend(Backend backend, std::function)> done) = 0; + virtual void close() = 0; // releases what open made; drops a ready not yet delivered }; class Frontend { public: @@ -359,7 +370,9 @@ std::expected, FrontendError> **local** (the application's models and screens linked in-process). The generic client's `main` builds one from its arguments. - `run` builds the runtime and executor first, opens the source, mounts the app shell and drives its - loop until `quit()`; screens and connections are destroyed before the runtime. + loop until `quit()`; screens and connections are destroyed before the runtime. The source is + `main`'s and outlives `run`, so `run` ends its connections with `close()`, after the screens are + unmounted and before the runtime goes; `ui::runApp` fixes that order once for every frontend. - An `AppSource` signs the user in natively when the application needs it (before the catalog is requested, on the owner), and `switchBackend` replaces the dispatch target under mounted screens: it is `Bridge::switchBackend` posted to the owner, after which the interpreter holds the queries on diff --git a/docs/superpowers/specs/2026-10-04-examples-migration-design.md b/docs/superpowers/specs/2026-10-04-examples-migration-design.md index 000c87631..baad429e5 100644 --- a/docs/superpowers/specs/2026-10-04-examples-migration-design.md +++ b/docs/superpowers/specs/2026-10-04-examples-migration-design.md @@ -68,7 +68,10 @@ int main(int argc, char** argv) { #if MORPH_CLIENT_HAS_TUI built.push_back(tui::frontendOption()); #endif - return ui::selectFrontend(built, argc, argv)->run(*source); + std::vector args{argv, argv + argc}; + auto frontend = ui::selectFrontend(built, args); // removes --ui (spec 1 §5b) + if (!frontend) { std::cerr << frontend.error().message() << '\n'; return 2; } + return (*frontend)->run(*source); } ``` diff --git a/tests/test_ui_controlled.cpp b/tests/test_ui_controlled.cpp index 0ad4439d5..21e5e8264 100644 --- a/tests/test_ui_controlled.cpp +++ b/tests/test_ui_controlled.cpp @@ -66,7 +66,7 @@ TEST_CASE("ui controlled: an edit the slot refuses snaps back once the event's t CHECK(backend.log() == Lines{"set TextInput#1 text=a"}); } -// Mutations: in Mounter::reassert, show the slot whatever the widget shows; in Mounter::controlled, send a value the +// Mutations: in Mounter::showSlot, show the slot whatever the widget shows; in Mounter::controlled, send a value the // widget already shows. Either adds a setText the field does not need. TEST_CASE("ui controlled: an accepted edit costs no setter call", "[ui]") { Owner owner; From a228a49b6f69d50707f22df3e3e0cf28a84d0a99 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 10 Oct 2026 18:26:56 +0300 Subject: [PATCH 7/9] ui: answer clang-tidy 22 on the new code A slot's read is checked for emptiness after it is stored, which the static analyzer follows without seeing a temporary escape; slotId() reads the variant without a throwing access, as its noexcept promises; the slot pointer in a controlled binding is const; moveCursor's id and position carry the reason they are adjacent. In the tests, the fake source takes its options through its constructor, and handlers move or borrow the text they get. inputEvent returns its two results as prvalues, which clang 22's -Wnrvo prefers. Co-Authored-By: Claude Opus 5.5 --- include/morph/ui/mount.hpp | 7 ++-- .../morph/ui/testing/recording_backend.hpp | 2 ++ include/morph/ui/view.hpp | 25 +++++++------- tests/test_ui_controlled.cpp | 20 ++++++----- tests/test_ui_frontend.cpp | 34 ++++++++----------- 5 files changed, 42 insertions(+), 46 deletions(-) diff --git a/include/morph/ui/mount.hpp b/include/morph/ui/mount.hpp index 7c8d78c40..7ed1a6bbf 100644 --- a/include/morph/ui/mount.hpp +++ b/include/morph/ui/mount.hpp @@ -313,7 +313,7 @@ class Mounter { apply(value); shown = value; }; - reactive::Computed* const value = bind(scope, prop, show); + reactive::Computed const* const value = bind(scope, prop, show); if (value == nullptr) { return {}; } @@ -325,11 +325,10 @@ class Mounter { template [[nodiscard]] std::function inputEvent(std::function const& handler, std::function reassert) const { - std::function inner = event(handler); if (!reassert) { - return inner; + return event(handler); } - return [inner = std::move(inner), reassert = std::move(reassert)](Arg arg) { + return [inner = event(handler), reassert = std::move(reassert)](Arg arg) { T const requested(arg); if (inner) { inner(std::move(arg)); diff --git a/include/morph/ui/testing/recording_backend.hpp b/include/morph/ui/testing/recording_backend.hpp index 54f8baa55..d41832acc 100644 --- a/include/morph/ui/testing/recording_backend.hpp +++ b/include/morph/ui/testing/recording_backend.hpp @@ -1171,6 +1171,8 @@ class RecordingBackend final : public IViewBackend { /// @param widgetId The field. /// @param position A byte offset into the text it shows; clamped to the text's size. /// @throws std::logic_error when the widget is not a text field. + // An id and a position: an `int` and a `std::size_t`, which convert into each other. + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) void moveCursor(int widgetId, std::size_t position) { if (detail::Record const* const record = actionable(widgetId, "moveCursor", {"TextInput"}); record != nullptr) { diff --git a/include/morph/ui/view.hpp b/include/morph/ui/view.hpp index ec20f2f63..cf173c976 100644 --- a/include/morph/ui/view.hpp +++ b/include/morph/ui/view.hpp @@ -132,7 +132,9 @@ class Prop { /// @throws std::invalid_argument when `binding` is an empty `std::function` or a null function pointer. template requires std::invocable && std::convertible_to, T> - Prop(F binding) : _value{std::in_place_index<1>, Slot{.id = std::nullopt, .read = nonEmpty(std::move(binding))}} {} + Prop(F binding) : _value{std::in_place_index<1>, Slot{.id = std::nullopt, .read = std::move(binding)}} { + refuseEmpty(); + } /// @brief A slot with an id, as `ui::slot` makes it. /// @tparam F A callable taking no arguments and returning something convertible to `T`. @@ -141,7 +143,9 @@ class Prop { template requires std::invocable && std::convertible_to, T> Prop(SlotBinding binding) - : _value{std::in_place_index<1>, Slot{.id = binding.id, .read = nonEmpty(std::move(binding.read))}} {} + : _value{std::in_place_index<1>, Slot{.id = binding.id, .read = std::move(binding.read)}} { + refuseEmpty(); + } /// @brief Whether this is a slot. /// @return True for a slot, false for a constant. @@ -160,7 +164,8 @@ class Prop { /// @brief The slot's id. /// @return The id given through `ui::slot`; `nullopt` for a constant and for a slot made from a bare callable. [[nodiscard]] std::optional slotId() const noexcept { - return isBound() ? std::get<1>(_value).id : std::nullopt; + Slot const* const bound = std::get_if<1>(&_value); + return bound == nullptr ? std::nullopt : bound->id; } /// @brief The current value: the constant, or the slot read once. @@ -176,18 +181,12 @@ class Prop { std::function read; }; - /// @brief Wraps a read, refusing an empty one. - /// @tparam F The read's type. - /// @param binding The read. - /// @return The wrapped read. - /// @throws std::invalid_argument when @p binding is empty. - template - [[nodiscard]] static std::function nonEmpty(F binding) { - std::function read{std::move(binding)}; - if (!read) { + /// @brief Refuses a slot whose read is empty: an empty `std::function` or a null function pointer. + /// @throws std::invalid_argument when the read is empty. + void refuseEmpty() const { + if (!std::get<1>(_value).read) { throw std::invalid_argument{"ui::Prop: a binding must not be empty"}; } - return read; } std::variant _value; diff --git a/tests/test_ui_controlled.cpp b/tests/test_ui_controlled.cpp index 21e5e8264..b73850af9 100644 --- a/tests/test_ui_controlled.cpp +++ b/tests/test_ui_controlled.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include "test_support.hpp" @@ -73,9 +74,9 @@ TEST_CASE("ui controlled: an accepted edit costs no setter call", "[ui]") { Runtime runtime{owner}; RecordingBackend backend; Signal name{runtime, "a"}; - ui::Mounted const view{ - runtime, backend, - ui::textInput({.value = [&] { return name.get(); }, .onChange = [&](std::string text) { name.set(text); }})}; + ui::Mounted const view{runtime, backend, + ui::textInput({.value = [&] { return name.get(); }, + .onChange = [&](std::string text) { name.set(std::move(text)); }})}; backend.clearLog(); backend.edit(1, "ab"); owner.runAll(); @@ -110,9 +111,9 @@ TEST_CASE("ui controlled: an accepted edit keeps the cursor where the user left Runtime runtime{owner}; RecordingBackend backend; Signal name{runtime, ""}; - ui::Mounted const view{ - runtime, backend, - ui::textInput({.value = [&] { return name.get(); }, .onChange = [&](std::string text) { name.set(text); }})}; + ui::Mounted const view{runtime, backend, + ui::textInput({.value = [&] { return name.get(); }, + .onChange = [&](std::string text) { name.set(std::move(text)); }})}; backend.edit(1, "abc"); backend.moveCursor(1, 1); owner.runAll(); @@ -156,8 +157,9 @@ TEST_CASE("ui controlled: an edit whose handler unmounts the field posts no turn RecordingBackend backend; Signal const name{runtime, "a"}; std::optional view; - view.emplace(runtime, backend, - ui::textInput({.value = [&] { return name.get(); }, .onChange = [&](std::string) { view.reset(); }})); + view.emplace( + runtime, backend, + ui::textInput({.value = [&] { return name.get(); }, .onChange = [&](std::string const&) { view.reset(); }})); backend.edit(1, "ab"); CHECK_FALSE(view.has_value()); CHECK_FALSE(backend.exists(1)); @@ -178,7 +180,7 @@ TEST_CASE("ui controlled: a turn posted for a field that is gone by then does no ui::switchOf({.selector = [&] { return ui::Key{which.get()}; }, .cases = {{.key = ui::Key{std::int64_t{1}}, .node = ui::textInput({.value = [&] { return name.get(); }, - .onChange = [&](std::string) { which.set(0); }})}}})}; + .onChange = [&](std::string const&) { which.set(0); }})}}})}; int const field = backend.all("TextInput").front(); backend.edit(field, "ab"); CHECK_NOTHROW(owner.runAll()); diff --git a/tests/test_ui_frontend.cpp b/tests/test_ui_frontend.cpp index 3c1f62c08..c5aa8a915 100644 --- a/tests/test_ui_frontend.cpp +++ b/tests/test_ui_frontend.cpp @@ -80,12 +80,13 @@ class FakeSource final : public ui::AppSource { public: using Opened = std::expected; - explicit FakeSource(Events& events) : _events{&events} {} + explicit FakeSource(Events& events, std::vector immediate = {}, bool keepReadyAfterClose = false) + : _events{&events}, _immediate{std::move(immediate)}, _keepReadyAfterClose{keepReadyAfterClose} {} void open(ui::AppContext&, std::function ready) override { _events->emplace_back("open"); _ready = std::move(ready); - for (Opened const& opened : immediate) { + for (Opened const& opened : _immediate) { deliver(opened); } } @@ -95,7 +96,7 @@ class FakeSource final : public ui::AppSource { } void close() override { _events->emplace_back("close"); - if (!keepReadyAfterClose) { + if (!_keepReadyAfterClose) { _ready = nullptr; } } @@ -108,11 +109,10 @@ class FakeSource final : public ui::AppSource { } } - std::vector immediate; - bool keepReadyAfterClose = false; - private: Events* _events; + std::vector _immediate; + bool _keepReadyAfterClose = false; std::function _ready; }; @@ -376,8 +376,7 @@ TEST_CASE("ui::processEnvironment: reads the process environment", "[ui]") { TEST_CASE("ui::runApp: the shell goes first, then the source's connections, then the runtime", "[ui]") { Events events; RecordingBackend backend; - FakeSource source{events}; - source.immediate.emplace_back(bundle()); + FakeSource source{events, {bundle()}}; TestFrontend frontend{events, tracedShells(backend, events), [&](TestContext&) { events.push_back("loop: " + backend.dump()); return 7; @@ -392,8 +391,7 @@ TEST_CASE("ui::runApp: the shell goes first, then the source's connections, then TEST_CASE("ui::runApp: a loop that throws still tears down in order, then rethrows", "[ui]") { Events events; RecordingBackend backend; - FakeSource source{events}; - source.immediate.emplace_back(bundle()); + FakeSource source{events, {bundle()}}; TestFrontend frontend{events, tracedShells(backend, events), [&](TestContext&) -> int { events.emplace_back("loop"); throw std::runtime_error{"loop failed"}; @@ -422,8 +420,7 @@ TEST_CASE("ui::runApp: a source that delivers from the loop mounts the shell the TEST_CASE("ui::runApp: a ready that comes after the run ended mounts nothing", "[ui]") { Events events; RecordingBackend backend; - FakeSource source{events}; - source.keepReadyAfterClose = true; + FakeSource source{events, {}, true}; TestFrontend frontend{events, tracedShells(backend, events), [](TestContext&) { return 0; }}; CHECK(frontend.run(source) == 0); source.deliver(bundle()); @@ -435,9 +432,7 @@ TEST_CASE("ui::runApp: a ready that comes after the run ended mounts nothing", " TEST_CASE("ui::runApp: a second ready is ignored", "[ui]") { Events events; RecordingBackend backend; - FakeSource source{events}; - source.immediate.emplace_back(bundle()); - source.immediate.emplace_back(ui::Bundle{.applicationId = "other"}); + FakeSource source{events, {bundle(), ui::Bundle{.applicationId = "other"}}}; TestFrontend frontend{events, tracedShells(backend, events), [](TestContext&) { return 0; }}; CHECK(frontend.run(source) == 0); CHECK(events == Events{"open", "mount demo", "shell", "close", "runtime"}); @@ -446,9 +441,9 @@ TEST_CASE("ui::runApp: a second ready is ignored", "[ui]") { TEST_CASE("ui::runApp: a source that cannot connect hands the shell factory the error", "[ui]") { Events events; RecordingBackend backend; - FakeSource source{events}; - source.immediate.emplace_back( - std::unexpected{ui::ConnectError{.kind = ui::ConnectError::Kind::NoUiService, .message = "no UI here"}}); + FakeSource source{ + events, + {std::unexpected{ui::ConnectError{.kind = ui::ConnectError::Kind::NoUiService, .message = "no UI here"}}}}; TestFrontend frontend{events, tracedShells(backend, events), [](TestContext&) { return 3; }}; CHECK(frontend.run(source) == 3); CHECK(events == Events{"open", "connect error: no UI here", "close", "runtime"}); @@ -458,8 +453,7 @@ TEST_CASE("ui::runApp: a source that cannot connect hands the shell factory the TEST_CASE("ui::runApp: a shell factory that throws quits with 1, and its exception leaves after the teardown", "[ui]") { Events events; - FakeSource source{events}; - source.immediate.emplace_back(bundle()); + FakeSource source{events, {bundle()}}; std::optional quitWith; TestFrontend frontend{ events, From 6f337c9c3c7b5555dbdd1bf2c41dfc4e9c73e90a Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 10 Oct 2026 19:20:25 +0300 Subject: [PATCH 8/9] ui: build cleanly under GCC 15 at -O2 GCC 15's -Wdangling-pointer read the conformance case's two braced option lists in a conditional as temporaries escaping the lambda. They did not: each initializer list is copied into the returned vector. The list is now built by name, so there is no temporary to misread. GCC 15's -Wstrict-overflow flagged the reverse inlined from std::ranges::next_permutation in the reorder test at -O2, a libstdc++ false positive. The test now names each of the 120 permutations by its mixed-radix rank and checks it saw 120 different ones. Co-Authored-By: Claude Opus 5.5 --- .../morph/ui/testing/backend_conformance.hpp | 11 ++++++++--- tests/test_ui_foreach.cpp | 19 +++++++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/include/morph/ui/testing/backend_conformance.hpp b/include/morph/ui/testing/backend_conformance.hpp index 6d509072a..32a1b4c03 100644 --- a/include/morph/ui/testing/backend_conformance.hpp +++ b/include/morph/ui/testing/backend_conformance.hpp @@ -802,9 +802,14 @@ struct Entry { ui::select( {.options = [odd] { - return odd() ? Options{{.key = Key{std::int64_t{1}}, .label = "One"}, - {.key = Key{std::int64_t{2}}, .label = "Two"}} - : Options{{.key = Key{std::int64_t{1}}, .label = "One"}}; + // Built up by name rather than as two braced lists in a conditional, whose backing + // arrays GCC's dangling-pointer analysis mistakes for escaping temporaries. + Options options; + options.push_back({.key = Key{std::int64_t{1}}, .label = "One"}); + if (odd()) { + options.push_back({.key = Key{std::int64_t{2}}, .label = "Two"}); + } + return options; }, .selected = [&phase] { return std::optional{Key{std::int64_t{phase.get() < 2 ? 2 : 1}}}; }, .onSelect = note("Select onSelect")}), diff --git a/tests/test_ui_foreach.cpp b/tests/test_ui_foreach.cpp index 59c5f4318..3774ef88e 100644 --- a/tests/test_ui_foreach.cpp +++ b/tests/test_ui_foreach.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -335,9 +336,22 @@ TEST_CASE("ui::detail::reorderChildren: every permutation of five ends in order for (FakeItem& item : items) { start.push_back(&item); } - std::vector permutation{0, 1, 2, 3, 4}; + // Each rank in [0, 120) names one permutation by its mixed-radix digits (5, 4, 3, 2, 1): a digit picks among + // the indices not yet used. Built by hand rather than with next_permutation, whose inlined reverse GCC's + // -Wstrict-overflow misreads at -O2. + std::set> seen; int permutations = 0; - for (bool more = true; more; more = std::ranges::next_permutation(permutation).found) { + for (std::size_t rank = 0; rank < 120; ++rank) { + std::vector pool{0, 1, 2, 3, 4}; + std::vector permutation; + std::size_t rest = rank; + for (std::size_t radix = pool.size(); radix > 0; --radix) { + std::size_t const pick = rest % radix; + rest /= radix; + permutation.push_back(pool.at(pick)); + pool.erase(pool.begin() + static_cast(pick)); + } + seen.insert(permutation); std::vector target; target.reserve(permutation.size()); for (std::size_t const index : permutation) { @@ -357,6 +371,7 @@ TEST_CASE("ui::detail::reorderChildren: every permutation of five ends in order ++permutations; } CHECK(permutations == 120); + CHECK(seen.size() == 120); // every rank was a different permutation, so all of them were tried } TEST_CASE("ui::detail::reorderChildren: a move that throws leaves the order as far as it got", "[ui]") { From c8f32fae18980aebda08385c2c45eb42b170b218 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 10 Oct 2026 19:20:43 +0300 Subject: [PATCH 9/9] ui: cover the branches a broken backend takes, and give ui a coverage floor The conformance suite is now run against probes that break the contract on purpose: one whose containers hand out no children, one whose widgets show other text, one whose user actions reach nothing, and a backend whose Select reports a choice when its options change. Each must fail the cases that check that part of the contract, which covers the suite's failure paths and shows it detects something. The cases read a mounted container's root through rootContainer, a downcast its factory's type guarantees, instead of a null check no backend can reach. RecordingBackend gains tests for every enumerator's name and the unknown fallback, escaping a carriage return, an empty date, unknown ids, moving or keying a stray widget, a select asked for no key, a hidden field's cursor, a duplicate row selection and drops that must not land. Mounted gains tests for constant enabled and drag keys, a menu entry without a handler or out of range, a constant Tabs index, a ForEach without a model, a Dialog without onDismiss whose content fails, and two rows failing in one run. include/morph/ui gets a branch floor of 92%, three points below the 95.05% (614 of 646 arms) measured with a clang 22 coverage build of the ui tests, the same build that reproduced CI's 569 of 660 before. Co-Authored-By: Claude Opus 5.5 --- .../morph/ui/testing/backend_conformance.hpp | 99 +++++++-------- scripts/check_branch_coverage.py | 4 + tests/test_ui_conformance.cpp | 115 ++++++++++++++++-- tests/test_ui_mount.cpp | 102 ++++++++++++++++ tests/test_ui_recording_backend.cpp | 110 +++++++++++++++++ 5 files changed, 371 insertions(+), 59 deletions(-) diff --git a/include/morph/ui/testing/backend_conformance.hpp b/include/morph/ui/testing/backend_conformance.hpp index 32a1b4c03..dec5141ce 100644 --- a/include/morph/ui/testing/backend_conformance.hpp +++ b/include/morph/ui/testing/backend_conformance.hpp @@ -224,6 +224,18 @@ struct Entry { return dynamic_cast(&widget); } +/// @brief The root widget of a mounted container node, as the container it is. +/// +/// The root of a mounted Switch, Tabs, Column, ForEach or Dialog is the widget the backend's `createSlot`, +/// `createTabs`, `createStack` or `createDialog` returned, and each of those is a `ContainerWidget` by its declared +/// type, so the downcast cannot fail for any backend that compiles. +/// @param view The mount, of a container node. +/// @return The root widget as a container. +[[nodiscard]] inline ContainerWidget const& rootContainer(Mounted const& view) { + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-static-cast-downcast): a container by the factory's type, see above + return static_cast(view.root()); +} + /// @brief A child of a widget that should be a container. /// @param probe The backend under test. /// @param parent The widget; null, or a widget that is no container, has no children. @@ -397,21 +409,18 @@ struct Entry { .cases = {{.key = Key{std::int64_t{0}}, .node = ui::text({.text = "zero"})}, {.key = Key{std::int64_t{1}}, .node = ui::text({.text = "one"})}}})}; probe.settle(); - ContainerWidget const* const slot = asContainer(view.root()); - if (slot == nullptr) { - return "a Switch's widget is not a ContainerWidget"; - } + ContainerWidget const& slot = rootContainer(view); Checks checks; - checks.text("the case for key 0", "zero", childTexts(probe, *slot)); + checks.text("the case for key 0", "zero", childTexts(probe, slot)); which.set(1); probe.settle(); - checks.text("the case for key 1", "one", childTexts(probe, *slot)); + checks.text("the case for key 1", "one", childTexts(probe, slot)); which.set(7); probe.settle(); - checks.that(probe.childCount(*slot) == 0, "a key with no case and no fallback still shows a child"); + checks.that(probe.childCount(slot) == 0, "a key with no case and no fallback still shows a child"); which.set(0); probe.settle(); - checks.text("the case for key 0 again", "zero", childTexts(probe, *slot)); + checks.text("the case for key 0 again", "zero", childTexts(probe, slot)); return checks.result(); } @@ -426,24 +435,21 @@ struct Entry { .selected = [&selected] { return selected.get(); }, .onSelect = [&selected](std::size_t index) { selected.set(index); }})}; probe.settle(); - ContainerWidget const* const bar = asContainer(view.root()); - if (bar == nullptr) { - return "a Tabs' widget is not a ContainerWidget"; - } + ContainerWidget const& bar = rootContainer(view); Checks checks; - checks.that(probe.childCount(*bar) == 1, "Tabs mounted a page that was never selected"); + checks.that(probe.childCount(bar) == 1, "Tabs mounted a page that was never selected"); selected.set(1); probe.settle(); - checks.that(probe.childCount(*bar) == 2, "selecting a tab did not mount its page"); - Widget const* const first = probe.childAt(*bar, 0); - Widget const* const second = probe.childAt(*bar, 1); + checks.that(probe.childCount(bar) == 2, "selecting a tab did not mount its page"); + Widget const* const first = probe.childAt(bar, 0); + Widget const* const second = probe.childAt(bar, 1); checks.that(first != nullptr && !probe.visibleOf(*first), "the unselected tab's page is shown"); checks.that(second != nullptr && probe.visibleOf(*second), "the selected tab's page is hidden"); selected.set(0); probe.settle(); // Read again rather than through `first`: a backend that destroyed the page would leave that pointer dangling. - checks.that(probe.childCount(*bar) == 2, "re-selecting a tab mounted its page again"); - Widget const* const reselected = probe.childAt(*bar, 0); + checks.that(probe.childCount(bar) == 2, "re-selecting a tab mounted its page again"); + Widget const* const reselected = probe.childAt(bar, 0); checks.that(reselected != nullptr && probe.visibleOf(*reselected), "the re-selected tab's page is hidden"); return checks.result(); } @@ -460,18 +466,15 @@ struct Entry { int builds = 0; Mounted const view{probe.runtime(), probe.backend(), entryList(rows, &builds)}; probe.settle(); - ContainerWidget const* const list = asContainer(view.root()); - if (list == nullptr) { - return "a ForEach's widget is not a ContainerWidget"; - } - Widget const* const before = probe.childAt(*list, 0); + ContainerWidget const& list = rootContainer(view); + Widget const* const before = probe.childAt(list, 0); rows.set({{.id = 1, .label = "a2"}, {.id = 2, .label = "b"}}); probe.settle(); Checks checks; checks.that(builds == 2, "updating a kept row built its view again: " + std::to_string(builds) + " row views built for two keys"); - checks.that(probe.childAt(*list, 0) == before, "updating a kept row replaced its widget"); - checks.text("the rows after an update", "a2,b", childTexts(probe, *list)); + checks.that(probe.childAt(list, 0) == before, "updating a kept row replaced its widget"); + checks.text("the rows after an update", "a2,b", childTexts(probe, list)); return checks.result(); } @@ -483,18 +486,15 @@ struct Entry { probe.runtime(), {{.id = 1, .label = "a"}, {.id = 2, .label = "b"}, {.id = 3, .label = "c"}}}; Mounted const view{probe.runtime(), probe.backend(), entryList(rows)}; probe.settle(); - ContainerWidget const* const list = asContainer(view.root()); - if (list == nullptr) { - return "a ForEach's widget is not a ContainerWidget"; - } + ContainerWidget const& list = rootContainer(view); Checks checks; - checks.text("the rows as mounted", "a,b,c", childTexts(probe, *list)); + checks.text("the rows as mounted", "a,b,c", childTexts(probe, list)); rows.set({{.id = 3, .label = "c"}, {.id = 1, .label = "a"}}); probe.settle(); - checks.text("the rows after removing b and moving c first", "c,a", childTexts(probe, *list)); + checks.text("the rows after removing b and moving c first", "c,a", childTexts(probe, list)); rows.set({{.id = 3, .label = "c"}, {.id = 4, .label = "d"}, {.id = 1, .label = "a"}}); probe.settle(); - checks.text("the rows after inserting d in the middle", "c,d,a", childTexts(probe, *list)); + checks.text("the rows after inserting d in the middle", "c,d,a", childTexts(probe, list)); return checks.result(); } @@ -512,20 +512,20 @@ struct Entry { return ui::textInput({.placeholder = [&entry] { return entry.get().label; }}); })}; probe.settle(); - ContainerWidget const* const list = asContainer(view.root()); - Widget* const field = list == nullptr ? nullptr : probe.childAt(*list, 0); + ContainerWidget const& list = rootContainer(view); + Widget* const field = probe.childAt(list, 0); if (field == nullptr) { return "a ForEach of text fields shows no first field"; } probe.type(*field, "edited"); probe.settle(); Checks checks; - checks.text("the fields after typing into the first", "edited,,", childTexts(probe, *list)); + checks.text("the fields after typing into the first", "edited,,", childTexts(probe, list)); rows.set({{.id = 2, .label = "b"}, {.id = 3, .label = "c"}, {.id = 1, .label = "a"}}); probe.settle(); - checks.that(probe.childAt(*list, 2) == field, "moving a row replaced its widget"); + checks.that(probe.childAt(list, 2) == field, "moving a row replaced its widget"); checks.text("the fields after moving the edited one last; moveChild must keep a widget's native state", ",,edited", - childTexts(probe, *list)); + childTexts(probe, list)); return checks.result(); } @@ -539,18 +539,15 @@ struct Entry { ui::dialog( {.open = [&open] { return open.get(); }, .title = "Confirm", .child = ui::text({.text = "inside"})})}; probe.settle(); - ContainerWidget const* const dialog = asContainer(view.root()); - if (dialog == nullptr) { - return "a Dialog's widget is not a ContainerWidget"; - } + ContainerWidget const& dialog = rootContainer(view); Checks checks; - checks.that(probe.childCount(*dialog) == 0, "a closed Dialog holds content"); + checks.that(probe.childCount(dialog) == 0, "a closed Dialog holds content"); open.set(true); probe.settle(); - checks.text("an open Dialog's content", "inside", childTexts(probe, *dialog)); + checks.text("an open Dialog's content", "inside", childTexts(probe, dialog)); open.set(false); probe.settle(); - checks.that(probe.childCount(*dialog) == 0, "a Dialog closed again still holds content"); + checks.that(probe.childCount(dialog) == 0, "a Dialog closed again still holds content"); return checks.result(); } @@ -729,10 +726,11 @@ struct Entry { probe.settle(); checks.text("the field after an edit the document refused", "kept", probe.textOf(view.root())); refuse = false; - probe.type(view.root(), "typed"); + // A character past `z`, and a capital, are left as they are. + probe.type(view.root(), "ty~Ped"); probe.settle(); - checks.text("the field after an edit the document upper-cased", "TYPED", probe.textOf(view.root())); - checks.text("the value after an edit the document upper-cased", "TYPED", name.peek()); + checks.text("the field after an edit the document upper-cased", "TY~PED", probe.textOf(view.root())); + checks.text("the value after an edit the document upper-cased", "TY~PED", name.peek()); return checks.result(); } @@ -749,15 +747,12 @@ struct Entry { .child = ui::button({.label = "OK", .onClick = [&clicks] { ++clicks; }}), .onDismiss = [&dismissals] { ++dismissals; }})}; probe.settle(); - ContainerWidget const* const dialog = asContainer(view.root()); - if (dialog == nullptr) { - return "a Dialog's widget is not a ContainerWidget"; - } + ContainerWidget const& dialog = rootContainer(view); probe.dismiss(view.root()); probe.settle(); Checks checks; checks.that(dismissals == 1, "dismissing the dialog called onDismiss " + std::to_string(dismissals) + " times"); - Widget* const button = probe.childAt(*dialog, 0); + Widget* const button = probe.childAt(dialog, 0); checks.that(button != nullptr, "a dialog kept open lost its content"); if (button != nullptr) { probe.click(*button); diff --git a/scripts/check_branch_coverage.py b/scripts/check_branch_coverage.py index 24ef78413..7a4b74648 100644 --- a/scripts/check_branch_coverage.py +++ b/scripts/check_branch_coverage.py @@ -116,6 +116,10 @@ # Measured in the CI coverage leg (22 branch arms) when the test kit was installed with # morph::reactive. "include/morph/testing": (74.0, 77.27), + # Measured (646 branch arms) when the view tree landed, from a clang 22.1.8 coverage build of the ui tests, + # the only tests that reach these headers; the same build reproduced the CI leg's 569 of 660 arms exactly + # before the coverage tests were added. + "include/morph/ui": (92.0, 95.05), "include/morph/util": (93.0, 96.39), } diff --git a/tests/test_ui_conformance.cpp b/tests/test_ui_conformance.cpp index 3e9d192b5..c9f9dd1e6 100644 --- a/tests/test_ui_conformance.cpp +++ b/tests/test_ui_conformance.cpp @@ -16,6 +16,7 @@ #include #include "test_support.hpp" +#include "ui_echoing_backend.hpp" namespace ui = morph::ui; @@ -77,15 +78,32 @@ ui::Key parseKey(std::string_view text) { return ui::Key{std::int64_t{std::stoll(std::string{text})}}; } +// How a probe, or the backend behind it, breaks the contract on purpose. A suite that passes a correct backend +// measures something only if it fails a broken one. +enum class Fault : std::uint8_t { + None, // the reference probe + NoChildren, // a container reports its children but hands none of them out + WrongText, // every widget reads as showing other text + DeadInput, // user actions never reach the widgets + EchoingSelect, // the backend's Select reports a choice when its options are replaced +}; + // The reference probe: what a TUI or Qt Quick probe does with a rendered screen or a QQuickItem tree, this one does -// with the recording backend's records. +// with the recording backend's records. Given a fault, it is a broken backend the suite must flag. class RecordingProbe final : public ui::testing::ConformanceProbe { public: - ui::IViewBackend& backend() override { return _backend; } + explicit RecordingProbe(Fault fault = Fault::None) : _fault{fault} {} + + ui::IViewBackend& backend() override { + return _fault == Fault::EchoingSelect ? static_cast(_echoing) : _backend; + } morph::reactive::Runtime& runtime() override { return _rt; } void settle() override { _owner.runAll(); } [[nodiscard]] std::string textOf(ui::Widget const& widget) override { + if (_fault == Fault::WrongText) { + return "?"; + } int const widgetId = _backend.idOf(widget); std::string const kind = _backend.kindOf(widgetId); if (kind == "Select") { @@ -103,17 +121,32 @@ class RecordingProbe final : public ui::testing::ConformanceProbe { return _backend.children(_backend.idOf(container)).size(); } [[nodiscard]] ui::Widget* childAt(ui::ContainerWidget const& container, std::size_t index) override { + if (_fault == Fault::NoChildren) { + return nullptr; + } auto const children = _backend.children(_backend.idOf(container)); return index < children.size() ? _backend.widget(children.at(index)) : nullptr; } - void click(ui::Widget& widget) override { _backend.click(_backend.idOf(widget)); } + void click(ui::Widget& widget) override { + if (_fault != Fault::DeadInput) { + _backend.click(_backend.idOf(widget)); + } + } void type(ui::Widget& widget, std::string_view text) override { - _backend.edit(_backend.idOf(widget), std::string{text}); + if (_fault != Fault::DeadInput) { + _backend.edit(_backend.idOf(widget), std::string{text}); + } } void drag(ui::Widget& source, ui::Widget& target) override { - static_cast(_backend.drag(_backend.idOf(source), _backend.idOf(target))); + if (_fault != Fault::DeadInput) { + static_cast(_backend.drag(_backend.idOf(source), _backend.idOf(target))); + } + } + void dismiss(ui::Widget& dialog) override { + if (_fault != Fault::DeadInput) { + _backend.dismiss(_backend.idOf(dialog)); + } } - void dismiss(ui::Widget& dialog) override { _backend.dismiss(_backend.idOf(dialog)); } void moveCursor(ui::Widget& field, std::size_t position) override { _backend.moveCursor(_backend.idOf(field), position); } @@ -121,6 +154,9 @@ class RecordingProbe final : public ui::testing::ConformanceProbe { return _backend.cursor(_backend.idOf(field)); } void selectRows(ui::Widget& table, std::vector const& rows) override { + if (_fault == Fault::DeadInput) { + return; + } int const tableId = _backend.idOf(table); std::vector const children = _backend.children(tableId); std::vector keys; @@ -158,11 +194,25 @@ class RecordingProbe final : public ui::testing::ConformanceProbe { return {}; } + Fault _fault; morph::testing::StepExecutor _owner; - ui::testing::RecordingBackend _backend; + morph::testing::EchoingBackend _echoing; + ui::testing::RecordingBackend& _backend = _echoing.recording(); morph::reactive::Runtime _rt{_owner}; }; +// The names of the cases a probe with @p fault fails. +std::set failingCases(Fault fault) { + std::set failing; + for (auto const& testCase : ui::testing::conformanceCases()) { + RecordingProbe probe{fault}; + if (testCase.run(probe).has_value()) { + failing.insert(testCase.name); + } + } + return failing; +} + } // namespace TEST_CASE("ui conformance: RecordingBackend passes every case", "[ui][conformance]") { @@ -177,3 +227,54 @@ TEST_CASE("ui conformance: RecordingBackend passes every case", "[ui][conformanc CHECK_FALSE(failure.has_value()); } } + +// Each fault breaks one part of the contract, and the cases that check that part must say so. Mutation: make +// `detail::Checks::that` or `detail::Checks::text` record nothing (every fault then passes everything). +TEST_CASE("ui conformance: the suite flags a backend that breaks the contract", "[ui][conformance]") { + std::set const noChildren = failingCases(Fault::NoChildren); + INFO("no children: " + std::to_string(noChildren.size()) + " cases failed"); + for (std::string_view const name : + {"Tabs mount a page on first selection and show only the selected one", + "a ForEach updates a kept row in place", "a ForEach follows inserts, removals and reorders", + "a moved widget keeps its native state", "a Dialog holds its content only while open", + "a dismissal the document refuses leaves the dialog open", + "a widget inside a hidden, disabled or collapsed container takes no input", + "a hidden Table cell keeps its column"}) { + INFO(name); + CHECK(noChildren.contains(name)); + } + + std::set const wrongText = failingCases(Fault::WrongText); + for (std::string_view const name : + {"a Text shows its constant text", "a bound Text updates once per batch and not for an equal write", + "a Switch shows the selected case and nothing for a key without one", + "a text field shows what the document makes of an edit"}) { + INFO(name); + CHECK(wrongText.contains(name)); + } + + std::set const deadInput = failingCases(Fault::DeadInput); + for (std::string_view const name : {"a click runs the button's action once", + "typing reaches onChange and a value set by the application is not echoed", + "a drag onto an accepting target delivers the key", + "a Table reports a user's selection as exactly the existing rows selected", + "a dismissal the document refuses leaves the dialog open"}) { + INFO(name); + CHECK(deadInput.contains(name)); + } + + // Only the setter case is run against the echoing backend: the probe reads widgets through the recording + // backend's ids, which the echoing Select's wrapper does not have. + auto const cases = ui::testing::conformanceCases(); + auto const setters = + std::ranges::find(cases, std::string_view{"no setter calls a handler"}, &ui::testing::ConformanceCase::name); + REQUIRE(setters != cases.end()); + RecordingProbe echoing{Fault::EchoingSelect}; + std::optional const failure = setters->run(echoing); + REQUIRE(failure.has_value()); + CHECK(failure->contains("Select onSelect")); +} + +TEST_CASE("ui conformance: a failure message names keys of either kind", "[ui][conformance]") { + CHECK(ui::testing::detail::keyTexts({ui::Key{std::int64_t{7}}, ui::Key{std::string{"b"}}}) == "7,\"b\""); +} diff --git a/tests/test_ui_mount.cpp b/tests/test_ui_mount.cpp index 5378643b9..6733d370d 100644 --- a/tests/test_ui_mount.cpp +++ b/tests/test_ui_mount.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,7 @@ #include "test_support.hpp" #include "ui_echoing_backend.hpp" +#include "ui_test_support.hpp" namespace ui = morph::ui; @@ -519,3 +521,103 @@ TEST_CASE("ui::Mounted: mounting inside an Effect does not subscribe that Effect owner.runAll(); CHECK(hostRuns == 1); } + +// Mutations: in Mounter::applyCommon, apply `enabled` only when bound, or `dragKey` only when bound. +TEST_CASE("ui::Mounted: a constant disabled or draggable widget says so once", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + ui::Mounted const view{runtime, backend, + ui::text({.text = "t", .common = {.enabled = false, .dragKey = ui::Key{std::int64_t{4}}}})}; + CHECK(backend.prop(1, "enabled") == "false"); + CHECK(backend.prop(1, "dragKey") == "4"); + CHECK(runtime.core()->liveNodes() == 0); +} + +// Mutation: in the Menu's dispatcher, call the entry without checking it has a handler or exists. +TEST_CASE("ui::Mounted: a menu entry without a handler, or past the last, runs nothing", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + int runs = 0; + ui::Mounted const view{ + runtime, backend, + ui::menu({.items = {{.label = "quiet"}, {.label = "loud", .onSelect = [&runs] { ++runs; }}}})}; + backend.chooseIndex(1, 0); + backend.chooseIndex(1, 5); + backend.chooseIndex(1, 1); + owner.runAll(); + CHECK(runs == 1); +} + +// Mutation: in Mounter's Tabs, treat a constant index as bound (no page is shown). +TEST_CASE("ui::Mounted: a constant Tabs index shows its page and leaves the user's pick on the bar", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + std::vector picks; + ui::Mounted const view{runtime, backend, + ui::tabs({.tabs = {{.label = "A", .node = ui::text({.text = "a"})}, + {.label = "B", .node = ui::text({.text = "b"})}}, + .selected = std::size_t{1}, + .onSelect = [&picks](std::size_t index) { picks.push_back(index); }})}; + CHECK(backend.dump() == "Tabs#1 selected=1 tabs=[A,B]\n Slot#2\n Text#3 role=Normal text=b\n"); + backend.chooseIndex(1, 0); + owner.runAll(); + CHECK(picks == std::vector{0}); + CHECK(backend.prop(1, "selected") == "0"); // not a slot: nothing re-asserts it +} + +// Mutation: in Mounter::mountRows, dereference the model without checking it. +TEST_CASE("ui::Mounted: a ForEach without a model shows no rows", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + ui::Mounted const view{runtime, backend, ui::detail::makeNode(ui::ForEach{})}; + CHECK(backend.dump() == "Column#1 gap=0\n"); +} + +// Mutation: in Mounter::dismissFailed, call the dismissal without checking there is one. +TEST_CASE("ui::Mounted: a Dialog without onDismiss whose content fails stays closed", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + EchoingBackend backend; + std::optional spare; + backend.setOnCreate(morph::testing::failWhenSpent(spare)); + Signal open{runtime, false}; + ui::Mounted const view{runtime, backend, + ui::dialog({.open = [&open] { return open.get(); }, .child = ui::text({.text = "x"})})}; + spare = 0; + open.set(true); + owner.runAll(); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(backend.recording().prop(1, "open") == "false"); +} + +// The first of several row failures in one run is the one reported. Mutation: in Mounter::reconcile, keep the last +// failure instead of the first. +TEST_CASE("ui::Mounted: two rows that fail in one run are one report, and neither is left behind", "[ui]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + EchoingBackend backend; + std::optional spare; + backend.setOnCreate(morph::testing::failWhenSpent(spare)); + Signal> rows{runtime, std::vector{}}; + int builds = 0; + ui::Mounted const view{runtime, backend, + ui::forEach( + rows, [](std::int64_t row) { return ui::Key{row}; }, + [&builds](Signal const& row) { + if (++builds > 2) { + throw std::runtime_error{"row " + std::to_string(row.peek())}; + } + return ui::text({.text = "r"}); + })}; + builds = 2; // every row view from here on throws + rows.set({1, 2}); + owner.runAll(); + CHECK(probe.count(morph::reactive::detail::site::kEffectThrew) == 1); + CHECK(backend.recording().dump() == "Column#1 gap=0\n"); +} diff --git a/tests/test_ui_recording_backend.cpp b/tests/test_ui_recording_backend.cpp index efde3123e..4e9b97232 100644 --- a/tests/test_ui_recording_backend.cpp +++ b/tests/test_ui_recording_backend.cpp @@ -533,3 +533,113 @@ TEST_CASE("RecordingBackend: a dismissal closes the dialog before onDismiss runs CHECK(backend.prop(1, "open") == "false"); CHECK(backend.log().empty()); } + +// Mutation: map any enumerator to the wrong name in one of the `detail::enumName` overloads, or drop a fallback. +TEST_CASE("RecordingBackend: every enumerator has its name, and a value outside the enumeration reads unknown", + "[ui]") { + using ui::testing::detail::enumName; + CHECK(enumName(ui::TextRole::Normal) == "Normal"); + CHECK(enumName(ui::TextRole::Muted) == "Muted"); + CHECK(enumName(ui::TextRole::Heading) == "Heading"); + CHECK(enumName(ui::TextRole::Error) == "Error"); + CHECK(enumName(ui::TextRole::Success) == "Success"); + CHECK(enumName(ui::TextInputMode::SingleLine) == "SingleLine"); + CHECK(enumName(ui::TextInputMode::Multiline) == "Multiline"); + CHECK(enumName(ui::TextInputMode::Password) == "Password"); + CHECK(enumName(ui::SelectStyle::Dropdown) == "Dropdown"); + CHECK(enumName(ui::SelectStyle::Radio) == "Radio"); + CHECK(enumName(ui::Axis::Vertical) == "Vertical"); + CHECK(enumName(ui::Axis::Horizontal) == "Horizontal"); + CHECK(enumName(ui::DateMode::Date) == "Date"); + CHECK(enumName(ui::DateMode::DateTime) == "DateTime"); + CHECK(enumName(ui::FilePickerMode::Open) == "Open"); + CHECK(enumName(ui::FilePickerMode::Save) == "Save"); + CHECK(enumName(ui::SelectionMode::None) == "None"); + CHECK(enumName(ui::SelectionMode::Single) == "Single"); + CHECK(enumName(ui::SelectionMode::Multiple) == "Multiple"); + // The fallbacks keep a dump readable when a value the enumeration does not name reaches a setter. + constexpr std::uint8_t kOutside = 99; + CHECK(enumName(static_cast(kOutside)) == "unknown"); + CHECK(enumName(static_cast(kOutside)) == "unknown"); + CHECK(enumName(static_cast(kOutside)) == "unknown"); + CHECK(enumName(static_cast(kOutside)) == "unknown"); + CHECK(enumName(static_cast(kOutside)) == "unknown"); + CHECK(enumName(static_cast(kOutside)) == "unknown"); + CHECK(enumName(static_cast(kOutside)) == "unknown"); + CHECK(ui::testing::detail::formatSizing({.kind = static_cast(kOutside), .amount = 1}) == + "unknown"); +} + +// Mutations: drop the carriage-return branch of `detail::escape`; format an empty Timestamp as `none`. +TEST_CASE("RecordingBackend: a carriage return is escaped, and an empty date reads empty", "[ui]") { + CHECK(ui::testing::detail::formatText("a\r\nb") == "a\\r\\nb"); + CHECK(ui::testing::detail::formatTimestamp(morph::time::Timestamp{}) == "empty"); + CHECK(ui::testing::detail::formatTimestamp(std::nullopt) == "none"); +} + +// Mutation: let `RecordStore::at` return a default record for an unknown id. +TEST_CASE("RecordingBackend: an id that names no live widget is a test error", "[ui]") { + RecordingBackend backend; + auto field = backend.createTextInput(nullptr, ui::TextInputMode::SingleLine); + CHECK_THROWS_AS(backend.prop(99, "text"), std::out_of_range); + CHECK_THROWS_AS(backend.kindOf(99), std::out_of_range); + CHECK_THROWS_AS(backend.cursor(99), std::out_of_range); + CHECK_FALSE(backend.find("TextInput", "text", "anything").has_value()); // never set +} + +// Mutations: drop the parent check in FakeContainer::moveChild or FakeTable::setRowKey. +TEST_CASE("RecordingBackend: moving or keying a widget that is not the container's child is a test error", "[ui]") { + RecordingBackend backend; + auto stack = backend.createStack(nullptr, ui::Axis::Vertical); + auto table = backend.createTable(nullptr); + auto stray = backend.createText(nullptr); + CHECK_THROWS_AS(stack->moveChild(*stray, 0), std::logic_error); + CHECK_THROWS_AS(table->setRowKey(*stray, intKey(1)), std::logic_error); +} + +// Mutation: in FakeSelect::marked, read the requested key without checking that one is requested. +TEST_CASE("RecordingBackend: a select asked for no key marks none", "[ui]") { + RecordingBackend backend; + auto select = backend.createSelect(nullptr, ui::SelectStyle::Dropdown); + select->setOptions({{.key = intKey(1), .label = "one"}}); + select->setSelected(intKey(1)); + CHECK(backend.prop(1, "selected") == "1"); + select->setSelected(std::nullopt); + CHECK(backend.prop(1, "selected") == "none"); +} + +// Mutation: drop the reachability check in RecordingBackend::moveCursor. +TEST_CASE("RecordingBackend: a hidden field's cursor does not move", "[ui]") { + RecordingBackend backend; + auto field = backend.createTextInput(nullptr, ui::TextInputMode::SingleLine); + backend.edit(1, "abc"); + field->setVisible(false); + backend.moveCursor(1, 1); + CHECK(backend.cursor(1) == 3); +} + +// Mutation: drop the duplicate check in RecordingBackend::selectRows. +TEST_CASE("RecordingBackend: selecting one row twice is a test error", "[ui]") { + RecordingBackend backend; + auto table = backend.createTable(nullptr); + table->setSelectionMode(ui::SelectionMode::Multiple); + auto row = backend.createStack(table.get(), ui::Axis::Horizontal); + table->setRowKey(*row, intKey(1)); + CHECK_THROWS_AS(backend.selectRows(1, {intKey(1), intKey(1)}), std::logic_error); +} + +// Mutations: in RecordingBackend::drag, check only the source's reachability; or call a target with no predicate. +TEST_CASE("RecordingBackend: no drop lands on a hidden target or one without a predicate", "[ui]") { + RecordingBackend backend; + int drops = 0; + auto card = backend.createText(nullptr); + card->setDragKey(intKey(7)); + auto hidden = backend.createPanel(nullptr); + hidden->setDropHandler([](ui::Key const&) { return true; }, [&drops](ui::Key const&) { ++drops; }); + hidden->setVisible(false); + auto blind = backend.createPanel(nullptr); + blind->setDropHandler({}, [&drops](ui::Key const&) { ++drops; }); + CHECK_FALSE(backend.drag(1, 2)); + CHECK_FALSE(backend.drag(1, 3)); + CHECK(drops == 0); +}