diff --git a/docs/superpowers/plans/2026-10-04-declarative-ui-tui-0-core-cpp.md b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-0-core-cpp.md new file mode 100644 index 000000000..ff99adaf8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-0-core-cpp.md @@ -0,0 +1,2551 @@ +# Declarative UI, Part 0 — core-cpp mouse tracking and pointer capture, then morph's pin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Release core-cpp 0.7.0 with standard mouse tracking, pointer capture and a public `Screen::componentAt`, +then raise morph's core-cpp pin from 0.5 to 0.7 in one commit of the declarative-UI branch. + +**Architecture:** In core-cpp, a `MouseTracking` enum and one pure function, +`protocols::appendMouseTrackingChange(out, from, to)`, compose every mouse-mode transition; `TerminalInput` +writes the change from `Off` when it enables its protocols, the change to `Off` when it disables them, and only the +transition when the mode changes while they are enabled. `Screen` hit-tests overlays before the tree, makes the +component that handled a press the capture target, and delivers moves and the release to it; a component's own +teardown clears the capture. morph then changes one CMake bound, one tag and the two README lines that state them. + +**Tech Stack:** C++23; core-cpp `core::tui` / `core::tui_output` (Catch2 v3 through `core::testing_main`, CMake +presets, pinned clang-format/clang-tidy 22.1.8); morph CMake + CPM; `gh` for the core-cpp release steps. + +**Spec:** `docs/superpowers/specs/2026-10-04-core-cpp-mouse-design.md` (spec 0, all sections); +`docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md` §2 "core-cpp pin". + +This is **Part 0 of 11** of the declarative-UI program. Read the master plan +`docs/superpowers/plans/2026-10-04-declarative-ui-tui.md` first. This part spans two repositories: Tasks 1–9 run in +**core-cpp** (`/Users/christianparpart/projects/core-cpp`, GitHub `contour-terminal/core-cpp`) and follow +core-cpp's own conventions — conventional-commit subjects, `git commit -s`, no `wip(...)` commits, a pull request +merged with a merge commit; Tasks 10–11 run in **morph** on `feature/declarative-ui` and follow the master plan: +`wip(corecpp)` commits squashed into `core: build against core-cpp 0.7`. + +## Global Constraints + +- morph side: Part 1's Global Constraints apply unchanged (C++23, SPDX first line, `#pragma once`, naming, present + tense comments without history or issue numbers, Doxygen `WARN_AS_ERROR`, `-Weverything -Werror`, sign-off). +- core-cpp side follows core-cpp's `AGENT.md` and `.agent/rules/` instead, where they differ from morph's: + - constants are `CamelCase` with **no `k` prefix** (`EnableDragTracking`, not `kEnableDragTracking`); + functions, variables, parameters and local `const` values `camelBack`; private members `_camelBack`; + a `bool` member reads as a predicate (`_isRedirected`). + - **no C-style `for (;;)`/`for (init; cond; step)`** (the hygiene scan refuses the three-clause form): range-for, + `std::views`, algorithms or `while`. + - **no `NOLINT`, no diagnostic pragma**; every enum has an explicit underlying type and its zero enumerator is + the off/default case. + - `.hpp` + `#pragma once`, `// SPDX-License-Identifier: Apache-2.0` on line 1, includes `` from + `src/`, `QualifierAlignment: Right` (`auto const`, `Component const&`), braces on their own line. + - Doxygen `///` with `@brief`/`@param`/`@return` on every public declaration, `[[nodiscard]]` on every query. + - every new file under `src/core/` gets a row in `.agent/reference/provenance.md` + (`core-cpp.cmake-hygiene` refuses a file without one). + - format with the pinned tool only: `python scripts/clang-format.py `; never `clang-format -i` from PATH. + - a public header change gets a `CHANGELOG.md` `[Unreleased]` entry in the same commit. + - every commit builds and passes on its own and ends with `Signed-off-by:` (`git commit -s`). +- Pushing core-cpp's branch, opening and merging its pull request, cutting and publishing `v0.7.0`, and filing the + Shift+Tab issue are each **confirmed with the user immediately before they are taken** (master plan, + "Outward-facing steps are confirmed first"). +- morph's pin task (Task 10) cannot run before `v0.7.0` exists on GitHub: CPM fetches the tag. Before that, morph + is verified against the feature branch with `-DCPM_core-cpp_SOURCE=…` in a throwaway build directory (Task 8); + that override is never committed. + +## Review Focus + +1. **A component destroyed while handling its own press** must not be left as the capture target, and the next + move must not reach it (Task 6 test "Screen.pointerCapture_isNotTakenByAComponentItsPressDestroyed" — ASan is + its observer in Task 7). +2. **A release the terminal never delivered** (the window lost focus mid-drag) must not hold the pointer for ever: + the next press re-captures (Task 6 test "Screen.pointerCapture_movesToTheComponentOfTheNextPress"). +3. **An overlay and its `Screen` destroyed in either order**: the new teardown hook calls into the screen, so + the screen detaches every overlay it still shows before it dies, and an overlay destroyed while shown leaves + the list (Task 6 tests "Screen.destructor_detachesOverlaysStillShown" and + "Screen.overlayDestroyedWhileShown_leavesTheOverlayList"). +4. **Scroll during a drag** goes to whatever is under the pointer and leaves the capture in place (Task 6 test + "Screen.pointerCapture_neverTakesScrollEvents"). +5. **The default `Off` writes no mouse mode at all** — no 1000, 1002, 1003 or 1006 — so an existing program's + terminal keeps its text selection (Task 4 test "TerminalInput.posix.mouse_tracking_off_writes_no_mouse_mode"). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| core-cpp `src/core/tui/MouseTracking.hpp` (new) | `core::tui::MouseTracking { Off, Buttons, Drag, AnyMotion }`, in `core::tui_output` | +| core-cpp `src/core/tui/TerminalProtocols.hpp` | 1000/1002 constants, `mouseTrackingSet()`, `mouseTrackingReset()`, `appendMouseTrackingChange()` | +| core-cpp `src/core/tui/TerminalProtocols_test.cpp` | Byte-exact table of every transition | +| core-cpp `src/core/tui/TerminalInput.hpp`, `TerminalInput.cpp` | `setMouseTracking()`, `mouseTracking()`, the hover raise, `writeMouseTrackingChange()` | +| core-cpp `src/core/tui/posix/TerminalInput.cpp`, `windows/TerminalInput.cpp` | `enableProtocols()`/`disableProtocols()` write the mode | +| core-cpp `src/core/tui/Terminal.hpp`, `Terminal.cpp` | `Terminal::setMouseTracking()` forwards | +| core-cpp `src/core/tui/TerminalInput_test.cpp` (new) | Requested vs raised mode; `Terminal` forwarding | +| core-cpp `src/core/tui/posix/TerminalInput_test.cpp` (new) | The bytes the POSIX `TerminalInput` writes, read from a pipe | +| core-cpp `src/core/tui/VtParser_test.cpp` | SGR drag and any-motion reports | +| core-cpp `src/core/tui/Screen.hpp`, `Screen.cpp` | Public `componentAt`, overlay-first `hitTest`, pointer capture, `~Screen` detaching overlays | +| core-cpp `src/core/tui/Component.cpp` | Teardown tells the screen (`componentDetached`) | +| core-cpp `src/core/tui/Screen_test.cpp` | Hit-testing and capture cases | +| core-cpp `src/core/tui/CMakeLists.txt` | Header and test registration | +| core-cpp `.agent/reference/provenance.md` | Rows for the three new files | +| core-cpp `CHANGELOG.md`, `docs/modules/tui.md` | `[Unreleased]` entries; the module page | +| morph `CMakeLists.txt` | `MORPH_CORE_CPP_VERSION 0.7`, `GIT_TAG v0.7.0`, `VERSION 0.7.0` | +| morph `README.md` | The two lines that state the core-cpp version | +| morph `CHANGELOG.md` | `[Unreleased]` → Changed: "morph builds against core-cpp 0.7" | + +`cmake/morphConfig.cmake.in` needs no edit: it says `find_dependency(core-cpp @MORPH_CORE_CPP_VERSION@ CONFIG)`. + +## Build and test commands + +core-cpp (every command runs in `/Users/christianparpart/projects/core-cpp`; trees live in `out/build/`): + +```bash +cmake --preset clang-debug # once +cmake --build --preset clang-debug --target core-cpp-tui-test core-cpp-tui_output-test +./out/build/clang-debug/src/core/tui/core-cpp-tui-test "" +./out/build/clang-debug/src/core/tui/core-cpp-tui_output-test "" +ctest --preset clang-debug # everything +``` + +On macOS `clang-debug` uses Homebrew LLVM's `clang++`; if it does not configure, use `appleclang-debug` with the +same commands and `out/build/appleclang-debug/…`, and say which one ran. + +morph (every command runs in `/Users/christianparpart/projects/morph`): + +```bash +cmake -S . -B build/all -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_NET=ON -DMORPH_BUILD_FORMS_QML=ON +cmake --build build/all +ctest --test-dir build/all --output-on-failure +``` + +`MORPH_BUILD_TUI` and `MORPH_BUILD_QT_QUICK` do not exist yet (Parts 3 and 4 add them); CMake lists them under +"Manually-specified variables were not used", which is expected here. Configuring prints +`morph: warnings: ... strict=ON`; if it does not, stop and say so (CONTRIBUTING, "Warnings are errors"). + +--- + +### Task 1: Put core-cpp on a fresh `feature/mouse-tracking` branch + +**Files:** none changed. + +**Interfaces:** none. + +- [ ] **Step 1: Leave the abandoned branch cleanly** + +```bash +cd /Users/christianparpart/projects/core-cpp +git status --porcelain # expected: empty (feature/reactive-ui is clean and pushed) +git switch master +git pull --ff-only origin master +git log --oneline -1 # expected: 04dc0eb docs(changelog): open [Unreleased] after 0.6.0, or newer +git switch -c feature/mouse-tracking +``` + +If `git status --porcelain` is not empty, stop and ask the user — `feature/reactive-ui` is theirs to keep or drop. +If `master` moved past `04dc0eb`, run `git diff 04dc0eb..master --stat -- src/core/tui CHANGELOG.md` and re-read +every file this plan modifies before applying its code: the plan was written against `04dc0eb` (`v0.6.0` plus the +reopened `[Unreleased]`). + +- [ ] **Step 2: Install the pinned tools and record a green baseline** + +```bash +cd /Users/christianparpart/projects/core-cpp +python3 scripts/tool-versions.py --install +cmake --preset clang-debug && cmake --build --preset clang-debug && ctest --preset clang-debug +``` + +Expected: `100% tests passed` (or every failure named and present on `master` already — then stop and tell the +user; this part does not start on a red tree). Keep the summary line for the PR body. + +No commit. + +--- + +### Task 2: `VtParser` decodes drag and any-motion reports — pinned by tests + +The parser already turns `CSI < b ; x ; y M` with bit 32 into `MouseEvent{Move, button = b & 3}` +(`src/core/tui/VtParser.cpp`, `dispatchCsi`, the `else if (rawButton & 32)` arm). No test covers it; these tests +are what keep `Drag` and `AnyMotion` usable. + +**Files:** +- Modify: `src/core/tui/VtParser_test.cpp` — append at the end of the file +- Test: the same file + +**Interfaces:** +- Consumes: `core::tui::VtParser::feed(std::string_view) -> std::vector`, + `core::tui::MouseEvent { Type type; int button; int x; int y; Modifier modifiers; bool uiHandled; }` + (`src/core/tui/InputEvent.hpp`). +- Produces: nothing new. + +- [ ] **Step 1: Write the tests** + +Append to `src/core/tui/VtParser_test.cpp`: + +```cpp +// ============================================================================ +// SGR mouse reports (DEC 1006), as every tracking mode delivers them +// ============================================================================ + +namespace +{ + +/// @brief Parses a VT sequence and returns every MouseEvent in it, in order. +auto parseMice(std::string_view seq) -> std::vector +{ + auto parser = VtParser {}; + auto mice = std::vector {}; + for (auto const& event: parser.feed(seq)) + if (auto const* mouse = std::get_if(&event)) + mice.push_back(*mouse); + return mice; +} + +} // namespace + +TEST_CASE("VtParser.SGRMouse.motion_with_the_left_button_held_is_a_move_of_button_0", "[tui,vtparser]") +{ + // DEC 1002 and 1003 report motion with a button held as that button's code plus 32. + auto const mice = parseMice("\033[<32;10;5M"); + REQUIRE(mice.size() == 1); + CHECK(mice[0].type == MouseEvent::Type::Move); + CHECK(mice[0].button == 0); + CHECK(mice[0].x == 10); + CHECK(mice[0].y == 5); +} + +TEST_CASE("VtParser.SGRMouse.motion_with_no_button_held_is_a_move_of_button_3", "[tui,vtparser]") +{ + // Only DEC 1003 reports motion with no button held: code 3, "no button", plus 32. + auto const mice = parseMice("\033[<35;11;6M"); + REQUIRE(mice.size() == 1); + CHECK(mice[0].type == MouseEvent::Type::Move); + CHECK(mice[0].button == 3); + CHECK(mice[0].x == 11); + CHECK(mice[0].y == 6); +} + +TEST_CASE("VtParser.SGRMouse.motion_with_the_right_button_held_is_a_move_of_button_2", "[tui,vtparser]") +{ + auto const mice = parseMice("\033[<34;3;4M"); + REQUIRE(mice.size() == 1); + CHECK(mice[0].type == MouseEvent::Type::Move); + CHECK(mice[0].button == 2); +} + +TEST_CASE("VtParser.SGRMouse.a_shift_drag_carries_the_modifier", "[tui,vtparser]") +{ + // 36 = 32 (motion) | 4 (Shift) | 0 (left button). + auto const mice = parseMice("\033[<36;7;8M"); + REQUIRE(mice.size() == 1); + CHECK(mice[0].type == MouseEvent::Type::Move); + CHECK(mice[0].button == 0); + CHECK(mice[0].modifiers == Modifier::Shift); +} + +TEST_CASE("VtParser.SGRMouse.a_drag_is_a_press_moves_and_a_release", "[tui,vtparser]") +{ + auto const mice = parseMice("\033[<0;2;3M\033[<32;4;3M\033[<0;4;3m"); + REQUIRE(mice.size() == 3); + CHECK(mice[0].type == MouseEvent::Type::Press); + CHECK(mice[0].x == 2); + CHECK(mice[1].type == MouseEvent::Type::Move); + CHECK(mice[1].x == 4); + CHECK(mice[2].type == MouseEvent::Type::Release); + CHECK(mice[2].button == 0); + CHECK(mice[2].x == 4); + CHECK(mice[2].y == 3); +} +``` + +Add `#include ` to the file's standard includes, after ``. + +- [ ] **Step 2: Run them** + +Run: + +```bash +cmake --build --preset clang-debug --target core-cpp-tui-test \ + && ./out/build/clang-debug/src/core/tui/core-cpp-tui-test "VtParser.SGRMouse.*" +``` + +Expected: PASS, `All tests passed (… assertions in 5 test cases)`. These tests characterise existing behaviour, so +they pass before any change; Step 3 is what shows they measure it. + +- [ ] **Step 3: Mutation check** + +In `src/core/tui/VtParser.cpp`, inside `dispatchCsi`'s `else if (rawButton & 32)` arm (the `Move` event), change +`.button = buttonBits & 3,` to `.button = 0,`. Rebuild and rerun the command above. +Expected: FAIL in `motion_with_no_button_held_is_a_move_of_button_3` and +`motion_with_the_right_button_held_is_a_move_of_button_2`. Restore the line (`git diff src/core/tui/VtParser.cpp` +prints nothing afterwards). + +- [ ] **Step 4: Format and commit** + +```bash +python3 scripts/clang-format.py src/core/tui/VtParser_test.cpp +git add src/core/tui/VtParser_test.cpp +git commit -s -F - <<'EOF' +test(tui): VtParser decodes SGR motion with a button held, and with none + +DEC 1002 and 1003 report motion as the button code plus 32, and 1003 reports +motion with no button held as code 3. The parser already decodes both into +MouseEvent::Type::Move with that button; nothing pinned it, and the standard +mouse tracking modes depend on it. +EOF +``` + +No CHANGELOG entry: test-only (AGENT.md, workflow checklist 5). + +--- + +### Task 3: `MouseTracking` and the sequences that move a terminal between its modes + +**Files:** +- Create: `src/core/tui/MouseTracking.hpp` +- Modify: `src/core/tui/TerminalProtocols.hpp` — include the new header; add the constants and functions after the + passive-mouse-tracking constants (`DisablePassiveMouseTracking`) +- Modify: `src/core/tui/CMakeLists.txt` — `core_cpp_add_module(tui_output …)` `HEADERS` list +- Modify: `.agent/reference/provenance.md` — one row after `src/core/tui/Modifier.hpp` +- Modify: `CHANGELOG.md` — `## [Unreleased]` +- Test: `src/core/tui/TerminalProtocols_test.cpp` + +**Interfaces:** +- Consumes: `protocols::EnableAnyMotionTracking`, `DisableAnyMotionTracking`, `EnableSGRMouse`, `DisableSGRMouse` + (`src/core/tui/TerminalProtocols.hpp`). +- Produces: + - `core::tui::MouseTracking : std::uint8_t { Off, Buttons, Drag, AnyMotion }` in ``. + - `core::tui::protocols::EnableButtonTracking`, `DisableButtonTracking`, `EnableDragTracking`, + `DisableDragTracking` (`std::string_view` constants). + - `[[nodiscard]] constexpr auto protocols::mouseTrackingSet(MouseTracking) noexcept -> std::string_view`, + `[[nodiscard]] constexpr auto protocols::mouseTrackingReset(MouseTracking) noexcept -> std::string_view`. + - `inline void protocols::appendMouseTrackingChange(std::string& out, MouseTracking from, MouseTracking to)`. + +- [ ] **Step 1: Write the failing test** + +In `src/core/tui/TerminalProtocols_test.cpp`, add `#include ` after the +`catch_test_macros.hpp` include and `#include ` after ``, then append: + +```cpp +// ============================================================================ +// Mouse tracking modes (DEC 1000, 1002, 1003 with SGR 1006) +// ============================================================================ + +namespace +{ + +/// @brief One transition and the exact bytes it writes. +struct MouseTrackingChange +{ + core::tui::MouseTracking from; + core::tui::MouseTracking to; + std::string_view bytes; +}; + +} // namespace + +TEST_CASE("TerminalProtocols.mouse_tracking_change_byte_exact") +{ + using enum core::tui::MouseTracking; + auto const change = GENERATE(values({ + { Off, Off, "" }, + { Off, Buttons, "\033[?1000h\033[?1006h" }, + { Off, Drag, "\033[?1002h\033[?1006h" }, + { Off, AnyMotion, "\033[?1003h\033[?1006h" }, + { Buttons, Off, "\033[?1006l\033[?1000l" }, + { Buttons, Buttons, "" }, + { Buttons, Drag, "\033[?1000l\033[?1002h" }, + { Buttons, AnyMotion, "\033[?1000l\033[?1003h" }, + { Drag, Off, "\033[?1006l\033[?1002l" }, + { Drag, Buttons, "\033[?1002l\033[?1000h" }, + { Drag, Drag, "" }, + { Drag, AnyMotion, "\033[?1002l\033[?1003h" }, + { AnyMotion, Off, "\033[?1006l\033[?1003l" }, + { AnyMotion, Buttons, "\033[?1003l\033[?1000h" }, + { AnyMotion, Drag, "\033[?1003l\033[?1002h" }, + { AnyMotion, AnyMotion, "" }, + })); + CAPTURE(change.from, change.to); + auto out = std::string {}; + appendMouseTrackingChange(out, change.from, change.to); + CHECK(out == change.bytes); +} + +TEST_CASE("TerminalProtocols.mouse_tracking_change_appends") +{ + auto out = std::string { "x" }; + appendMouseTrackingChange(out, core::tui::MouseTracking::Off, core::tui::MouseTracking::Drag); + CHECK(out == "x\033[?1002h\033[?1006h"); +} + +TEST_CASE("TerminalProtocols.mouse_tracking_off_has_no_mode_sequence") +{ + CHECK(mouseTrackingSet(core::tui::MouseTracking::Off).empty()); + CHECK(mouseTrackingReset(core::tui::MouseTracking::Off).empty()); + STATIC_REQUIRE(mouseTrackingSet(core::tui::MouseTracking::Drag) == "\033[?1002h"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build --preset clang-debug --target core-cpp-tui_output-test` +Expected: compile error, `no member named 'MouseTracking' in namespace 'core::tui'` (and +`use of undeclared identifier 'appendMouseTrackingChange'`). + +- [ ] **Step 3: Implement** + +Create `src/core/tui/MouseTracking.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +namespace core::tui +{ + +/// @brief How much mouse input the terminal is asked to report. +/// +/// Reporting is opt-in: while a terminal reports the mouse, a click-and-drag there no longer selects +/// text (most terminals keep Shift+drag for selection), so the default asks for nothing. Each mode is +/// a DEC private mode, enabled together with SGR encoding (DEC 1006) so that coordinates are not +/// limited to 223 cells. The parser decodes every mode's reports into a @c MouseEvent. +enum class MouseTracking : std::uint8_t +{ + Off, ///< No reports (the default). + Buttons, ///< Press and release (DEC 1000). + Drag, ///< Press, release and motion while a button is held (DEC 1002). + AnyMotion, ///< Press, release and all motion (DEC 1003). +}; + +} // namespace core::tui +``` + +In `src/core/tui/TerminalProtocols.hpp`, add `#include ` as the first include (its own +group, above ``), and insert after the `DisablePassiveMouseTracking` line: + +```cpp +// Button-event tracking (mode 1000): press and release only. +constexpr auto EnableButtonTracking = "\033[?1000h"sv; ///< Enable press/release mouse tracking. +constexpr auto DisableButtonTracking = "\033[?1000l"sv; ///< Disable press/release mouse tracking. + +// Button-motion tracking (mode 1002): press, release, and motion while a button is held. +constexpr auto EnableDragTracking = "\033[?1002h"sv; ///< Enable drag mouse tracking. +constexpr auto DisableDragTracking = "\033[?1002l"sv; ///< Disable drag mouse tracking. + +/// @brief Returns the DEC private mode set that asks for @p mode. +/// @param mode The tracking mode. +/// @return `CSI ? 1000 h`, `CSI ? 1002 h` or `CSI ? 1003 h`; empty for @c MouseTracking::Off. +[[nodiscard]] constexpr auto mouseTrackingSet(MouseTracking mode) noexcept -> std::string_view +{ + switch (mode) + { + case MouseTracking::Off: return {}; + case MouseTracking::Buttons: return EnableButtonTracking; + case MouseTracking::Drag: return EnableDragTracking; + case MouseTracking::AnyMotion: return EnableAnyMotionTracking; + } + return {}; +} + +/// @brief Returns the DEC private mode reset that ends @p mode. +/// @param mode The tracking mode. +/// @return `CSI ? 1000 l`, `CSI ? 1002 l` or `CSI ? 1003 l`; empty for @c MouseTracking::Off. +[[nodiscard]] constexpr auto mouseTrackingReset(MouseTracking mode) noexcept -> std::string_view +{ + switch (mode) + { + case MouseTracking::Off: return {}; + case MouseTracking::Buttons: return DisableButtonTracking; + case MouseTracking::Drag: return DisableDragTracking; + case MouseTracking::AnyMotion: return DisableAnyMotionTracking; + } + return {}; +} + +/// @brief Appends the sequences that move a terminal from mouse tracking @p from to @p to. +/// +/// From @c MouseTracking::Off: the mode's set, then SGR encoding (1006). To @c MouseTracking::Off: +/// the reverse, 1006's reset and then the mode's. Between two modes: the old mode's reset and the new +/// one's set, leaving 1006 on. Nothing when the two are equal. Enabling a terminal's protocols is the +/// change from Off and disabling them the change to Off, so what is reset always mirrors what was set. +/// @param out Destination to append to. +/// @param from The mode the terminal is in. +/// @param to The mode it is to be in. +inline void appendMouseTrackingChange(std::string& out, MouseTracking from, MouseTracking to) +{ + if (from == to) + return; + if (from == MouseTracking::Off) + { + out.append(mouseTrackingSet(to)); + out.append(EnableSGRMouse); + return; + } + if (to == MouseTracking::Off) + { + out.append(DisableSGRMouse); + out.append(mouseTrackingReset(from)); + return; + } + out.append(mouseTrackingReset(from)); + out.append(mouseTrackingSet(to)); +} +``` + +`EnableSGRMouse`/`DisableSGRMouse` are declared above the any-motion constants, so the order compiles. + +In `src/core/tui/CMakeLists.txt`, in `core_cpp_add_module(tui_output … HEADERS …)`, add `MouseTracking.hpp` +between `Error.hpp` and `SgrBuilder.hpp`. + +In `.agent/reference/provenance.md`, after the `src/core/tui/Modifier.hpp` row, add: + +```markdown +| `src/core/tui/MouseTracking.hpp` | origin: core-cpp | - | - | - | +``` + +In `CHANGELOG.md`, replace the `## [Unreleased]` heading (its section is empty on `master`) with: + +```markdown +## [Unreleased] + +### Added + +- **`core::tui::MouseTracking` and `protocols::appendMouseTrackingChange()`** (``, + in `core::tui_output`): the standard mouse tracking modes -- `Buttons` (DEC 1000), `Drag` (1002) and + `AnyMotion` (1003), with `Off` the default -- and the one function that writes a change between two of + them: from `Off` the mode's set and then SGR encoding (1006), to `Off` the reverse, and between two modes + only the swap. `protocols::EnableButtonTracking`, `EnableDragTracking` and their `Disable` twins join the + existing 1003 constants, with `mouseTrackingSet()` and `mouseTrackingReset()` mapping a mode to its own. +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build --preset clang-debug --target core-cpp-tui_output-test \ + && ./out/build/clang-debug/src/core/tui/core-cpp-tui_output-test "TerminalProtocols.mouse*" +``` + +Expected: PASS, `All tests passed (… assertions in 3 test cases)` (the generator makes the first case 16 runs). + +Mutation check: in `appendMouseTrackingChange`, delete the `if (from == to) return;` pair of lines; rebuild and +rerun. Expected: FAIL in `mouse_tracking_change_byte_exact` for `{ Off, Off, "" }` (it writes +`"\033[?1006h"`). Restore the lines. + +- [ ] **Step 5: Format and commit** + +```bash +python3 scripts/clang-format.py src/core/tui/MouseTracking.hpp src/core/tui/TerminalProtocols.hpp \ + src/core/tui/TerminalProtocols_test.cpp +cmake --build --preset clang-debug && ctest --preset clang-debug -L hygiene +git add src/core/tui/MouseTracking.hpp src/core/tui/TerminalProtocols.hpp src/core/tui/TerminalProtocols_test.cpp \ + src/core/tui/CMakeLists.txt .agent/reference/provenance.md CHANGELOG.md +git commit -s -F - <<'EOF' +feat(tui): MouseTracking, and the sequences that move a terminal between its modes + +The standard mouse tracking modes -- DEC 1000, 1002 and 1003, each with SGR +encoding (1006) -- as an enum in core::tui_output, and one pure function that +writes the change between any two of them. Enabling is the change from Off and +disabling the change to Off, so the resets always mirror the sets; between two +modes only the swap is written. The whole table is pinned byte for byte. +EOF +``` + +`ctest -L hygiene` is there for the provenance row: expected `100% tests passed`. + +--- + +### Task 4: `TerminalInput` and `Terminal` ask the terminal for the requested mouse tracking + +**Files:** +- Modify: `src/core/tui/TerminalInput.hpp` — include, the `setAnyMotionTracking` doc, two new public members after + it, one private member after `_anyMotionTracking`, one private function after `writeProtocol` +- Modify: `src/core/tui/TerminalInput.cpp` — replace `setAnyMotionTracking`, add three definitions +- Modify: `src/core/tui/posix/TerminalInput.cpp` — `enableProtocols()` and `disableProtocols()` +- Modify: `src/core/tui/windows/TerminalInput.cpp` — `enableProtocols()` and `disableProtocols()` +- Modify: `src/core/tui/Terminal.hpp` — one public member after `isSuspended()` +- Modify: `src/core/tui/Terminal.cpp` — its definition after `Terminal::isSuspended()` +- Modify: `src/core/tui/CMakeLists.txt` — `core_cpp_add_test(tui …)` `SOURCES` and `SOURCES_POSIX` +- Modify: `.agent/reference/provenance.md` — two rows +- Modify: `CHANGELOG.md`, `docs/modules/tui.md` +- Test: `src/core/tui/TerminalInput_test.cpp` (new), `src/core/tui/posix/TerminalInput_test.cpp` (new) + +**Interfaces:** +- Consumes: Task 3's `MouseTracking` and `protocols::appendMouseTrackingChange`; `TerminalInput::writeProtocol`, + `_rawMode`, `_anyMotionTracking`, `enableProtocols`, `disableProtocols` (`src/core/tui/TerminalInput.hpp`). +- Produces: + - `void core::tui::TerminalInput::setMouseTracking(MouseTracking mode);` + - `[[nodiscard]] auto core::tui::TerminalInput::mouseTracking() const noexcept -> MouseTracking;` + - `void core::tui::Terminal::setMouseTracking(MouseTracking mode);` + - `TerminalInput::setAnyMotionTracking(bool)` keeps its signature and now raises the requested mode. + +- [ ] **Step 1: Write the failing tests** + +Create `src/core/tui/TerminalInput_test.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The mouse tracking mode TerminalInput asks for, without a terminal: nothing here enables the +// protocols, so nothing is written. posix/TerminalInput_test.cpp reads the bytes themselves. + +#include +#include +#include +#include + +#include + +#include + +using core::tui::MockTerminalOutput; +using core::tui::MouseTracking; +using core::tui::Terminal; +using core::tui::TerminalInput; + +TEST_CASE("TerminalInput.mouse_tracking_defaults_to_off", "[tui]") +{ + auto const input = TerminalInput {}; + CHECK(input.mouseTracking() == MouseTracking::Off); +} + +TEST_CASE("TerminalInput.mouse_tracking_is_the_requested_mode_raised_while_hover_is_on", "[tui]") +{ + auto input = TerminalInput {}; + input.setMouseTracking(MouseTracking::Drag); + CHECK(input.mouseTracking() == MouseTracking::Drag); + + input.setAnyMotionTracking(true); + CHECK(input.mouseTracking() == MouseTracking::AnyMotion); + + // The request made while raised is kept, and applies again once hover is off. + input.setMouseTracking(MouseTracking::Buttons); + CHECK(input.mouseTracking() == MouseTracking::AnyMotion); + input.setAnyMotionTracking(false); + CHECK(input.mouseTracking() == MouseTracking::Buttons); +} + +TEST_CASE("Terminal.setMouseTracking_forwards_to_its_input", "[tui]") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + terminal.setMouseTracking(MouseTracking::Buttons); + CHECK(terminal.input().mouseTracking() == MouseTracking::Buttons); +} +``` + +Create `src/core/tui/posix/TerminalInput_test.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The bytes the POSIX TerminalInput writes when it enables its protocols, when the mouse tracking +// mode changes while they are enabled, and when it disables them, read back from a pipe that stands +// in for standard output. TerminalInput writes to STDOUT_FILENO and reads STDIN_FILENO and has no +// other seam for either, so each case swaps the two descriptors for the length of the run, as +// TerminalHangup_test.cpp does for standard input. Standard input is /dev/null: raw mode is then +// refused by the kernel, which TerminalInput ignores, and nothing is read. + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +using core::tui::MouseTracking; +using core::tui::TerminalInput; +namespace protocols = core::tui::protocols; + +namespace +{ + +/// Standard output into a pipe and standard input from /dev/null, until destroyed. +class RedirectedStandardStreams +{ + public: + RedirectedStandardStreams() + { + // Anything the test runner buffered goes to the real output first, not into the pipe. + std::cout.flush(); + std::fflush(stdout); + if (::pipe(_pipe.data()) != 0) + return; + if (auto const flags = ::fcntl(_pipe[0], F_GETFL, 0); flags != -1) + ::fcntl(_pipe[0], F_SETFL, flags | O_NONBLOCK); + _savedOut = ::dup(STDOUT_FILENO); + _savedIn = ::dup(STDIN_FILENO); + auto const devNull = ::open("/dev/null", O_RDONLY); + _isRedirected = _savedOut >= 0 && _savedIn >= 0 && devNull >= 0 + && ::dup2(devNull, STDIN_FILENO) == STDIN_FILENO + && ::dup2(_pipe[1], STDOUT_FILENO) == STDOUT_FILENO; + if (devNull >= 0) + ::close(devNull); + } + + ~RedirectedStandardStreams() + { + if (_savedOut >= 0) + { + ::dup2(_savedOut, STDOUT_FILENO); + ::close(_savedOut); + } + if (_savedIn >= 0) + { + ::dup2(_savedIn, STDIN_FILENO); + ::close(_savedIn); + } + for (auto const end: _pipe) + if (end >= 0) + ::close(end); + } + + RedirectedStandardStreams(RedirectedStandardStreams const&) = delete; + RedirectedStandardStreams& operator=(RedirectedStandardStreams const&) = delete; + RedirectedStandardStreams(RedirectedStandardStreams&&) = delete; + RedirectedStandardStreams& operator=(RedirectedStandardStreams&&) = delete; + + /// Whether both descriptors were swapped; a run that was not measured nothing. + [[nodiscard]] bool isRedirected() const noexcept { return _isRedirected; } + + /// Everything written to standard output since the last call. + [[nodiscard]] std::string take() + { + auto bytes = std::string {}; + auto chunk = std::array {}; + auto count = ::read(_pipe[0], chunk.data(), chunk.size()); + while (count > 0) + { + bytes.append(chunk.data(), static_cast(count)); + count = ::read(_pipe[0], chunk.data(), chunk.size()); + } + return bytes; + } + + private: + std::array _pipe { -1, -1 }; + int _savedOut = -1; + int _savedIn = -1; + bool _isRedirected = false; +}; + +/// What one TerminalInput wrote: when it enabled its protocols, during a change made while they were +/// enabled, and when it disabled them. +struct ProtocolRun +{ + bool isRedirected = false; + bool isInitialized = false; + std::string enabled; + std::string changed; + std::string disabled; +}; + +/// Runs a TerminalInput that asks for @p requested from initialize() to shutdown(), calling @p change +/// in between. The checks happen after this returns, once standard output is the test runner's again. +template +[[nodiscard]] ProtocolRun runProtocols(MouseTracking requested, Change change) +{ + auto run = ProtocolRun {}; + auto streams = RedirectedStandardStreams {}; + run.isRedirected = streams.isRedirected(); + if (!run.isRedirected) + return run; + + auto input = TerminalInput {}; + input.setMouseTracking(requested); + run.isInitialized = input.initialize().has_value(); + run.enabled = streams.take(); + change(input); + run.changed = streams.take(); + input.shutdown(); + run.disabled = streams.take(); + return run; +} + +/// One mode and the set and reset it is written with. +struct ModeBytes +{ + MouseTracking mode; + std::string_view set; + std::string_view reset; +}; + +} // namespace + +TEST_CASE("TerminalInput.posix.mouse_tracking_off_writes_no_mouse_mode", "[tui]") +{ + auto const run = runProtocols(MouseTracking::Off, [](TerminalInput&) {}); + REQUIRE(run.isRedirected); + REQUIRE(run.isInitialized); + + // The run saw enableProtocols(): Contour's passive mode is enabled as it always was. + CHECK(run.enabled.contains(protocols::EnablePassiveMouseTracking)); + for (auto const* const mode: { "1000", "1002", "1003", "1006" }) + { + CAPTURE(mode); + CHECK_FALSE(run.enabled.contains(std::string("\033[?") + mode)); + CHECK_FALSE(run.disabled.contains(std::string("\033[?") + mode)); + } +} + +TEST_CASE("TerminalInput.posix.a_mouse_tracking_mode_is_set_with_sgr_and_reset_in_reverse", "[tui]") +{ + auto const expected = GENERATE(values({ + { MouseTracking::Buttons, "\033[?1000h", "\033[?1000l" }, + { MouseTracking::Drag, "\033[?1002h", "\033[?1002l" }, + { MouseTracking::AnyMotion, "\033[?1003h", "\033[?1003l" }, + })); + CAPTURE(expected.mode); + auto const run = runProtocols(expected.mode, [](TerminalInput&) {}); + REQUIRE(run.isRedirected); + REQUIRE(run.isInitialized); + + CHECK(run.enabled.contains(std::string(expected.set) + std::string(protocols::EnableSGRMouse))); + CHECK(run.disabled.contains(std::string(protocols::DisableSGRMouse) + std::string(expected.reset))); + for (auto const other: { protocols::EnableButtonTracking, protocols::EnableDragTracking, + protocols::EnableAnyMotionTracking }) + if (other != expected.set) + CHECK_FALSE(run.enabled.contains(other)); + CHECK(run.changed.empty()); +} + +TEST_CASE("TerminalInput.posix.a_mode_change_while_enabled_writes_only_the_transition", "[tui]") +{ + auto const run = + runProtocols(MouseTracking::Buttons, [](TerminalInput& input) { input.setMouseTracking(MouseTracking::Drag); }); + REQUIRE(run.isRedirected); + REQUIRE(run.isInitialized); + CHECK(run.changed == "\033[?1000l\033[?1002h"); + CHECK(run.disabled.contains("\033[?1006l\033[?1002l")); +} + +TEST_CASE("TerminalInput.posix.hover_raises_the_requested_mode_to_any_motion", "[tui]") +{ + auto raised = MouseTracking::Off; + auto const run = runProtocols(MouseTracking::Drag, [&raised](TerminalInput& input) { + input.setAnyMotionTracking(true); + raised = input.mouseTracking(); + }); + REQUIRE(run.isRedirected); + REQUIRE(run.isInitialized); + CHECK(raised == MouseTracking::AnyMotion); + CHECK(run.changed == "\033[?1002l\033[?1003h"); + CHECK(run.disabled.contains("\033[?1006l\033[?1003l")); +} +``` + +Register both in `src/core/tui/CMakeLists.txt`'s `core_cpp_add_test(tui …)`: add `TerminalInput_test.cpp` to +`SOURCES` between `TerminalQuery_test.cpp` and `TreeTableView_test.cpp`, and `posix/TerminalInput_test.cpp` to +`SOURCES_POSIX` after `posix/TerminalHangup_test.cpp`. Update that block's comment: replace +"posix/TerminalHangup_test.cpp is POSIX-only: it hangs up a pseudo-terminal." with +"posix/TerminalHangup_test.cpp and posix/TerminalInput_test.cpp are POSIX-only: one hangs up a pseudo-terminal, +the other swaps standard output for a pipe with dup2." + +In `.agent/reference/provenance.md`, add after the `src/core/tui/TerminalInput.hpp` row: + +```markdown +| `src/core/tui/TerminalInput_test.cpp` | origin: core-cpp | - | - | - | +``` + +and after the `src/core/tui/posix/TerminalInput.cpp` row: + +```markdown +| `src/core/tui/posix/TerminalInput_test.cpp` | origin: core-cpp | - | - | the protocol bytes, read from a pipe that stands in for standard output | +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build --preset clang-debug --target core-cpp-tui-test` +Expected: compile error, `no member named 'setMouseTracking' in 'core::tui::TerminalInput'` (and +`'mouseTracking'`, and `no member named 'setMouseTracking' in 'core::tui::Terminal'`). + +- [ ] **Step 3: Implement** + +`src/core/tui/TerminalInput.hpp`: add `#include ` after ``. +Replace the whole `setAnyMotionTracking` declaration and its comment with: + +```cpp + /// @brief Raises the mouse tracking mode to any-motion (mode 1003) while @p enabled, for hover + /// tooltips. + /// + /// Call it only after confirming passive mouse tracking (mode 2029) via DECRQPM, so that a terminal + /// without it is not asked to report every motion. While raised, @c mouseTracking() answers + /// @c MouseTracking::AnyMotion whatever @c setMouseTracking() requested; lowered, the requested + /// mode applies again. The change is written at once when the protocols are enabled. + /// @param enabled True to raise the mode, false to return to the requested one. + void setAnyMotionTracking(bool enabled); + + /// @brief Sets how much mouse input the terminal is asked to report. + /// + /// Effective at the next enableProtocols() -- @c initialize() or @c resume() -- and at once when + /// the protocols are enabled now, by writing only the change from the current mode. The default + /// is @c MouseTracking::Off: a terminal that reports the mouse stops selecting text on a + /// click-and-drag, so an application opts in. + /// @param mode The mode the application asks for. + void setMouseTracking(MouseTracking mode); + + /// @brief Returns the mode the terminal is asked for. + /// @return The mode @c setMouseTracking() requested, or @c MouseTracking::AnyMotion while + /// @c setAnyMotionTracking() raises it. + [[nodiscard]] auto mouseTracking() const noexcept -> MouseTracking; +``` + +In the private members, replace the `_anyMotionTracking` line with: + +```cpp + bool _anyMotionTracking = false; ///< True while hover tracking raises the mode to any-motion (1003). + MouseTracking _mouseTracking = MouseTracking::Off; ///< The mode setMouseTracking() requested. +``` + +and after `void writeProtocol(std::string_view data) const;` add: + +```cpp + + /// @brief Writes the sequences that move the terminal from mouse tracking @p from to @p to. + /// @param from The mode the terminal is in. + /// @param to The mode it is to be in. + void writeMouseTrackingChange(MouseTracking from, MouseTracking to) const; +``` + +`src/core/tui/TerminalInput.cpp`: add `#include ` after the `` include +(own group), and replace `TerminalInput::setAnyMotionTracking` with: + +```cpp +void TerminalInput::setAnyMotionTracking(bool enabled) +{ + auto const before = mouseTracking(); + _anyMotionTracking = enabled; + if (_rawMode) + writeMouseTrackingChange(before, mouseTracking()); +} + +void TerminalInput::setMouseTracking(MouseTracking mode) +{ + auto const before = mouseTracking(); + _mouseTracking = mode; + if (_rawMode) + writeMouseTrackingChange(before, mouseTracking()); +} + +auto TerminalInput::mouseTracking() const noexcept -> MouseTracking +{ + return _anyMotionTracking ? MouseTracking::AnyMotion : _mouseTracking; +} + +void TerminalInput::writeMouseTrackingChange(MouseTracking from, MouseTracking to) const +{ + auto sequence = std::string {}; + protocols::appendMouseTrackingChange(sequence, from, to); + if (!sequence.empty()) + writeProtocol(sequence); +} +``` + +`_rawMode` is false while suspended (`suspend()` calls `disableRawMode()`), so a change made then is written by +`resume()`'s `enableProtocols()`, as the hover flag's was. + +`src/core/tui/posix/TerminalInput.cpp`, in `enableProtocols()`, replace + +```cpp + if (_anyMotionTracking) + writeProtocol(protocols::EnableAnyMotionTracking); +``` + +with + +```cpp + writeMouseTrackingChange(MouseTracking::Off, mouseTracking()); +``` + +and in `disableProtocols()` replace + +```cpp + if (_anyMotionTracking) + writeProtocol(protocols::DisableAnyMotionTracking); +``` + +with + +```cpp + writeMouseTrackingChange(mouseTracking(), MouseTracking::Off); +``` + +`src/core/tui/windows/TerminalInput.cpp`: the same two replacements in its `enableProtocols()` and +`disableProtocols()`. + +`src/core/tui/Terminal.hpp`, after the `isSuspended()` declaration: + +```cpp + + /// @brief Sets how much mouse input the terminal is asked to report. + /// + /// Forwards to @c TerminalInput::setMouseTracking(): effective at @c initialize() when called + /// before it, and at once on an initialized terminal. A terminal over a mock output never enables + /// its protocols, so there the mode is only recorded. + /// @param mode The mode the application asks for. + void setMouseTracking(MouseTracking mode); +``` + +`src/core/tui/Terminal.cpp`, after `Terminal::isSuspended()`: + +```cpp +void Terminal::setMouseTracking(MouseTracking mode) +{ + _input.setMouseTracking(mode); +} +``` + +The hover path in `posix/Terminal.cpp` (`if (isChangeable(queryDecMode(2029))) _input.setAnyMotionTracking(true);`) +is unchanged: it now raises the requested mode. Update its comment block to: + +```cpp + // Detect passive mouse tracking support (DEC mode 2029). Where the terminal recognises it, hover + // tooltips raise the mouse tracking mode to any-motion (1003); a terminal that does not silently + // ignored mode 2029 in enableProtocols() and is not asked to report every motion. +``` + +`CHANGELOG.md`, append to `### Added`: + +```markdown +- **`TerminalInput::setMouseTracking()` and `Terminal::setMouseTracking()`: standard mouse tracking.** + core::tui enabled only Contour's passive mode 2029, so xterm, kitty, WezTerm, iTerm2 and Windows Terminal + reported no mouse events at all. An application now asks for a `MouseTracking` mode, which + `enableProtocols()` writes with SGR encoding and `disableProtocols()` resets in reverse order; a change + while the protocols are enabled writes only the transition. The default stays `Off`, so no existing + program changes what a click-and-drag does in its terminal. The hover path that enables 1003 once the + terminal confirms 2029 now raises the requested mode to `AnyMotion`, and so also writes 1006; + `TerminalInput::mouseTracking()` answers the mode in effect. +``` + +`docs/modules/tui.md`, in the `## core::tui` list, after the `- **Input.** …` bullet, insert: + +```markdown +- **Mouse.** `TerminalInput::setMouseTracking()`, or `Terminal::setMouseTracking()`, asks the terminal for a + `MouseTracking` mode: `Buttons` (DEC 1000), `Drag` (1002) or `AnyMotion` (1003), each with SGR encoding + (1006). The default is `Off`, because a terminal that reports the mouse stops selecting text on a + click-and-drag; an application opts in. Contour's passive mode 2029 is enabled either way, and where the + terminal confirms it, hover tooltips raise the mode to `AnyMotion` (`mouseTracking()` answers the raised + mode). `VtParser` decodes every mode's reports into `MouseEvent`s: motion with a button held is a `Move` + of that button, motion with none a `Move` of button 3. +``` + +and in the `## core::tui_output` list, replace + +```markdown + win32-input-mode, OSC 8), `appendHyperlinkOpen()`, and `parseSixelFromDeviceAttributes()`, which + reads a DA1 answer. +``` + +with + +```markdown + win32-input-mode, OSC 8), `appendHyperlinkOpen()`, `appendMouseTrackingChange()`, which writes the change + between two `MouseTracking` modes, and `parseSixelFromDeviceAttributes()`, which reads a DA1 answer. +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build --preset clang-debug --target core-cpp-tui-test \ + && ./out/build/clang-debug/src/core/tui/core-cpp-tui-test "TerminalInput.*,Terminal.setMouseTracking*" +``` + +Expected: PASS, `All tests passed (… assertions in 7 test cases)` on POSIX (3 portable, 4 POSIX). + +Mutation check: in `src/core/tui/posix/TerminalInput.cpp`'s `enableProtocols()`, delete +`writeMouseTrackingChange(MouseTracking::Off, mouseTracking());`; rebuild and rerun. Expected: FAIL in +`a_mouse_tracking_mode_is_set_with_sgr_and_reset_in_reverse` (`run.enabled.contains(...)` is false for every +mode). Restore the line. Then, to show the Off case is not vacuous, add `"2029"` to its +`{ "1000", "1002", "1003", "1006" }` list and rerun: expected FAIL on that entry, because the capture does hold +`\033[?2029h`. Restore the list. + +Windows: the two `windows/TerminalInput.cpp` edits are the same calls as POSIX's and are compiled and run only by +CI's Windows legs. Its `enableProtocols()` cannot be captured the same way (`initialize()` refuses a standard +output that is not a console), so what it writes is **inferred from the shared helper, not measured** — say so in +the pull request body. + +- [ ] **Step 5: Format and commit** + +```bash +python3 scripts/clang-format.py src/core/tui/TerminalInput.hpp src/core/tui/TerminalInput.cpp \ + src/core/tui/posix/TerminalInput.cpp src/core/tui/windows/TerminalInput.cpp src/core/tui/Terminal.hpp \ + src/core/tui/Terminal.cpp src/core/tui/posix/Terminal.cpp src/core/tui/TerminalInput_test.cpp \ + src/core/tui/posix/TerminalInput_test.cpp +cmake --build --preset clang-debug && ctest --preset clang-debug +git add src/core/tui .agent/reference/provenance.md CHANGELOG.md docs/modules/tui.md +git commit -s -F - <<'EOF' +feat(tui): TerminalInput and Terminal ask the terminal for the requested mouse tracking + +core::tui enabled only Contour's passive mode 2029, so a standard terminal +reported no mouse at all. setMouseTracking() asks for DEC 1000, 1002 or 1003 +with SGR 1006, written when the protocols are enabled and reset in reverse when +they are disabled; a change while enabled writes only the transition. The +default is Off, so no existing program loses its terminal's text selection. +The 2029 hover path now raises the requested mode to AnyMotion. + +The POSIX bytes are read back from a pipe standing in for standard output; +the Windows arm makes the same calls and is covered by CI only. +EOF +``` + +--- + +### Task 5: `Screen::componentAt` is public, and a mouse event over an overlay goes to the overlay + +Today `Screen::componentAt` is private, ignores overlays, and returns the root for a cell no child covers. Spec 0 +§3 wants it public, overlay-first, and null for an empty cell. Dispatch uses the same lookup, so an overlay +(a popup or dialog shown with `showOverlay`) now receives the mouse events over it; the screen's own tooltip is +excluded, because it sits under the pointer and would take the hover away from the component it describes. The +internal lookup keeps answering the root for an empty cell, so `HoverState` sees exactly what it saw before. + +**Files:** +- Modify: `src/core/tui/Screen.hpp` — a public block after the overlay system; the private hit-testing block +- Modify: `src/core/tui/Screen.cpp` — `componentAt` becomes `hitTest` plus a public `componentAt`; + `dispatchMouseEvent` calls `hitTest` +- Modify: `CHANGELOG.md` +- Test: `src/core/tui/Screen_test.cpp` — append + +**Interfaces:** +- Consumes: `Component::screenBounds()`, `visible()`, `children()`, `zIndex()` (`src/core/tui/Component.hpp`); + `Screen::showOverlay`, `showTooltip`, `isTooltipVisible`, `draw` (`src/core/tui/Screen.hpp`). +- Produces: `[[nodiscard]] Component* core::tui::Screen::componentAt(int row, int col) const;` (public); + private `[[nodiscard]] Component* Screen::hitTest(int row, int col) const;`. + +- [ ] **Step 1: Write the failing tests** + +Add `#include ` and `#include ` to `src/core/tui/Screen_test.cpp`'s standard includes, then append: + +```cpp +// ============================================================================ +// Hit testing and pointer capture +// ============================================================================ + +namespace +{ + +/// @brief Records every mouse event it receives, in the coordinates it receives them in. +struct MouseRecorder: Component +{ + EventResult pressResult = EventResult::Handled; ///< What a press returns. + Size size { .width = 10, .height = 3 }; ///< Preferred size, which an overlay is shown at. + std::vector received; ///< Every mouse event, in order. + + void render(Canvas& /*canvas*/) override {} + + [[nodiscard]] Size preferredSize() const override { return size; } + + EventResult onEvent(InputEvent const& event) override + { + auto const* mouse = std::get_if(&event); + if (mouse == nullptr) + return EventResult::Ignored; + received.push_back(*mouse); + return mouse->type == MouseEvent::Type::Press ? pressResult : EventResult::Handled; + } +}; + +/// @brief A mouse event at a 1-based terminal cell, as the parser delivers it. +[[nodiscard]] InputEvent mouseAt(MouseEvent::Type type, int x, int y) +{ + return InputEvent { MouseEvent { .type = type, .button = 0, .x = x, .y = y } }; +} + +} // namespace + +TEST_CASE("Screen.componentAt_findsTheDeepestVisibleComponent") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto panel = MouseRecorder {}; + auto leaf = MouseRecorder {}; + auto hidden = MouseRecorder {}; + screen.root().addChild(panel, LayoutParams { .area = { .x = 2, .y = 1, .width = 20, .height = 5 } }); + panel.addChild(leaf, LayoutParams { .area = { .x = 1, .y = 1, .width = 5, .height = 2 } }); + screen.root().addChild(hidden, + LayoutParams { .area = { .x = 30, .y = 1, .width = 5, .height = 2 }, .visible = false }); + screen.draw(); + + CHECK(screen.componentAt(2, 4) == &leaf); // leaf covers columns 3..7, rows 2..3 + CHECK(screen.componentAt(1, 2) == &panel); // the panel's own top-left cell + CHECK(screen.componentAt(1, 31) == nullptr); // only the invisible component is there + CHECK(screen.componentAt(20, 70) == nullptr); // nothing but the root +} + +TEST_CASE("Screen.componentAt_prefersTheHigherZIndex") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto high = MouseRecorder {}; + auto low = MouseRecorder {}; + // Added first, so insertion order alone would put it underneath. + screen.root().addChild(high, LayoutParams { .area = { .x = 5, .y = 0, .width = 10, .height = 3 }, .zIndex = 1 }); + screen.root().addChild(low, LayoutParams { .area = { .x = 0, .y = 0, .width = 10, .height = 3 } }); + screen.draw(); + + CHECK(screen.componentAt(0, 6) == &high); + CHECK(screen.componentAt(0, 2) == &low); +} + +TEST_CASE("Screen.componentAt_findsOverlaysBeforeTheTree") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto tree = MouseRecorder {}; + auto first = MouseRecorder {}; + auto second = MouseRecorder {}; + screen.root().addChild(tree, LayoutParams { .area = { .x = 0, .y = 0, .width = 40, .height = 10 } }); + screen.showOverlay(first, Point { .x = 5, .y = 2 }); // columns 5..14, rows 2..4 + screen.showOverlay(second, Point { .x = 8, .y = 3 }); // columns 8..17, rows 3..5 + screen.draw(); + + CHECK(screen.componentAt(2, 6) == &first); + CHECK(screen.componentAt(4, 10) == &second); // shown last, so on top where they overlap + CHECK(screen.componentAt(0, 0) == &tree); + + // Showing an overlay again moves it but keeps its place in the stack. + screen.showOverlay(first, Point { .x = 5, .y = 2 }); + screen.draw(); + CHECK(screen.componentAt(4, 10) == &second); +} + +TEST_CASE("Screen.componentAt_neverReturnsTheTooltip") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto tree = MouseRecorder {}; + screen.root().addChild(tree, LayoutParams { .area = { .x = 0, .y = 0, .width = 80, .height = 10 } }); + screen.showTooltip("a tooltip", Point { .x = 0, .y = 0 }); // drawn from row 1 down + screen.draw(); + + REQUIRE(screen.isTooltipVisible()); + CHECK(screen.componentAt(1, 1) == &tree); +} + +TEST_CASE("Screen.mousePress_overAnOverlayGoesToTheOverlay") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto tree = MouseRecorder {}; + auto overlay = MouseRecorder {}; + screen.root().addChild(tree, LayoutParams { .area = { .x = 0, .y = 0, .width = 40, .height = 10 } }); + screen.showOverlay(overlay, Point { .x = 5, .y = 2 }); + screen.draw(); + + CHECK(screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 7, 4)) == EventResult::Handled); // cell (3, 6) + + REQUIRE(overlay.received.size() == 1); + CHECK(overlay.received[0].x == 2); // overlay-relative, 1-based, as for any component + CHECK(overlay.received[0].y == 2); + CHECK(tree.received.empty()); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build --preset clang-debug --target core-cpp-tui-test` +Expected: compile error, `'componentAt' is a private member of 'core::tui::Screen'`. + +- [ ] **Step 3: Implement** + +`src/core/tui/Screen.hpp`: after the `isOverlayVisible(...)` declaration (end of "Overlay System"), insert: + +```cpp + + // --- Hit Testing --- + + /// Returns the top-most visible component at a cell, as the last draw() laid the screen out. + /// + /// Overlays come first, in the order they are drawn, so the one shown last is on top; then the + /// tree, siblings in reverse z-order and the deepest descendant that contains the cell. The + /// screen's own tooltip is never returned. Mouse events are routed by the same lookup; a drag + /// source asks it, at the release, which component lies under the pointer. + /// @param row 0-based row, in the coordinates of Component::screenBounds() (rows from the top of + /// the inline content in Viewport::Inline). + /// @param col 0-based column, in the same coordinates. + /// @return The component, or nullptr when nothing but the root covers the cell. + [[nodiscard]] Component* componentAt(int row, int col) const; +``` + +In the private `// Hit testing` block, replace + +```cpp + [[nodiscard]] Component* componentAt(int row, int col) const; +``` + +with + +```cpp + /// componentAt() without its last step: an empty cell answers the root, which is what mouse + /// dispatch and the hover state expect. + /// @param row 0-based row, as for componentAt(). + /// @param col 0-based column, as for componentAt(). + /// @return The top-most visible component at the cell, the root, or nullptr outside the root. + [[nodiscard]] Component* hitTest(int row, int col) const; +``` + +`src/core/tui/Screen.cpp`: replace the definition of `Screen::componentAt` with: + +```cpp +Component* Screen::componentAt(int row, int col) const +{ + auto* const found = hitTest(row, col); + return found == _root.get() ? nullptr : found; +} + +Component* Screen::hitTest(int row, int col) const +{ + // Overlays are drawn after the tree, in the order they were first shown, so the last of them is on + // top. The tooltip is not a target: it opens under the pointer, and hit-testing it would end the + // hover over the component it describes. + for (auto const& entry: _overlays | std::views::reverse) + { + auto* const overlay = entry.component; + if (overlay == nullptr || overlay == &_tooltip || !overlay->visible() + || !overlay->screenBounds().contains(col, row)) + continue; + return componentAtRecursive(*overlay, row, col); + } + return componentAtRecursive(*_root, row, col); +} +``` + +In `Screen::dispatchMouseEvent`, replace `Component* target = componentAt(mouseRow, mouseCol);` with +`Component* target = hitTest(mouseRow, mouseCol);`. Update the doc of `dispatchEvent` in `Screen.hpp` from +"For mouse events: hit tests to find target, then bubbles up." to +"For mouse events: hit tests to find the target (overlays first, see componentAt()), then bubbles up." + +`CHANGELOG.md`, append to `### Added`: + +```markdown +- **`Screen::componentAt(row, col)` is public**: the top-most visible component at a cell, as the last + `draw()` laid the screen out -- overlays first, the one shown last on top, then the tree in reverse + z-order, deepest descendant first -- or null where nothing but the root is. A drag source asks it for + the component under the pointer at the release. +``` + +and add after the `### Added` section: + +```markdown +### Changed + +- **A mouse event over an overlay goes to the overlay.** `Screen` hit-tested only the component tree, so + a click on a popup or dialog shown with `showOverlay()` reached the component beneath it. Overlays are + now tested first, the one shown last on top. The screen's own tooltip is not a target, so the hover + stays with the component it describes. +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build --preset clang-debug --target core-cpp-tui-test \ + && ./out/build/clang-debug/src/core/tui/core-cpp-tui-test "Screen.*,HoverState.*" +``` + +Expected: PASS, every `Screen.*` and `HoverState.*` case — the existing hover/tooltip cases included, which is +what shows the root-answering `hitTest` kept their behaviour. + +Mutation check: in `hitTest`, delete `overlay == &_tooltip ||`; rebuild and rerun `"Screen.componentAt*"`. +Expected: FAIL in `componentAt_neverReturnsTheTooltip`. Restore. Then delete the whole `for` loop over +`_overlays`; expected: FAIL in `componentAt_findsOverlaysBeforeTheTree` and +`mousePress_overAnOverlayGoesToTheOverlay`. Restore. + +- [ ] **Step 5: Format and commit** + +```bash +python3 scripts/clang-format.py src/core/tui/Screen.hpp src/core/tui/Screen.cpp src/core/tui/Screen_test.cpp +cmake --build --preset clang-debug && ctest --preset clang-debug +git add src/core/tui/Screen.hpp src/core/tui/Screen.cpp src/core/tui/Screen_test.cpp CHANGELOG.md +git commit -s -F - <<'EOF' +feat(tui): Screen::componentAt is public, and a mouse event over an overlay goes to the overlay + +componentAt answers the top-most visible component at a cell -- overlays first, +the last shown on top, then the tree in reverse z-order -- or null where only +the root is, so a drag source can find its drop target. Dispatch uses the same +lookup, so a click on a popup or dialog no longer reaches what lies beneath it. +The tooltip is not a target, and dispatch still sees the root for an empty +cell, so the hover state behaves as before. +EOF +``` + +--- + +### Task 6: `Screen` captures the pointer for the component that took the press + +**Files:** +- Modify: `src/core/tui/Screen.hpp` — `friend class Component;`, a public capture block, `_pointerCapture`, + `dispatchPress`, `componentDetached` and `componentDestroyed` in the private helpers, the `dispatchEvent` doc +- Modify: `src/core/tui/Screen.cpp` — `~Screen`, `dispatchMouseEvent`, three new members +- Modify: `src/core/tui/Component.cpp` — `~Component`, `setScreen` +- Modify: `CHANGELOG.md`, `docs/modules/tui.md` +- Test: `src/core/tui/Screen_test.cpp` — append after Task 5's cases (same anonymous-namespace helpers) + +**Interfaces:** +- Consumes: Task 5's `hitTest`, `componentAt`, `MouseRecorder`, `mouseAt`; `Component::setScreen` (private, + `Screen` is its friend), `Screen::bubbleEvent` (`src/core/tui/Screen.hpp`). +- Produces: `[[nodiscard]] Component* core::tui::Screen::pointerCapture() const noexcept;`, + `void core::tui::Screen::releasePointer() noexcept;`; private + `[[nodiscard]] EventResult Screen::dispatchPress(Component* target, InputEvent const& event);`, + `void Screen::componentDetached(Component const& component) noexcept;`, + `void Screen::componentDestroyed(Component const& component) noexcept;`. + +- [ ] **Step 1: Write the failing tests** + +Add `#include ` to `src/core/tui/Screen_test.cpp`'s standard includes. Inside the anonymous namespace +of Task 5 (after `mouseAt`), add: + +```cpp +/// @brief Runs @c onPress when pressed, which may destroy this component, and handles the press. +struct PressCallback: Component +{ + std::function onPress; ///< Called on a press. + + void render(Canvas& /*canvas*/) override {} + + EventResult onEvent(InputEvent const& event) override + { + auto const* mouse = std::get_if(&event); + if (mouse == nullptr || mouse->type != MouseEvent::Type::Press) + return EventResult::Ignored; + auto const callback = onPress; // the member dies with this component + callback(); + return EventResult::Handled; + } +}; + +/// @brief The layout of the capture cases: A at columns 2..11, B at columns 20..29, both on rows 1..3. +constexpr auto AreaA = Rect { .x = 2, .y = 1, .width = 10, .height = 3 }; +constexpr auto AreaB = Rect { .x = 20, .y = 1, .width = 10, .height = 3 }; +``` + +Then append after Task 5's test cases: + +```cpp +TEST_CASE("Screen.pointerCapture_followsTheDragOutsideThePressedComponent") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto source = MouseRecorder {}; + auto other = MouseRecorder {}; + screen.root().addChild(source, LayoutParams { .area = AreaA }); + screen.root().addChild(other, LayoutParams { .area = AreaB }); + screen.draw(); + + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 4, 2)); // cell (1, 3): inside A + CHECK(screen.pointerCapture() == &source); + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Move, 25, 2)); // over B + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Release, 1, 10)); // left of and below A + + REQUIRE(source.received.size() == 3); + CHECK(source.received[0].type == MouseEvent::Type::Press); + CHECK(source.received[0].x == 2); + CHECK(source.received[0].y == 1); + CHECK(source.received[1].type == MouseEvent::Type::Move); + CHECK(source.received[1].x == 23); + CHECK(source.received[1].y == 1); + CHECK(source.received[2].type == MouseEvent::Type::Release); + CHECK(source.received[2].x == -1); // A-relative, so left of A is below 1 + CHECK(source.received[2].y == 9); + CHECK(other.received.empty()); + CHECK(screen.pointerCapture() == nullptr); +} + +TEST_CASE("Screen.pointerCapture_isTakenByTheAncestorThatHandledThePress") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto panel = MouseRecorder {}; + auto leaf = MouseRecorder {}; + leaf.pressResult = EventResult::Ignored; + screen.root().addChild(panel, LayoutParams { .area = { .x = 2, .y = 1, .width = 20, .height = 5 } }); + panel.addChild(leaf, LayoutParams { .area = { .x = 1, .y = 1, .width = 5, .height = 2 } }); + screen.draw(); + + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 5, 3)); // cell (2, 4): inside the leaf + CHECK(screen.pointerCapture() == &panel); + + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Move, 40, 10)); // cell (9, 39) + REQUIRE(panel.received.size() == 2); + CHECK(panel.received[1].type == MouseEvent::Type::Move); + CHECK(panel.received[1].x == 38); // panel-relative: 39 - 2 + 1 + CHECK(panel.received[1].y == 9); // 9 - 1 + 1 + CHECK(leaf.received.size() == 1); // the press only +} + +TEST_CASE("Screen.pointerCapture_isNotTakenByAPressNobodyHandled") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto source = MouseRecorder {}; + auto other = MouseRecorder {}; + source.pressResult = EventResult::Ignored; + screen.root().addChild(source, LayoutParams { .area = AreaA }); + screen.root().addChild(other, LayoutParams { .area = AreaB }); + screen.draw(); + + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 4, 2)); + CHECK(screen.pointerCapture() == nullptr); + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Move, 25, 2)); + REQUIRE(other.received.size() == 1); + CHECK(other.received[0].type == MouseEvent::Type::Move); +} + +TEST_CASE("Screen.pointerCapture_endsWhenTheTargetIsDestroyed") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto source = std::make_unique(); + auto other = MouseRecorder {}; + screen.root().addChild(*source, LayoutParams { .area = AreaA }); + screen.root().addChild(other, LayoutParams { .area = AreaB }); + screen.draw(); + + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 4, 2)); + REQUIRE(screen.pointerCapture() == source.get()); + source.reset(); + CHECK(screen.pointerCapture() == nullptr); + + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Move, 25, 2)); // hit-tested again + REQUIRE(other.received.size() == 1); + CHECK(other.received[0].type == MouseEvent::Type::Move); +} + +TEST_CASE("Screen.pointerCapture_endsWhenTheTargetOrItsAncestorLeavesTheTree") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto panel = MouseRecorder {}; + auto source = MouseRecorder {}; + panel.pressResult = EventResult::Ignored; + screen.root().addChild(panel, LayoutParams { .area = { .x = 0, .y = 0, .width = 30, .height = 10 } }); + panel.addChild(source, LayoutParams { .area = AreaA }); + screen.draw(); + + SECTION("the target itself") + { + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 4, 2)); + REQUIRE(screen.pointerCapture() == &source); + panel.removeChild(source); + CHECK(screen.pointerCapture() == nullptr); + } + SECTION("an ancestor") + { + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 4, 2)); + REQUIRE(screen.pointerCapture() == &source); + screen.root().removeChild(panel); + CHECK(screen.pointerCapture() == nullptr); + } +} + +TEST_CASE("Screen.pointerCapture_endsWhenTheOverlayHoldingItIsHidden") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto overlay = MouseRecorder {}; + screen.showOverlay(overlay, Point { .x = 5, .y = 2 }); + screen.draw(); + + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 7, 4)); + REQUIRE(screen.pointerCapture() == &overlay); + screen.hideOverlay(overlay); + CHECK(screen.pointerCapture() == nullptr); +} + +TEST_CASE("Screen.releasePointer_endsTheCapture") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto source = MouseRecorder {}; + auto other = MouseRecorder {}; + screen.root().addChild(source, LayoutParams { .area = AreaA }); + screen.root().addChild(other, LayoutParams { .area = AreaB }); + screen.draw(); + + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 4, 2)); + screen.releasePointer(); + CHECK(screen.pointerCapture() == nullptr); + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Move, 25, 2)); + CHECK(other.received.size() == 1); + CHECK(source.received.size() == 1); +} + +TEST_CASE("Screen.pointerCapture_neverTakesScrollEvents") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto source = MouseRecorder {}; + auto other = MouseRecorder {}; + screen.root().addChild(source, LayoutParams { .area = AreaA }); + screen.root().addChild(other, LayoutParams { .area = AreaB }); + screen.draw(); + + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 4, 2)); + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::ScrollDown, 25, 2)); // over B + REQUIRE(other.received.size() == 1); + CHECK(other.received[0].type == MouseEvent::Type::ScrollDown); + CHECK(screen.pointerCapture() == &source); // the drag goes on + + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Move, 25, 2)); + CHECK(source.received.size() == 2); + CHECK(other.received.size() == 1); +} + +TEST_CASE("Screen.pointerCapture_movesToTheComponentOfTheNextPress") +{ + // A release the terminal never delivered -- the window lost focus mid-drag -- holds the pointer + // only until the next press. + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto source = MouseRecorder {}; + auto other = MouseRecorder {}; + screen.root().addChild(source, LayoutParams { .area = AreaA }); + screen.root().addChild(other, LayoutParams { .area = AreaB }); + screen.draw(); + + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 4, 2)); + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 25, 2)); // no release in between + CHECK(screen.pointerCapture() == &other); + (void) screen.dispatchEvent(mouseAt(MouseEvent::Type::Move, 5, 2)); // over A + CHECK(source.received.size() == 1); + REQUIRE(other.received.size() == 2); + CHECK(other.received[1].type == MouseEvent::Type::Move); +} + +TEST_CASE("Screen.pointerCapture_isNotTakenByAComponentItsPressDestroyed") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto doomed = std::make_unique(); + doomed->onPress = [&doomed] { doomed.reset(); }; + screen.root().addChild(*doomed, LayoutParams { .area = AreaA }); + screen.draw(); + + CHECK(screen.dispatchEvent(mouseAt(MouseEvent::Type::Press, 4, 2)) == EventResult::Handled); + CHECK(doomed == nullptr); + CHECK(screen.pointerCapture() == nullptr); + // Delivered to the capture, this would reach freed memory: under ASan, a heap-use-after-free. + CHECK(screen.dispatchEvent(mouseAt(MouseEvent::Type::Move, 4, 2)) == EventResult::Ignored); +} + +TEST_CASE("Screen.destructor_detachesOverlaysStillShown") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto overlay = MouseRecorder {}; + { + auto screen = Screen(terminal); + screen.showOverlay(overlay, Point { .x = 5, .y = 5 }); + screen.draw(); + CHECK(overlay.screen() == &screen); + } + // The overlay outlives the screen; its own teardown must not reach the destroyed one. + CHECK(overlay.screen() == nullptr); +} + +TEST_CASE("Screen.overlayDestroyedWhileShown_leavesTheOverlayList") +{ + auto terminal = Terminal(std::make_unique(80, 24)); + auto screen = Screen(terminal); + auto overlay = std::make_unique(); + screen.showOverlay(*overlay, Point { .x = 5, .y = 2 }); + screen.draw(); + REQUIRE(screen.componentAt(3, 6) == overlay.get()); + + overlay.reset(); + // Kept in the list, the destroyed overlay would be drawn and hit-tested here: under ASan, a + // heap-use-after-free. + screen.draw(); + CHECK(screen.componentAt(3, 6) == nullptr); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build --preset clang-debug --target core-cpp-tui-test` +Expected: compile error, `no member named 'pointerCapture' in 'core::tui::Screen'` (and `'releasePointer'`). + +- [ ] **Step 3: Implement** + +`src/core/tui/Screen.hpp`: + +After Task 5's `componentAt` declaration (still in the public section), add: + +```cpp + + // --- Pointer Capture --- + + /// Returns the component the pointer is captured by, or nullptr. + /// + /// A press makes the component that handled it (after bubbling) the capture target. Until the + /// capture ends, every move and the next release go to it directly -- no hit test, no bubbling -- + /// in its own coordinates, which are below 1 or beyond its size once the pointer has left it. The + /// release ends the capture, as do the next press, releasePointer(), and the target leaving this + /// screen: destroyed, removed from the tree (an ancestor's removal included, and a move to another + /// parent, which removes it on the way) or hidden as an overlay. Scroll events are never captured, + /// and a captured move does not update the hover state. + /// @return The capture target, or nullptr when the pointer is not captured. + [[nodiscard]] Component* pointerCapture() const noexcept { return _pointerCapture; } + + /// Ends the pointer capture, if any: the next move or release is hit-tested again. A press handler + /// that calls it declines the capture its press would take. + void releasePointer() noexcept { _pointerCapture = nullptr; } +``` + +Change the `dispatchEvent` doc's mouse line to: + +```cpp + /// For mouse events: a move or release goes to the pointer capture target if there is one (see + /// pointerCapture()); anything else hit tests to find the target (overlays first, see + /// componentAt()) and bubbles up, and a handled press makes its handler the capture target. +``` + +At the top of the `private:` section, before `Terminal& _terminal;`, add `friend class Component;`, and between +`RenderMode _renderMode = RenderMode::Diff;` and `std::unique_ptr _root;` add: + +```cpp + + /// The component the pointer is captured by, or nullptr. + Component* _pointerCapture = nullptr; + +``` + +In the `// Event dispatch helpers` block, after `dispatchMouseEvent`, add: + +```cpp + + /// Delivers a press from @p target up through its ancestors, making each the capture target while + /// it decides, so that the one that handles it keeps the capture. + /// @param target The component under the pointer. + /// @param event The press, in @p target's coordinates. + /// @return The first result that is not Ignored, or Ignored. + [[nodiscard]] EventResult dispatchPress(Component* target, InputEvent const& event); + + /// Called by a component that leaves this screen -- removed from the tree, directly or with an + /// ancestor, or hidden as an overlay -- so that no capture outlives its place on the screen. + /// @param component The component leaving. + void componentDetached(Component const& component) noexcept; + + /// Called by a component this screen still knows when it is destroyed: what componentDetached() + /// does, and an overlay that was never hidden leaves the overlay list, so nothing draws, hit-tests + /// or detaches it after it is gone. + /// @param component The component being destroyed. + void componentDestroyed(Component const& component) noexcept; +``` + +`src/core/tui/Screen.cpp`: add `#include ` to its standard includes (for `std::erase_if`). + +At the top of `Screen::~Screen()`, before `if (_enteredAlternateScreen)`, add: + +```cpp + // Overlays and the tree's components belong to the caller and may outlive this screen. Each + // forgets it here, while every member is alive, so that no teardown -- theirs later, or the root's + // among this screen's members -- reaches a destroyed screen. + for (auto const& entry: _overlays) + entry.component->setScreen(nullptr); + _overlays.clear(); + _root->setScreen(nullptr); + +``` + +Replace `Screen::dispatchMouseEvent` entirely with: + +```cpp +EventResult Screen::dispatchMouseEvent(MouseEvent const& mouse) +{ + // Note: We intentionally ignore mouse.uiHandled to ensure hover detection + // works even when the terminal (e.g., Contour with passive tracking) has + // already processed the event for its own UI purposes. + + // A press starts a new gesture, so it ends whatever capture is left: a release the terminal never + // delivered (the window lost focus mid-drag) holds the pointer only until the next press. + if (mouse.type == MouseEvent::Type::Press) + _pointerCapture = nullptr; + + // Moves and the release go to the capture target; presses and scroll events are hit-tested. + auto const isCaptured = _pointerCapture != nullptr + && (mouse.type == MouseEvent::Type::Move || mouse.type == MouseEvent::Type::Release); + + int mouseRow = mouse.y - 1; // Convert to 0-based + int const mouseCol = mouse.x - 1; + + // Translate mouse coordinates for inline mode + // Use _mainContentHeight (content before overlays) to avoid tooltip affecting coordinates + int const contentHeight = (_mainContentHeight > 0) ? _mainContentHeight : _previousContentHeight; + if (_config.viewport == Viewport::Inline && contentHeight > 0) + { + // _inlineContentStartRow is calculated in flushInline() based on terminal size and peak content + // height + if (_inlineContentStartRow < 0) + { + // Not yet rendered - skip mouse handling + if (mouse.type == MouseEvent::Type::Move) + _hoverState.onMouseMove(mouseCol + 1, 0, nullptr); + return EventResult::Ignored; + } + + mouseRow = mouse.y - 1 - _inlineContentStartRow; + + // Outside the inline content only a captured event is delivered: the drag it belongs to may + // leave the content and come back. + if (!isCaptured && (mouseRow < 0 || mouseRow >= contentHeight)) + { + // Still update hover state (to trigger leave if needed) + if (mouse.type == MouseEvent::Type::Move) + _hoverState.onMouseMove(mouseCol + 1, mouseRow + 1, nullptr); + return EventResult::Ignored; + } + } + + if (isCaptured) + { + // No hit test and no bubbling: the capture target sees its whole gesture, in its own + // coordinates, wherever the pointer is. A release ends the capture before it is delivered, so + // its handler may start the next gesture or destroy the component. + auto* const captureTarget = _pointerCapture; + if (mouse.type == MouseEvent::Type::Release) + _pointerCapture = nullptr; + auto relative = mouse; + auto const captureBounds = captureTarget->screenBounds(); + relative.x = mouseCol - captureBounds.x + 1; + relative.y = mouseRow - captureBounds.y + 1; + return captureTarget->onEvent(relative); + } + + // Hit test to find target component + Component* target = hitTest(mouseRow, mouseCol); + + // Update hover state for mouse move events + // Use viewport-relative 1-based coordinates for consistency with component bounds + if (mouse.type == MouseEvent::Type::Move) + { + _hoverState.onMouseMove(mouseCol + 1, mouseRow + 1, target); + } + + if (!target) + return EventResult::Ignored; + + // Create adjusted event with component-relative coordinates + MouseEvent adjusted = mouse; + Rect const bounds = target->screenBounds(); + adjusted.x = mouseCol - bounds.x + 1; // Back to 1-based for component + adjusted.y = mouseRow - bounds.y + 1; + + if (mouse.type == MouseEvent::Type::Press) + return dispatchPress(target, adjusted); + return bubbleEvent(target, adjusted); +} + +EventResult Screen::dispatchPress(Component* target, InputEvent const& event) +{ + // Each component is the capture target while it decides, and a component's teardown clears the + // capture (componentDetached()): one destroyed or detached while handling its own press is never + // left holding the pointer, and one that calls releasePointer() declines it. + while (target) + { + _pointerCapture = target; + EventResult const result = target->onEvent(event); + if (result != EventResult::Ignored) + return result; + // Ignored, and no longer the capture target: it left this screen while handling the press, so + // it may be gone and its parent cannot be read, or it released the pointer. The press ends here. + if (_pointerCapture != target) + return EventResult::Ignored; + _pointerCapture = nullptr; + target = target->parent(); + } + return EventResult::Ignored; +} + +void Screen::componentDetached(Component const& component) noexcept +{ + if (_pointerCapture == &component) + _pointerCapture = nullptr; +} + +void Screen::componentDestroyed(Component const& component) noexcept +{ + componentDetached(component); + std::erase_if(_overlays, [&component](OverlayEntry const& entry) { return entry.component == &component; }); +} +``` + +`src/core/tui/Component.cpp`: replace `Component::~Component()` and `Component::setScreen` with: + +```cpp +Component::~Component() +{ + // A component the screen still knows when it dies -- an overlay never hidden, say -- tells the + // screen first, so that neither the pointer capture nor the overlay list outlives it. + if (_screen) + _screen->componentDestroyed(*this); + + // Remove from parent if attached + if (_parent) + _parent->removeChild(*this); + + // Clear children (sets their parent to nullptr) + clearChildren(); +} +``` + +```cpp +void Component::setScreen(Screen* screen) +{ + // Leaving a screen -- removed from its tree, directly or with an ancestor, or hidden as an overlay -- + // ends that screen's pointer capture on this component. + if (_screen && _screen != screen) + _screen->componentDetached(*this); + + _screen = screen; + + // Propagate to children + for (Component* child: _children) + child->setScreen(screen); +} +``` + +`CHANGELOG.md`, append to `### Added`: + +```markdown +- **Pointer capture in `Screen`.** `Screen` hit-tested every mouse event, so a drag that left the component + it started on lost its moves and its release. A press now makes the component that handled it the + capture target: every move and the next release go to it directly, in its own coordinates, wherever the + pointer is. The release ends the capture, as do the next press, `Screen::releasePointer()` and the + target leaving the screen (destroyed, removed from the tree or hidden as an overlay); scroll events are + never captured. `Screen::pointerCapture()` answers the target. +``` + +and add after the `### Changed` section: + +```markdown +### Fixed + +- **An overlay and its `Screen` may be destroyed in either order.** The screen's destructor detaches every + overlay still shown and its tree; before, an overlay that outlived the screen reached the destroyed one + from its own destructor, through `invalidate()`. A component destroyed while shown as an overlay leaves + the overlay list; before, the next `draw()` rendered the destroyed component. +``` + +`docs/modules/tui.md`, after Task 4's `- **Mouse.** …` bullet, insert: + +```markdown +- **Pointer.** `Screen` routes a mouse event to the top-most visible component under it -- overlays first, + the one shown last on top, then the tree -- and `componentAt(row, col)` answers the same question for a + caller, such as a drag source looking for its drop target. A press makes the component that handled it + the capture target: until the release, every move and that release go to it, in its own coordinates and + wherever the pointer is, so a component sees its whole drag. The release ends the capture, as do the + next press, `releasePointer()`, and the component leaving the screen -- destroyed, removed from the tree + or hidden as an overlay. Scroll events are never captured. +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build --preset clang-debug --target core-cpp-tui-test \ + && ./out/build/clang-debug/src/core/tui/core-cpp-tui-test "Screen.*,HoverState.*,Component.*" +``` + +Expected: PASS, every case. + +Mutation check: in `Component::setScreen`, delete the two lines that call `componentDetached`; rebuild and rerun. +Expected: FAIL in `pointerCapture_endsWhenTheTargetOrItsAncestorLeavesTheTree` (both sections) and +`pointerCapture_endsWhenTheOverlayHoldingItIsHidden`. Restore. Then in `dispatchPress`, move +`_pointerCapture = target;` to after the `onEvent` call (set only once a component has handled the press); expected: +FAIL in `pointerCapture_isNotTakenByAComponentItsPressDestroyed` (`pointerCapture()` is the freed component). Restore. + +- [ ] **Step 5: Format and commit** + +```bash +python3 scripts/clang-format.py src/core/tui/Screen.hpp src/core/tui/Screen.cpp src/core/tui/Component.cpp \ + src/core/tui/Screen_test.cpp +cmake --build --preset clang-debug && ctest --preset clang-debug +git add src/core/tui/Screen.hpp src/core/tui/Screen.cpp src/core/tui/Component.cpp src/core/tui/Screen_test.cpp \ + CHANGELOG.md docs/modules/tui.md +git commit -s -F - <<'EOF' +feat(tui): Screen captures the pointer for the component that took the press + +Screen hit-tested every mouse event, so a drag that left its component lost +its moves and its release. A press now makes the component that handled it the +capture target; moves and the next release go to it in its own coordinates +until the release, the next press, releasePointer(), or the target leaving the +screen, which its own teardown reports. Scroll is never captured. + +A component destroyed while handling its press is never left holding the +pointer: each candidate holds the capture while it decides. A destroyed +overlay leaves the overlay list, and ~Screen detaches the overlays it still +shows and its tree, so neither the new teardown hook nor the existing +invalidate() reaches a destroyed screen or component. +EOF +``` + +--- + +### Task 7: core-cpp gates + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Format, the whole tree** + +```bash +python3 scripts/clang-format.py --all --check +``` + +Expected: no output, exit 0. + +- [ ] **Step 2: clang-tidy, the pinned one** + +```bash +cmake --preset clang-tidy && cmake --build --preset clang-tidy +``` + +Expected: builds with no finding (`WarningsAsErrors: '*'`). Fix every finding; never `NOLINT`. + +- [ ] **Step 3: Both macOS configurations, every test** + +```bash +cmake --build --preset clang-debug && ctest --preset clang-debug +cmake --preset appleclang-debug && cmake --build --preset appleclang-debug && ctest --preset appleclang-debug +``` + +Expected: `100% tests passed` in each (skips reported as skipped, not as passed). Linux `gcc-release` and the +Windows presets run in CI (Task 9, Step 3). + +- [ ] **Step 4: Sanitizers** + +```bash +cmake --preset clang-asan-ubsan && cmake --build --preset clang-asan-ubsan --target core-cpp-tui-test core-cpp-tui_output-test +./out/build/clang-asan-ubsan/src/core/tui/core-cpp-tui-test "Screen.*,TerminalInput.*,VtParser.*" +./out/build/clang-asan-ubsan/src/core/tui/core-cpp-tui_output-test "TerminalProtocols.*" +nm -C out/build/clang-asan-ubsan/src/core/tui/core-cpp-tui-test | grep -c __asan_report +``` + +Expected: every case passes with no sanitizer report, and the `nm` count is greater than 0 (the binary is +instrumented, so a clean run means something). `Screen.pointerCapture_isNotTakenByAComponentItsPressDestroyed` and +`Screen.destructor_detachesOverlaysStillShown` and `Screen.overlayDestroyedWhileShown_leavesTheOverlayList` rely on +ASan as their observer. If the preset does not configure on +macOS, say so; CI's `clang-asan-ubsan` and `clang-tsan` legs are then the measurement, and the PR body says that. + +- [ ] **Step 5: Hygiene and the documentation site** + +```bash +ctest --preset clang-debug -L hygiene +python3 -m venv out/mkdocs && out/mkdocs/bin/pip install -q -r docs/requirements.txt +out/mkdocs/bin/mkdocs build --strict +``` + +Expected: `100% tests passed` (provenance, layering, platform sources, CMake hygiene); `mkdocs` exits 0 with no +warning (`CHANGELOG.md` is rendered into the site by a snippet). + +- [ ] **Step 6: Commit any fixes** + +One commit per kind of fix, with the conventional subject that describes it (e.g. +`style(tui): clang-tidy's findings in the mouse tracking change`), `git commit -s`. Skip if nothing needed fixing, +and say so in the hand-off. + +--- + +### Task 8: Verify morph against the feature branch, locally (nothing committed) + +Catches a core-cpp change that breaks morph before anything is pushed. morph builds core-cpp with +`CORE_CPP_WITH_TUI OFF`, so this exercises the 0.5.1 and 0.6.0 changes morph now takes, not the new `core::tui` +code; Part 3 builds that. + +**Files:** none (a throwaway build directory in morph). + +- [ ] **Step 1: Configure morph with core-cpp from the local checkout** + +```bash +cd /Users/christianparpart/projects/morph +git status --porcelain # expected: empty +cmake -S . -B build/corecpp-local -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_NET=ON \ + -DCPM_core-cpp_SOURCE=/Users/christianparpart/projects/core-cpp +``` + +Expected: the configure output contains `CPM: Adding package core-cpp@` followed by +`/Users/christianparpart/projects/core-cpp` as the source (CPM's override ignores `VERSION`, so the +still-`v0.5.0` lines in morph's `CMakeLists.txt` do not matter here) and `morph: warnings: ... strict=ON`. +If the output names `.cache/cpm/core-cpp/…` instead, the override did not apply — stop. + +- [ ] **Step 2: Build and run the suite** + +```bash +cmake --build build/corecpp-local +ctest --test-dir build/corecpp-local --output-on-failure +``` + +Expected: everything passes. A failure here is a core-cpp 0.5.1/0.6.0 behaviour change that Task 10 would have to +absorb: diagnose it now, record the fix Task 10 must make (and keep it for Task 10's commit — do not commit in morph +here). + +- [ ] **Step 3: Remove the override** + +```bash +rm -rf build/corecpp-local +git status --porcelain # expected: empty — nothing of the override reached the tree +``` + +--- + +### Task 9: Release core-cpp 0.7.0 and file the three core-cpp issues — every step asks the user first + +Each step below is outward-facing. **Before each, ask the user with the exact command and wait for a yes**; a +"no" stops the part there and is reported. + +- [ ] **Step 1: Push the branch — ask the user first** + +```bash +cd /Users/christianparpart/projects/core-cpp +git log --oneline master..feature/mouse-tracking # show the user the commits being pushed +git push -u origin feature/mouse-tracking +``` + +- [ ] **Step 2: Open the pull request — ask the user first** + +Write the body to the session scratchpad as `core-cpp-pr-body.md`, filling the two bracketed places with real output: + +```markdown +## What + +core::tui asks a standard terminal for mouse reports, and gives a drag to the component that started it: + +- `MouseTracking { Off, Buttons, Drag, AnyMotion }` with `TerminalInput::setMouseTracking()` and + `Terminal::setMouseTracking()`: DEC 1000/1002/1003 with SGR 1006, default `Off`; + `protocols::appendMouseTrackingChange()` writes each transition. The 2029 hover path raises the mode to + `AnyMotion`. +- Pointer capture in `Screen`: a press makes its handler the capture target; moves and the release go to it in its + own coordinates until the release, the next press, `releasePointer()`, or the target leaving the screen. Scroll + is never captured. +- `Screen::componentAt()` is public, and hit testing, dispatch included, looks at overlays before the tree (the + tooltip excepted). +- `~Screen` detaches the overlays it still shows. + +## Why + +Only Contour's passive mode 2029 was enabled, so xterm, kitty, WezTerm, iTerm2 and Windows Terminal reported no +mouse at all; and `Screen` hit-tested every event, so a drag lost its moves and its release once it left its +component. morph's terminal frontend needs both for drag-and-drop. + +## Consumer impact + +- contour (vendored): none; a grep of `contour/src` finds no `core/tui` include. +- endo: none until it moves onto core-cpp's `core::tui` (it still builds its own copy). +- fastcached: none; no `core::tui` use found. +- tuidu: a mouse event over its help or delete-dialog overlay now goes to the overlay, not to the tree view under + it. On terminals other than Contour it receives mouse input only after `terminal.setMouseTracking(...)`. +- Lightweight `dbtool`: none; it links `core::tui_output`, where this adds names only. +- morph (including its WebAssembly build): none at the pin; its terminal frontend uses all of this. The + WebAssembly build does not build `core::tui`. + +## Tests + +- `VtParser.SGRMouse.*`, `TerminalProtocols.mouse_tracking_*`, `TerminalInput.*` (the POSIX bytes are read back + from a pipe standing in for standard output), `Screen.componentAt_*`, `Screen.pointerCapture_*`, + `Screen.destructor_detachesOverlaysStillShown`, `Screen.overlayDestroyedWhileShown_leavesTheOverlayList`; each + was seen to fail with its mutation. +- Locally: `clang-debug`, `appleclang-debug`, `clang-tidy`, [`clang-asan-ubsan`, or "not run locally: …"], + `mkdocs build --strict`, `scripts/clang-format.py --all --check`. Local `ctest` summary: [paste]. +- Not measured: the Windows arm's protocol bytes (`initialize()` needs a real console); it makes the same calls as + the POSIX arm and is compiled and run by the Windows legs here. + +- [x] CHANGELOG entry under `[Unreleased]` +- [x] Documentation updated (`docs/modules/tui.md`) +``` + +```bash +gh pr create --repo contour-terminal/core-cpp --base master --head feature/mouse-tracking \ + --title "tui: standard mouse tracking, pointer capture and a public Screen::componentAt" \ + --body-file /core-cpp-pr-body.md --label type/feature --label module/tui +``` + +- [ ] **Step 3: CI to green** + +```bash +gh pr checks --repo contour-terminal/core-cpp --watch +``` + +Expected: every check passes, `ci-ok` included. A failure is fixed on the branch with a conventional commit; pushing +the fix is Step 1 again — ask the user first. + +- [ ] **Step 4: Merge — ask the user first** + +```bash +gh pr merge --repo contour-terminal/core-cpp --merge +``` + +(core-cpp merges pull requests with a merge commit: `Merge pull request #59 from …`.) + +- [ ] **Step 5: Cut `v0.7.0` — ask the user first** + +core-cpp's release is its own commit on `master`, after the merge (`Release 0.6.0` is `afe8a18`). Run the +`contour-workflows:draft-release` skill in `/Users/christianparpart/projects/core-cpp` for version `0.7.0`. What it +must produce, and what to do by hand if it does not: + +```bash +git switch master && git pull --ff-only origin master +# CHANGELOG.md: "## [Unreleased]" -> "## [0.7.0] - " +# CMakeLists.txt: project(core-cpp VERSION 0.6.0 ...) -> VERSION 0.7.0 +# README.md: "GIT_TAG v0.6.0" -> v0.7.0 and "-DREF=v0.6.0" -> v0.7.0 +# docs/getting-started/cpm.md and docs/getting-started/install.md: "GIT_TAG v0.6.0" -> v0.7.0 +cmake -DTAG=v0.7.0 -DROOT=. -P tests/cmake/check-release.cmake # expected: exit 0 +git commit -s -am "Release 0.7.0" +git tag -a v0.7.0 -m "Release 0.7.0" +git push origin master v0.7.0 +``` + +The tag push starts `.github/workflows/release.yml`, which drafts the GitHub release with the vendor archive and +`SHA256SUMS`. + +- [ ] **Step 6: Publish the release — ask the user first** + +Run the `contour-workflows:publish-release` skill: it refuses until CI is green on the tag and both assets are +attached, then publishes, marks it latest, and opens the next `[Unreleased]` +(`docs(changelog): open [Unreleased] after 0.7.0`). + +```bash +git ls-remote --tags https://github.com/contour-terminal/core-cpp.git v0.7.0 # expected: one line, the tag +``` + +- [ ] **Step 7: File the Shift+Tab issue — ask the user first** + +Re-run the evidence on the tags that exist now and paste the real output into the body: + +```bash +cd /Users/christianparpart/projects/core-cpp +bash -c 'for t in v0.5.0 v0.5.1 v0.6.0 v0.7.0; do printf "%s: " "$t"; git grep -c "'"'"'Z'"'"'" "$t" -- src/core/tui/VtParser.cpp || echo "0 matches"; done' +``` + +Expected, as measured while writing this plan for the first +three: `0 matches` on each. Write `/core-cpp-shift-tab-issue.md`: + +````markdown +## What is missing + +`VtParser` does not decode `CSI Z` (`ESC [ Z`), the sequence xterm and most terminals send for Shift+Tab +when the Kitty keyboard protocol is not in effect. `mapCsiKey()` in `src/core/tui/VtParser.cpp` maps the +final bytes `A B C D H F ~` and has no `'Z'` case, so the sequence produces no event at all. + +Shift+Tab therefore reaches an application only as Kitty CSI-u (`CSI 9 ; 2 u`, decoded by the CSI-u path's +`case 9:`) and — inferred from reading, not tested — as a win32-input-mode record on Windows Terminal +(`vk::Tab` with Shift). On a terminal that does not speak the Kitty keyboard protocol, or ignores the +`CSI > 13 u` push, the key is lost: a backward focus move bound to Shift+Tab never fires. + +## Expected + +`VtParser{}.feed("\033[Z")` yields exactly one `KeyEvent { .key = KeyCode::Tab, .modifiers = Modifier::Shift }`, +the same event the Kitty form produces. + +## Verification status + +Inferred from reading the code; not reproduced in a terminal. The absence of a `'Z'` case, by grep on each tag: + +```text + +``` + +Not verified: which terminals still send `CSI Z` with the Kitty protocol pushed, and what Windows Terminal sends +with win32-input-mode on. + +## What would close it + +A `VtParser_test.cpp` case feeding `"\033[Z"` and expecting `KeyEvent{Tab, Shift}`, passing. Reopen if a +terminal is found that sends Shift+Tab some other legacy way that still produces no event. + +Found while planning morph's terminal frontend, which binds Shift+Tab to moving focus backwards. +```` + +```bash +gh issue create --repo contour-terminal/core-cpp --title "tui: decode legacy Shift+Tab (CSI Z)" \ + --body-file /core-cpp-shift-tab-issue.md --label bug --label module/tui +``` + +Record the issue URL for the hand-off. + +- [ ] **Step 8: File the dangling focus-pointer issue — ask the user first** + +Re-check on the tag that exists now (paste the output into the body): + +```bash +cd /Users/christianparpart/projects/core-cpp +git grep -n "_focusedComponents" v0.7.0 -- src/core/tui/Screen.hpp src/core/tui/Screen.cpp +git show v0.7.0:src/core/tui/Component.cpp | sed -n '/Component::~Component/,/^}/p' +``` + +Write `/core-cpp-focus-issue.md`: + +````markdown +## What is wrong + +`Screen` remembers the focused component of each focus group as a raw pointer +(`std::unordered_map _focusedComponents`, `src/core/tui/Screen.hpp`). +`Component::~Component` detaches from its parent and clears its children, but nothing removes the component from +that map. A focused component that is destroyed while its `Screen` lives on leaves a dangling pointer, which the next +`dispatchEvent` (key events go to the focused component), `focusNext`/`focusPrev` or `setFocus` (it calls +`onBlur` on the old focus) dereferences. + +## Expected + +Destroying the focused component clears the focus of its group (the component's teardown notifies its `Screen`, +the way the 0.7.0 pointer capture is cleared), so the next key event finds no focused component instead of freed +memory. + +## Verification status + +Inferred from reading the code; not reproduced (an ASan run of a test that focuses a component, destroys it and then +dispatches a key would show it). Code as of v0.7.0: + +```text + +``` + +Downstream, morph's terminal frontend works around it by clearing focus in each widget's destructor. + +## What would close it + +A `Screen_test.cpp` case — focus a component, destroy it, dispatch a key event — passing under ASan with no +use-after-free, and `focusedComponent()` returning null afterwards. +```` + +```bash +gh issue create --repo contour-terminal/core-cpp --title "tui: Screen keeps a dangling pointer to a destroyed focused component" \ + --body-file /core-cpp-focus-issue.md --label bug --label module/tui +``` + +- [ ] **Step 9: File the dangling hover-target issue — ask the user first** + +```bash +cd /Users/christianparpart/projects/core-cpp +git show v0.7.0:src/core/tui/HoverState.hpp | grep -n "Component\*" +git grep -n "_hoverState" v0.7.0 -- src/core/tui/Screen.cpp | head +``` + +Write `/core-cpp-hover-issue.md`: + +````markdown +## What is wrong + +`HoverState` keeps the component under the pointer as a raw pointer (`Component* target`, +`src/core/tui/HoverState.hpp`), set from `Screen::dispatchMouseEvent`'s hit test. Nothing clears it when that +component is destroyed, so a hover timeout (`Screen::tickHover`) or the next mouse move that compares or leaves the +old target uses freed memory — for instance when a view replaces the component under a resting pointer. + +## Expected + +Destroying a component that is the hover target clears it (the same teardown notification that clears the pointer +capture and, with the companion issue, the focus). + +## Verification status + +Inferred from reading the code; not reproduced. Code as of v0.7.0: + +```text + +``` + +morph's terminal frontend does not work around this one. + +## What would close it + +A `Screen_test.cpp` case — move the pointer over a component, destroy it, then `tickHover()` and move the pointer +again — passing under ASan. +```` + +```bash +gh issue create --repo contour-terminal/core-cpp --title "tui: HoverState keeps a dangling pointer to a destroyed hover target" \ + --body-file /core-cpp-hover-issue.md --label bug --label module/tui +``` + +Record both URLs for the hand-off. + +--- + +### Task 10: morph builds against core-cpp 0.7 + +**Precondition:** `v0.7.0` exists on GitHub — Task 9 Step 6's `git ls-remote` printed it. CPM fetches the tag; this +task cannot start before it exists. + +**Files:** +- Modify: `CMakeLists.txt` — `set(MORPH_CORE_CPP_VERSION 0.5)` (line 293) and the `CPMAddPackage(NAME core-cpp …)` + `GIT_TAG`/`VERSION` lines (312–313) +- Modify: `README.md` — line 424 (`v0.5.0`) and line 470 (`find_dependency(core-cpp 0.5)`) +- Modify: `CHANGELOG.md` — `## [Unreleased]` → `### Changed`, first bullet +- Test: morph's whole suite on `build/all` + +**Interfaces:** +- Consumes: core-cpp `v0.7.0` (Tasks 2–9). +- Produces: `MORPH_CORE_CPP_VERSION 0.7`; `find_dependency(core-cpp 0.7 CONFIG)` in the installed `morphConfig.cmake` + (through `cmake/morphConfig.cmake.in:36`, unchanged). + +- [ ] **Step 1: Read what changed between the pin and the new tag** + +```bash +cd /Users/christianparpart/projects/core-cpp +git show v0.7.0:CHANGELOG.md | sed -n '/^## \[0.7.0\]/,/^## \[0.5.0\]/p' +``` + +That prints the 0.7.0, 0.6.0 and 0.5.1 sections — everything after morph's `v0.5.0`. The ones that touch code +morph calls, and what morph does with each (measured by the grep in Step 2 while writing this plan): + +| core-cpp change | morph's use | Fix | +|---|---|---| +| 0.6.0 Breaking: `serve()` takes its loop | none (`morph::net::SocketServer` runs its own accept loop) | none | +| 0.6.0 Breaking: `IListener::close()` is the base's, implementations override `doClose()` | morph implements no `IListener`; it calls `accept()` (`include/morph/net/socket_server.hpp:266`) and owns one (`:401`) | none | +| 0.6.0 Breaking: exhaustion is `NetErrorCode::ResourceExhausted` | `acceptFlow` stops on `Cancelled` and backs off 50 ms on every other error (`socket_server.hpp:274`) | none: exhaustion still backs off | +| 0.6.0 Fixed: a refused registration is an error from `accept()`, not a thrown `FdRegistrationFailed` | the same `acceptFlow` | none: it now backs off where it used to see an exception | +| 0.5.1 Fixed: accept's errno classification (`EPERM`, `EINVAL` → `BadHandle`, …) | the same `acceptFlow` | none: every non-`Cancelled` code backs off as before | +| 0.7.0: `MouseTracking`, pointer capture, public `componentAt`, overlay-first hit testing | none: morph builds core-cpp with `CORE_CPP_WITH_TUI OFF` | none (Part 3 builds `core::tui`) | + +If the printed changelog has a Breaking or behaviour entry not in this table (core-cpp moved on after this plan), +grep morph for the API it names and add the fix to this task's commit. + +- [ ] **Step 2: Confirm morph's use is what the table says** + +```bash +cd /Users/christianparpart/projects/morph +git grep -n -E "IListener|core::net::serve|doClose|NetErrorCode::|ResourceExhausted|SystemError|BadHandle|FdRegistrationFailed|core/tui/" -- include src tests examples cmake +``` + +Expected, exactly: `include/morph/net/detail/ws_connection.hpp:67` (a comment naming `NetErrorCode::Cancelled`), +`include/morph/net/socket_server.hpp:9`, `:266`, `:274`, `:401`. Anything else is a use the table did not cover: +read it against the changelog before going on. + +- [ ] **Step 3: Raise the pin** + +`CMakeLists.txt`: `set(MORPH_CORE_CPP_VERSION 0.5)` → `set(MORPH_CORE_CPP_VERSION 0.7)`; in +`CPMAddPackage(NAME core-cpp …)`, `GIT_TAG v0.5.0` → `GIT_TAG v0.7.0` and `VERSION 0.5.0` → `VERSION 0.7.0`. + +`README.md` line 424: `v0.5.0, the` → `v0.7.0, the` (in "[core-cpp](…) v0.5.0, the"); line 470: +``find_dependency(core-cpp 0.5)`` → ``find_dependency(core-cpp 0.7)``. + +`CHANGELOG.md`: under `## [Unreleased]` → `### Changed`, insert as the first bullet (the existing +"morph depends on core-cpp v0.5.0" entry stays as written — CONTRIBUTING: an entry stops moving): + +```markdown +- **morph builds against core-cpp 0.7.** `MORPH_CORE_CPP_VERSION` is 0.7 and CPM fetches `v0.7.0`, so a + configure no longer accepts an installed core-cpp 0.5, and an installed morph finds its core-cpp through + `find_dependency(core-cpp 0.7)`. Nothing morph calls changed signature in core-cpp 0.5.1, 0.6.0 or 0.7.0. + `morph::net::SocketServer`'s accept loop stops on a closed listener and backs off on every other failed + accept, which now includes a registration the event loop refuses, where core-cpp 0.5 threw out of + `accept()`. core-cpp 0.7 adds standard mouse tracking, pointer capture and a public + `Screen::componentAt` to `core::tui`, for the terminal frontend. +``` + +- [ ] **Step 4: Build and run the whole suite** + +```bash +cd /Users/christianparpart/projects/morph +cmake -S . -B build/all -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_NET=ON -DMORPH_BUILD_FORMS_QML=ON 2>&1 | tee build/all-configure.log +grep -E "CPM: Adding package core-cpp@0\.7\.0|morph: warnings: .*strict=ON" build/all-configure.log +cmake --build build/all +ctest --test-dir build/all --output-on-failure +``` + +(`build/` is ignored by git.) Expected: the `grep` prints both lines (the configure +fetched `0.7.0`, not a cached `0.5.0`, and strict warnings are on); the build has no warning; `ctest` reports +`100% tests passed`. If an installed core-cpp 0.7 was found first, the CPM line is absent and +`core-cpp_DIR` in `build/all/CMakeCache.txt` names it — say so; it is still 0.7. + +- [ ] **Step 5: The installed package asks for 0.7, and the check sees a wrong one** + +```bash +bash scripts/check_install_export.sh +``` + +Expected: passes — it installs morph and core-cpp to a scratch prefix and builds a consumer through +`find_package(morph)`, which runs `find_dependency(core-cpp 0.7 CONFIG)`. + +Mutation check: set the `CPMAddPackage` lines back to `GIT_TAG v0.5.0` / `VERSION 0.5.0` while +`MORPH_CORE_CPP_VERSION` stays 0.7, and rerun `bash scripts/check_install_export.sh`. Expected: FAIL at the +consumer's `find_package(morph)` — the installed core-cpp is 0.5.0 and `find_dependency(core-cpp 0.7)` refuses it, +so the check does measure the bound. Restore the two lines (`git diff CMakeLists.txt` shows only the three intended +changes). + +- [ ] **Step 6: Commit** + +```bash +git add CMakeLists.txt README.md CHANGELOG.md +git commit -m "wip(corecpp): build against core-cpp 0.7 + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 11: Part verification and squash + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Sanitizers** (core-cpp is built in-tree and instrumented under `AF_SANITIZER`, so its 0.5.1–0.7.0 + changes are under test here; Linux, or on macOS the ASan configure that works there) + +```bash +cmake --preset clang-asan && cmake --build --preset clang-asan +bash scripts/check_sanitizer_instrumentation.sh build/clang-asan asan +ctest --preset clang-asan +cmake --preset clang-tsan && cmake --build --preset clang-tsan +bash scripts/check_sanitizer_instrumentation.sh build/clang-tsan tsan +ctest --preset clang-tsan +``` + +Expected: both instrumentation sweeps report every ctest binary carries the mode's symbols, and both suites pass +(the presets exclude `OomInjector|morph#108` themselves — CONTRIBUTING). A preset that cannot run on this machine +is stated as not run, never as passed. + +- [ ] **Step 2: The gates this part does not need, and why** + +- clang-tidy-diff: this part changes no C++ (`git diff --stat master...HEAD -- '*.hpp' '*.cpp'` shows none from + this part) — state that rather than running a gate over zero files. +- Docs build: no header changed. +- WebAssembly: the `wasm-ladder.yml`/`wasm-demo.yml` configurations fetch the same tag; they run in the master + plan's "Finishing the branch" and in the PR's CI. core-cpp's own Emscripten legs passed on the tag (Task 9 Step 3). + +- [ ] **Step 3: Commit any fixes** + +```bash +git add -A CMakeLists.txt README.md CHANGELOG.md +git commit -m "wip(corecpp): fixes from the sanitizer gates + +Signed-off-by: Christian Parpart " +``` + +Skip if there was nothing to fix, and say so in the hand-off. + +- [ ] **Step 4: Squash this part into its one commit** + +Follow the master plan's "Squashing a part" procedure with key `corecpp` and this message: + +```text +core: build against core-cpp 0.7 + +MORPH_CORE_CPP_VERSION is 0.7 and CPM fetches v0.7.0, so the installed +package asks find_dependency(core-cpp 0.7). core-cpp 0.7 adds what the +terminal frontend needs from core::tui: standard mouse tracking (DEC +1000/1002/1003 with SGR 1006), pointer capture in Screen and a public +Screen::componentAt. No morph code changes: nothing morph calls changed +signature in 0.5.1, 0.6.0 or 0.7.0, and SocketServer's accept loop already +backs off on every failed accept but a close. + +Signed-off-by: Christian Parpart +``` + +The last line of the procedure's output must show the docs commit and then `core: build against core-cpp 0.7`. diff --git a/docs/superpowers/plans/2026-10-04-declarative-ui-tui-1-reactive.md b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-1-reactive.md new file mode 100644 index 000000000..f4de941c2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-1-reactive.md @@ -0,0 +1,4352 @@ +# Declarative UI, Part 1 — `morph::reactive` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Ship `morph::reactive` — a glitch-free signal graph, `Store`, and declarative control +(`Query`, `Mutation`, `Subscription`) — header-only in the base `morph` target, with headless Catch2 tests. + +**Architecture:** A push-dirty/pull-value graph (Clean/Check/Dirty colouring) whose nodes share one +`detail::RuntimeCore`. Every write ends a batch; the outermost batch posts **one** flush to the owner executor; +the flush pulls queued effects through their computeds. Misuse is reported through +`morph::exec::detail::noteOwner` and then refused. `Query`/`Mutation`/`Subscription` are built from signals, an +effect and a `CallbackScope`, over any `Completion`-returning fetcher (a `BridgeHandler` in production, a +settleable promise in tests). + +**Tech Stack:** C++23, header-only; morph core (`IExecutor`, `OwnerAffinity`, `noteOwner`, `Completion`, +`CallbackScope`, `BridgeHandler`); Catch2 v3. + +**Spec:** `docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md` (§3, §4, §4b, §7, §9). + +This is **Part 1 of 11** of the declarative-UI program. Read the master plan +`docs/superpowers/plans/2026-10-04-declarative-ui-tui.md` first: it fixes the branch, the commit layout and the +`wip(...)` commit convention every task below follows, and this part ends by squashing its `wip(reactive)` commits +into one. + +## Global Constraints + +- C++23. `morph::reactive` is header-only, part of the base `morph` target's `FILE_SET HEADERS`; no new CMake + option, no new dependency. +- 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`, + public data members `camelBack`, constants `kName`; identifiers at least 3 characters except `i j k x y n fn cb op`. +- Comments and docs state what the code does now and why — no history, no issue numbers, no commit hashes. +- Doxygen runs with `WARN_AS_ERROR = FAIL_ON_WARNINGS` over every `*.hpp`, `detail` included: every class, + function and data member gets a brief, and every function complete `@param`/`@tparam`/`@return`. +- `-Weverything -Werror` (Clang) and clang-tidy (`WarningsAsErrors: "*"`) must stay clean. +- The UI state type is `ViewState`, never `Model` (morph's `Model` is the bridge domain model). +- Misuse site strings are exactly those in `detail::site` (Task 1); tests count them through + `morph::testing::OwnerProbeRecorder`. +- Commits end with `Signed-off-by: Christian Parpart `. + +## Review Focus + +1. **An Effect that writes a signal it reads** must not loop; it runs once per external change (Task 1 test + "an Effect that writes what it reads does not re-run itself"). +2. **A node destroyed while queued in the running flush** (a Switch remount destroying a child effect) must not + be touched again (Task 1 test "a queued Effect destroyed during the flush is skipped"). +3. **A batch whose body throws** must still flush the writes made before the throw, once, and leave the batch + depth at zero (Task 1 test "a throwing batch body still flushes what it wrote"). +4. **A Query key function that throws** is an Effect throw: reported, and the query keeps its last state (Task 6 + test "a throwing key is reported and leaves the query as it was"). +5. **A Mutation invalidating an idle Query** (key `nullopt`) issues nothing (Task 7 test "invalidating an idle + query issues nothing"). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `include/morph/reactive/detail/graph.hpp` | `Colour`, `site::` names, `RuntimeCore` (affinity, batch depth, queue, flush), `TrackingFrame`, `Node`, `BatchScope`, `WidgetEventScope` | +| `include/morph/reactive/runtime.hpp` | `RuntimeOptions`, `Runtime` (`batch`, `widgetEvent`, `untracked`) | +| `include/morph/reactive/signal.hpp` | `Signal`, `Computed`, `Effect` | +| `include/morph/reactive/scope.hpp` | `Scope` | +| `include/morph/reactive/store.hpp` | `ExhaustiveUpdate`, `Store`, `request()` | +| `include/morph/reactive/control.hpp` | `errorMessage`, `Refetchable`, `Query`, `MutationOptions`, `Mutation`, `Subscription` | +| `tests/test_reactive_signal.cpp` | Signal, Effect, batching, scheduling | +| `tests/test_reactive_computed.cpp` | Computed, glitch-freedom, throwing computeds | +| `tests/test_reactive_misuse.cpp` | Every reported-then-refused misuse | +| `tests/test_reactive_scope.cpp` | Scope ownership and teardown order | +| `tests/test_reactive_store.cpp` | Store, `ExhaustiveUpdate`, `request()` over a `LocalBackend` | +| `tests/test_reactive_query.cpp` | Query over settleable completions | +| `tests/test_reactive_mutation.cpp` | Mutation, Subscription-free cases, `errorMessage` | +| `tests/test_reactive_control_bridge.cpp` | Query, Mutation, Subscription, `refreshOn` over a real `LocalBackend` | +| `docs/spec/reactive/signals.md`, `store.md`, `control.md` | Authoritative specs | +| `CMakeLists.txt`, `tests/CMakeLists.txt` | Header and test registration | +| `docs/spec/README.md`, `docs/ARCHITECTURE.md`, `CHANGELOG.md` | Maps and changelog | + +## Build and test commands (used by every task) + +```bash +cmake -S . -B build/reactive -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_EXAMPLES=OFF # once +cmake --build build/reactive --target morph_tests +./build/reactive/tests/morph_tests "[reactive]" +``` + +Configuring prints `morph: warnings: ... strict=ON`; if it does not, stop and say so (CONTRIBUTING, "Warnings +are errors"). + +--- +### Task 1: The graph core, `Runtime`, `Signal` and `Effect` + +**Files:** +- Create: `include/morph/reactive/detail/graph.hpp` +- Create: `include/morph/reactive/runtime.hpp` +- Create: `include/morph/reactive/signal.hpp` +- Modify: `CMakeLists.txt` — the `FILE_SET HEADERS` block of `target_sources(morph …)` (the one listing + `include/morph/version.hpp` first) and the `FILE_SET morph_detail_headers` block +- Modify: `tests/CMakeLists.txt` — the `add_executable(morph_tests …)` source list +- Test: `tests/test_reactive_signal.cpp` + +**Interfaces:** +- Consumes: `morph::exec::IExecutor` (`include/morph/core/executor.hpp`), + `morph::exec::detail::OwnerAffinity` (`core/detail/owner_affinity.hpp`), + `morph::exec::detail::noteOwner` (`core/detail/owner_probe.hpp`), `MORPH_LIFETIMEBOUND` (`attributes.hpp`). +- Produces (later tasks rely on these exact names): + - `morph::reactive::detail::site::{kOffOwner, kEffectThrew, kWriteCycle, kSetInComputed, + kComputedReadsItself, kSendInUpdate, kRuntimeOutlived, kFlushInWidgetEvent}` — `char const*` constants. + - `detail::Colour { Clean, Check, Dirty }`. + - `detail::RuntimeCore`: `checkOwner() -> bool`, `report(char const*)`, `owner()`, `isComputing()`, + `enterComputed()`, `leaveComputed()`, `tracking()`, `isFlushRequested()`, `liveNodes()`. + - `detail::TrackingFrame(RuntimeCore&, Node* observer)`, `takeSources()`. + - `detail::Node(std::shared_ptr)`: public `markStale(Colour)`, `updateIfNecessary()`, + `countRun(std::uint64_t)`, `settle()`; protected `core()`, `trackRead()`, `forceDirty()`, + `markObserversStale(Colour)`, `adoptSources(std::vector)`, `mergeSources(std::vector)`, + pure virtual `recompute() -> bool`, virtual `isEffect() -> bool`. + - `detail::BatchScope(RuntimeCore&)`, `detail::WidgetEventScope(RuntimeCore&)`. + - `reactive::RuntimeOptions { std::size_t maxEffectRunsPerFlush = 100; std::function afterFlush; }`. + - `reactive::Runtime(exec::IExecutor&, RuntimeOptions = {})`: `batch(F)`, `widgetEvent(F)`, `untracked(F)`, + `owner()`, `isFlushRequested()`, `core() -> std::shared_ptr const&`. + - `reactive::Signal(Runtime&)`, `Signal(Runtime&, T)`: `get()`, `peek()`, `set(T)`, `mutate(F)`. + - `reactive::Effect(Runtime&, F)`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_reactive_signal.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace { + +using morph::reactive::Effect; +using morph::reactive::Runtime; +using morph::reactive::RuntimeOptions; +using morph::reactive::Signal; +using Owner = morph::testing::StepExecutor; + +// No operator==: the equality skip must not apply to it. +struct Opaque { + int value = 0; +}; + +} // namespace + +static_assert(!std::is_copy_constructible_v> && !std::is_move_constructible_v>); +static_assert(!std::is_copy_assignable_v> && !std::is_move_assignable_v>); +static_assert(!std::is_copy_constructible_v && !std::is_move_constructible_v); +static_assert(!std::is_copy_constructible_v && !std::is_move_constructible_v); + +TEST_CASE("reactive::Signal: get returns the initial value and set replaces it", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal count{runtime, 1}; + CHECK(count.get() == 1); + count.set(2); + CHECK(count.peek() == 2); +} + +TEST_CASE("reactive::Effect: runs on construction, then in a posted flush after a source changes", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal count{runtime, 1}; + std::vector seen; + Effect const log{runtime, [&] { seen.push_back(count.get()); }}; + CHECK(seen == std::vector{1}); + + count.set(2); + CHECK(seen == std::vector{1}); + owner.runAll(); + CHECK(seen == std::vector{1, 2}); +} + +TEST_CASE("reactive::Runtime: nested batches are one flush and one run", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal first{runtime, 0}; + Signal second{runtime, 0}; + int runs = 0; + Effect const sum{runtime, [&] { + static_cast(first.get() + second.get()); + ++runs; + }}; + runtime.batch([&] { + first.set(1); + second.set(2); + runtime.batch([&] { first.set(3); }); + CHECK(owner.pending() == 0); + }); + CHECK(owner.pending() == 1); + owner.runAll(); + CHECK(runs == 2); +} + +TEST_CASE("reactive::Runtime: exactly one post per idle-to-pending transition", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal value{runtime, 0}; + Effect const watch{runtime, [&] { static_cast(value.get()); }}; + value.set(1); + value.set(2); + value.set(3); + CHECK(owner.pending() == 1); + CHECK(runtime.isFlushRequested()); + owner.runAll(); + CHECK_FALSE(runtime.isFlushRequested()); + value.set(4); + CHECK(owner.pending() == 1); +} + +TEST_CASE("reactive::Signal: an equal value notifies nobody; a non-comparable one always does", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal comparable{runtime, 1}; + Signal opaque{runtime, Opaque{1}}; + Effect const watch{runtime, [&] { + static_cast(comparable.get()); + static_cast(opaque.get()); + }}; + comparable.set(1); + CHECK(owner.pending() == 0); + opaque.set(Opaque{1}); + CHECK(owner.pending() == 1); +} + +TEST_CASE("reactive::Signal: mutate always notifies", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal> list{runtime, {}}; + std::size_t lastSize = 99; + Effect const watch{runtime, [&] { lastSize = list.get().size(); }}; + CHECK(lastSize == 0); + list.mutate([](std::vector& items) { items.push_back(7); }); + owner.runAll(); + CHECK(lastSize == 1); +} + +TEST_CASE("reactive::Effect: a source it stops reading no longer triggers it", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal useFirst{runtime, true}; + Signal first{runtime, 0}; + Signal second{runtime, 0}; + int runs = 0; + Effect const pick{runtime, [&] { + ++runs; + static_cast(useFirst.get() ? first.get() : second.get()); + }}; + useFirst.set(false); + owner.runAll(); + CHECK(runs == 2); + + first.set(1); + CHECK(owner.pending() == 0); + + second.set(1); + owner.runAll(); + CHECK(runs == 3); +} + +TEST_CASE("reactive::Effect: destruction unlinks it from its sources", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal value{runtime, 0}; + { Effect const transient{runtime, [&] { static_cast(value.get()); }}; } + value.set(1); + CHECK(owner.pending() == 0); +} + +TEST_CASE("reactive::Signal: destruction unlinks it from its observers", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + auto source = std::make_unique>(runtime, 0); + Signal other{runtime, 0}; + int runs = 0; + Effect const watch{runtime, [&] { + ++runs; + static_cast(other.get()); + if (source) { + static_cast(source->get()); + } + }}; + source.reset(); + other.set(1); + owner.runAll(); // the effect re-tracks without the destroyed source; ASan would flag a dangling link + CHECK(runs == 2); +} + +TEST_CASE("reactive::Runtime: a flush posted before the Runtime is destroyed is a no-op", "[reactive]") { + Owner owner; + int runs = 0; + { + Runtime runtime{owner}; + Signal value{runtime, 0}; + Effect const watch{runtime, [&] { + static_cast(value.get()); + ++runs; + }}; + value.set(1); + CHECK(owner.pending() == 1); + } + owner.runAll(); + CHECK(runs == 1); +} + +TEST_CASE("reactive::Runtime: afterFlush runs once per flush that ran effects", "[reactive]") { + Owner owner; + int frames = 0; + Runtime runtime{owner, RuntimeOptions{.afterFlush = [&] { ++frames; }}}; + Signal value{runtime, 0}; + Effect const watch{runtime, [&] { static_cast(value.get()); }}; + value.set(1); + value.set(2); + owner.runAll(); + CHECK(frames == 1); +} + +TEST_CASE("reactive::Runtime: untracked reads do not subscribe", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal value{runtime, 0}; + int runs = 0; + Effect const watch{runtime, [&] { + ++runs; + static_cast(runtime.untracked([&] { return value.get(); })); + }}; + value.set(1); + CHECK(owner.pending() == 0); + CHECK(runs == 1); +} + +// Review Focus 1. +TEST_CASE("reactive::Effect: an Effect that writes what it reads does not re-run itself", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal trigger{runtime, 0}; + Signal counter{runtime, 0}; + int runs = 0; + Effect const bump{runtime, [&] { + static_cast(trigger.get()); + counter.set(counter.get() + 1); + ++runs; + }}; + trigger.set(1); + owner.runAll(); + CHECK(runs == 2); + CHECK(counter.peek() == 2); + CHECK(owner.pending() == 0); +} + +// Review Focus 2. +TEST_CASE("reactive::Effect: a queued Effect destroyed during the flush is skipped", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal value{runtime, 0}; + std::unique_ptr victim; + int victimRuns = 0; + Effect const killer{runtime, [&] { + if (value.get() == 1) { + victim.reset(); + } + }}; + victim = std::make_unique(runtime, [&] { + static_cast(value.get()); + ++victimRuns; + }); + value.set(1); // queues killer, then victim + owner.runAll(); + CHECK(victimRuns == 1); + CHECK(victim == nullptr); +} + +// Review Focus 3. +TEST_CASE("reactive::Runtime: a throwing batch body still flushes what it wrote", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal value{runtime, 0}; + std::vector seen; + Effect const watch{runtime, [&] { seen.push_back(value.get()); }}; + CHECK_THROWS_AS(runtime.batch([&] { + value.set(5); + throw std::runtime_error{"body failed"}; + }), + std::runtime_error); + CHECK(owner.pending() == 1); + owner.runAll(); + CHECK(seen == std::vector{0, 5}); + value.set(6); // the batch depth returned to zero, so this posts again + CHECK(owner.pending() == 1); +} +``` + +Register it: in `tests/CMakeLists.txt`, add `test_reactive_signal.cpp` to the `add_executable(morph_tests …)` list +directly after `test_callback_scope.cpp`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `fatal error: 'morph/reactive/runtime.hpp' file not found`. + +- [ ] **Step 3: Write the graph core** + +Create `include/morph/reactive/detail/graph.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../attributes.hpp" +#include "../../core/detail/owner_affinity.hpp" +#include "../../core/detail/owner_probe.hpp" +#include "../../core/executor.hpp" + +/// @file +/// @brief The reactive graph's internals: node colours and links, the tracking +/// frame, batching and the flush queue. +/// +/// Specified in `docs/spec/reactive/signals.md`. + +namespace morph::reactive::detail { + +/// @brief The site names a misuse is reported under through `exec::detail::noteOwner`. +namespace site { +/// @brief An operation ran off the Runtime's owner; it is dropped. +inline constexpr char const* kOffOwner = "morph::reactive: off the Runtime's owner"; +/// @brief An exception escaped an Effect; the flush stops. +inline constexpr char const* kEffectThrew = "morph::reactive: an exception escaped an Effect"; +/// @brief An Effect re-ran more than `maxEffectRunsPerFlush` times in one flush; the flush stops. +inline constexpr char const* kWriteCycle = "morph::reactive: an Effect re-ran past maxEffectRunsPerFlush"; +/// @brief A signal was written while a Computed was computing; the write is dropped. +inline constexpr char const* kSetInComputed = "morph::reactive: set() inside a Computed"; +/// @brief A Computed read itself; the read throws `std::logic_error`. +inline constexpr char const* kComputedReadsItself = "morph::reactive: a Computed read itself"; +/// @brief `Store::send` was called from inside an update; the message is dropped. +inline constexpr char const* kSendInUpdate = "morph::reactive: send() inside an update"; +/// @brief A Runtime was destroyed while nodes made from it were alive. +inline constexpr char const* kRuntimeOutlived = "morph::reactive: Runtime destroyed while nodes are alive"; +/// @brief A flush ran inside a widget event; it is re-posted. +inline constexpr char const* kFlushInWidgetEvent = "morph::reactive: flush inside a widget event"; +} // namespace site + +/// @brief A node's freshness. Ordered: a later enumerator is staler. +enum class Colour : std::uint8_t { + Clean, ///< Up to date. + Check, ///< A transitive source changed: pull the sources before deciding to recompute. + Dirty, ///< A direct source changed: recompute. +}; + +class Node; +class TrackingFrame; + +/// @brief State shared by one `Runtime` and every node made from it. +/// +/// Nodes hold it by `shared_ptr`, so a node that outlives its `Runtime` (a +/// reported misuse) still has a valid core. The posted flush holds it weakly +/// and checks `detach()`, so a flush after the `Runtime` is gone does nothing. +class RuntimeCore : public std::enable_shared_from_this { +public: + /// @param owner The executor every operation must run on, and the one flushes are posted to. + /// Borrowed: it must outlive the core. + explicit RuntimeCore(exec::IExecutor& owner MORPH_LIFETIMEBOUND) noexcept : _affinity{owner} {} + + ~RuntimeCore() = default; + RuntimeCore(RuntimeCore const&) = delete; + RuntimeCore& operator=(RuntimeCore const&) = delete; + RuntimeCore(RuntimeCore&&) = delete; + RuntimeCore& operator=(RuntimeCore&&) = delete; + + /// @brief Whether the calling thread is on the owner, reporting `site::kOffOwner` when it is not. + /// @return True on the owner: inside one of its tasks, or on the thread that built the Runtime + /// outside every executor's task (a Qt slot, `main()`, a test body). + [[nodiscard]] bool checkOwner() const noexcept { + if (_affinity.here()) { + return true; + } + report(site::kOffOwner); + return false; + } + + /// @brief Reports a misuse: asserts in a debug build, or hands it to an installed owner probe. + /// @param where One of the `site::` constants. + void report(char const* where) const noexcept { + exec::detail::noteOwner(where, _affinity.owner().coreExecutor(), false); + } + + /// @brief The owner executor. + /// @return The executor passed at construction. + [[nodiscard]] exec::IExecutor& owner() const noexcept { return _affinity.owner(); } + + /// @brief Sets the flush bound and the after-flush hook. + /// @param maxEffectRunsPerFlush How often one Effect may run in a flush before it is a write cycle. + /// @param afterFlush Called after every flush that ran at least one Effect; may be empty. + void configure(std::size_t maxEffectRunsPerFlush, std::function afterFlush) { + _maxEffectRunsPerFlush = maxEffectRunsPerFlush; + _afterFlush = std::move(afterFlush); + } + + /// @brief Opens a batch; batches nest. + void beginBatch() noexcept { ++_batchDepth; } + + /// @brief Closes a batch; closing the outermost one requests a flush when effects are queued. + void endBatch() { + if (--_batchDepth == 0) { + requestFlush(); + } + } + + /// @brief Queues an Effect that turned stale. + /// @param effect The Effect node. + void enqueue(Node& effect) { _queue.push_back(&effect); } + + /// @brief Posts one flush to the owner, unless one is already requested or running, nothing is + /// queued, or the Runtime is gone. + void requestFlush() { + if (_flushing || _isFlushRequested || _queue.empty() || _detached) { + return; + } + _affinity.owner().post([weak = weak_from_this()] { + if (auto const core = weak.lock()) { + core->flush(); + } + }); + // Set only once the post succeeded: a post that fails to allocate leaves the queue to the + // next write. + _isFlushRequested = true; + } + + /// @brief Runs every queued Effect that a source change really affects. Defined below `Node`. + void flush(); + + /// @brief Removes every reference the core holds to a node being destroyed. Defined below `Node`. + /// @param node The node. + void forget(Node& node) noexcept; + + /// @brief The innermost tracking frame, or null outside every tracked run. + /// @return The frame reads register with. + [[nodiscard]] TrackingFrame* tracking() const noexcept { return _tracking; } + + /// @brief Installs the innermost tracking frame. + /// @param frame The frame, or null. + void setTracking(TrackingFrame* frame) noexcept { _tracking = frame; } + + /// @brief Whether a Computed is computing on this runtime. + /// @return True between `enterComputed()` and the matching `leaveComputed()`. + [[nodiscard]] bool isComputing() const noexcept { return _computing != 0; } + + /// @brief Marks a Computed as computing. + void enterComputed() noexcept { ++_computing; } + + /// @brief Marks a Computed as done computing. + void leaveComputed() noexcept { --_computing; } + + /// @brief Marks the start of a widget callback. + void enterWidgetEvent() noexcept { ++_widgetEventDepth; } + + /// @brief Marks the end of a widget callback. + void leaveWidgetEvent() noexcept { --_widgetEventDepth; } + + /// @brief Whether a flush is posted and has not run yet. + /// @return The coalescing flag. + [[nodiscard]] bool isFlushRequested() const noexcept { return _isFlushRequested; } + + /// @brief Marks the Runtime as gone: flushes do nothing from now on. + void detach() noexcept { _detached = true; } + + /// @brief Counts a node made from this runtime. + void nodeCreated() noexcept { ++_liveNodes; } + + /// @brief Counts a node destroyed. + void nodeDestroyed() noexcept { --_liveNodes; } + + /// @brief How many nodes made from this runtime are alive. + /// @return The live-node count. + [[nodiscard]] std::size_t liveNodes() const noexcept { return _liveNodes; } + +private: + void dropQueue() noexcept; + + exec::detail::OwnerAffinity _affinity; + std::deque _queue; + std::function _afterFlush; + TrackingFrame* _tracking = nullptr; + std::size_t _maxEffectRunsPerFlush = 100; + std::size_t _batchDepth = 0; + std::size_t _computing = 0; + std::size_t _widgetEventDepth = 0; + std::size_t _liveNodes = 0; + std::uint64_t _flushEpoch = 0; + bool _isFlushRequested = false; + bool _flushing = false; + bool _detached = false; +}; + +/// @brief Collects the sources one tracked run reads, and restores the previous frame when it ends. +/// +/// A frame whose observer is null is an untracked region: reads inside it subscribe nothing. +class TrackingFrame { +public: + /// @param core The runtime core. + /// @param observer The node whose run this is, or null for an untracked region. + TrackingFrame(RuntimeCore& core, Node* observer) noexcept + : _core{&core}, _previous{core.tracking()}, _observer{observer} { + core.setTracking(this); + } + + ~TrackingFrame() { _core->setTracking(_previous); } + TrackingFrame(TrackingFrame const&) = delete; + TrackingFrame& operator=(TrackingFrame const&) = delete; + TrackingFrame(TrackingFrame&&) = delete; + TrackingFrame& operator=(TrackingFrame&&) = delete; + + /// @brief Records that the run read @p source; a no-op in an untracked region. + /// @param source The node read. + void add(Node& source) { + if (_observer != nullptr && std::ranges::find(_sources, &source) == _sources.end()) { + _sources.push_back(&source); + } + } + + /// @brief Drops @p node, which is being destroyed, from the recorded sources. + /// @param node The node. + void forget(Node& node) noexcept { std::erase(_sources, &node); } + + /// @brief Hands the recorded sources to the observer. + /// @return The sources, deduplicated, in first-read order. + [[nodiscard]] std::vector takeSources() noexcept { return std::move(_sources); } + + /// @brief The enclosing frame. + /// @return The frame that was innermost when this one was opened, or null. + [[nodiscard]] TrackingFrame* previous() const noexcept { return _previous; } + +private: + RuntimeCore* _core; + TrackingFrame* _previous; + Node* _observer; + std::vector _sources; +}; + +/// @brief A graph node: a source (Signal), an observer (Effect), or both (Computed). +/// +/// Non-copyable and non-movable: other nodes hold raw pointers to it, and its +/// destructor unlinks it in both directions. +class Node { +public: + /// @param core The runtime core this node belongs to. + explicit Node(std::shared_ptr core) : _core{std::move(core)} { _core->nodeCreated(); } + + virtual ~Node() { + for (Node* const source : _sources) { + std::erase(source->_observers, this); + } + for (Node* const observer : _observers) { + std::erase(observer->_sources, this); + } + _core->forget(*this); + _core->nodeDestroyed(); + } + + Node(Node const&) = delete; + Node& operator=(Node const&) = delete; + Node(Node&&) = delete; + Node& operator=(Node&&) = delete; + + /// @brief Push phase: raises this node to @p colour and marks its observers Check. + /// + /// An Effect is queued when it leaves Clean. A node already at least as stale stops the walk. + /// @param colour Dirty for a direct observer of a change, Check for a transitive one. + void markStale(Colour colour) { + if (_colour >= colour) { + return; + } + bool const wasClean = _colour == Colour::Clean; + _colour = colour; + if (wasClean && isEffect()) { + _core->enqueue(*this); + } + for (Node* const observer : _observers) { + observer->markStale(Colour::Check); + } + } + + /// @brief Pull phase: brings this node up to date, recomputing only if a source really changed. + /// + /// A recompute that throws leaves the node Clean (so later changes still reach it) and rethrows. + void updateIfNecessary() { + if (_colour == Colour::Check) { + for (std::size_t i = 0; i < _sources.size(); ++i) { + _sources.at(i)->updateIfNecessary(); + if (_colour == Colour::Dirty) { + break; + } + } + } + if (_colour == Colour::Dirty) { + bool changed = false; + try { + changed = recompute(); + } catch (...) { + _colour = Colour::Clean; + throw; + } + if (changed) { + markObserversStale(Colour::Dirty); + } + } + _colour = Colour::Clean; + } + + /// @brief Counts one run of this Effect in the flush numbered @p epoch. + /// @param epoch The current flush's number. + /// @return How many times it has run in that flush, this one included. + [[nodiscard]] std::size_t countRun(std::uint64_t epoch) noexcept { + if (_epoch != epoch) { + _epoch = epoch; + _runs = 0; + } + return ++_runs; + } + + /// @brief Marks this node Clean without running it (a dropped queue entry). + void settle() noexcept { _colour = Colour::Clean; } + +protected: + /// @brief The runtime core. + /// @return The core this node belongs to. + [[nodiscard]] RuntimeCore& core() const noexcept { return *_core; } + + /// @brief Registers this node as a source of the innermost tracked run, if any. + void trackRead() const { + if (TrackingFrame* const frame = _core->tracking(); frame != nullptr) { + // Reading is logically const; the dependency link is bookkeeping. + frame->add(const_cast(*this)); // NOLINT(cppcoreguidelines-pro-type-const-cast) + } + } + + /// @brief Marks this node Dirty, so the next pull recomputes it. + void forceDirty() noexcept { _colour = Colour::Dirty; } + + /// @brief Marks every observer of this node @p colour. + /// @param colour The colour to raise them to. + void markObserversStale(Colour colour) { + for (Node* const observer : _observers) { + observer->markStale(colour); + } + } + + /// @brief Replaces this node's sources with @p fresh, unlinking the ones no longer read. + /// @param fresh The sources the latest run read. + void adoptSources(std::vector fresh) { + for (Node* const old : _sources) { + if (std::ranges::find(fresh, old) == fresh.end()) { + std::erase(old->_observers, this); + } + } + for (Node* const source : fresh) { + if (std::ranges::find(_sources, source) == _sources.end()) { + source->_observers.push_back(this); + } + } + _sources = std::move(fresh); + } + + /// @brief Adds @p fresh to this node's sources without dropping any: what a failed run keeps. + /// @param fresh The sources the failed run read before it threw. + void mergeSources(std::vector fresh) { + for (Node* const old : _sources) { + if (std::ranges::find(fresh, old) == fresh.end()) { + fresh.push_back(old); + } + } + adoptSources(std::move(fresh)); + } + + /// @brief Brings the node's value up to date. + /// @return True when observers must treat the value as changed. + virtual bool recompute() = 0; + + /// @brief Whether this node is an Effect, which the flush queue runs. + /// @return False unless overridden. + [[nodiscard]] virtual bool isEffect() const noexcept { return false; } + +private: + std::shared_ptr _core; + std::vector _sources; + std::vector _observers; + std::uint64_t _epoch = 0; + std::size_t _runs = 0; + Colour _colour = Colour::Clean; +}; + +inline void RuntimeCore::flush() { + _isFlushRequested = false; + if (_detached || _queue.empty()) { + return; + } + if (_widgetEventDepth != 0) { + // A widget whose native handler is on the stack must not be torn down by a remount. + report(site::kFlushInWidgetEvent); + requestFlush(); + return; + } + _flushing = true; + ++_flushEpoch; + bool stopped = false; + while (!_queue.empty()) { + Node* const effect = _queue.front(); + _queue.pop_front(); + if (effect->countRun(_flushEpoch) > _maxEffectRunsPerFlush) { + report(site::kWriteCycle); + effect->settle(); + dropQueue(); + break; + } + try { + effect->updateIfNecessary(); + } catch (...) { + report(site::kEffectThrew); + stopped = true; + break; + } + } + _flushing = false; + if (stopped) { + // What is still queued runs in a flush of its own. + requestFlush(); + } + if (_afterFlush) { + _afterFlush(); + } +} + +inline void RuntimeCore::forget(Node& node) noexcept { + std::erase(_queue, &node); + for (TrackingFrame* frame = _tracking; frame != nullptr; frame = frame->previous()) { + frame->forget(node); + } +} + +inline void RuntimeCore::dropQueue() noexcept { + for (Node* const node : _queue) { + node->settle(); + } + _queue.clear(); +} + +/// @brief Opens a batch for its lifetime. +class BatchScope { +public: + /// @param core The runtime core. + explicit BatchScope(RuntimeCore& core) noexcept : _core{&core} { core.beginBatch(); } + ~BatchScope() { + try { + _core->endBatch(); + } catch (...) { // NOLINT(bugprone-empty-catch) + // Posting the flush failed to allocate. The queue is kept and the next write posts again. + } + } + BatchScope(BatchScope const&) = delete; + BatchScope& operator=(BatchScope const&) = delete; + BatchScope(BatchScope&&) = delete; + BatchScope& operator=(BatchScope&&) = delete; + +private: + RuntimeCore* _core; +}; + +/// @brief Marks a widget callback for its lifetime. +class WidgetEventScope { +public: + /// @param core The runtime core. + explicit WidgetEventScope(RuntimeCore& core) noexcept : _core{&core} { core.enterWidgetEvent(); } + ~WidgetEventScope() { _core->leaveWidgetEvent(); } + WidgetEventScope(WidgetEventScope const&) = delete; + WidgetEventScope& operator=(WidgetEventScope const&) = delete; + WidgetEventScope(WidgetEventScope&&) = delete; + WidgetEventScope& operator=(WidgetEventScope&&) = delete; + +private: + RuntimeCore* _core; +}; + +} // namespace morph::reactive::detail +``` + +- [ ] **Step 4: Write the Runtime** + +Create `include/morph/reactive/runtime.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include + +#include "../attributes.hpp" +#include "../core/executor.hpp" +#include "detail/graph.hpp" + +/// @file +/// @brief `morph::reactive::Runtime`: the owner, batching and flush scheduling of a signal graph. +/// +/// Specified in `docs/spec/reactive/signals.md`. + +namespace morph::reactive { + +/// @brief How a `Runtime` bounds and reports its flushes. +struct RuntimeOptions { + /// @brief How many times one Effect may run in a single flush before the flush is treated as a + /// write cycle, reported, and stopped. + std::size_t maxEffectRunsPerFlush = 100; + /// @brief Called on the owner after every flush that ran at least one Effect; a frontend + /// schedules its redraw here. May be empty. + std::function afterFlush; +}; + +/// @brief The owner of a signal graph: every node takes one, and every operation runs on its owner. +/// +/// Writes are batched. When the outermost batch ends with effects queued, the +/// runtime posts **one** flush to the owner; no Effect ever runs inside `set()`. +/// Destroying a Runtime while nodes made from it are alive is reported +/// (`detail::site::kRuntimeOutlived`); the nodes stay valid and any flush does nothing. +class Runtime { +public: + /// @param owner The executor the graph belongs to (the GUI or loop executor). Borrowed: it must + /// outlive the Runtime and every node made from it. + /// @param options Flush bound and after-flush hook. + explicit Runtime(exec::IExecutor& owner MORPH_LIFETIMEBOUND, RuntimeOptions options = {}) + : _core{std::make_shared(owner)} { + _core->configure(options.maxEffectRunsPerFlush, std::move(options.afterFlush)); + } + + ~Runtime() { + if (_core->liveNodes() != 0) { + _core->report(detail::site::kRuntimeOutlived); + } + _core->detach(); + } + + Runtime(Runtime const&) = delete; + Runtime& operator=(Runtime const&) = delete; + Runtime(Runtime&&) = delete; + Runtime& operator=(Runtime&&) = delete; + + /// @brief Runs @p fn as one batch: every write inside it is one flush. + /// @tparam F A callable taking no arguments. + /// @param fn The body. Writes made before it throws still flush. + /// @return Whatever @p fn returns. + template + decltype(auto) batch(F&& fn) { + detail::BatchScope const scope{*_core}; + return std::invoke(std::forward(fn)); + } + + /// @brief Runs a widget callback: one batch, during which a flush is refused and re-posted, so a + /// remount never destroys a widget whose native handler is on the stack. + /// @tparam F A callable taking no arguments. + /// @param fn The callback. + /// @return Whatever @p fn returns. + template + decltype(auto) widgetEvent(F&& fn) { + detail::WidgetEventScope const event{*_core}; + return batch(std::forward(fn)); + } + + /// @brief Runs @p fn with tracking off: reads inside it subscribe nothing. + /// @tparam F A callable taking no arguments. + /// @param fn The body. + /// @return Whatever @p fn returns. + template + decltype(auto) untracked(F&& fn) const { + detail::TrackingFrame const frame{*_core, nullptr}; + return std::invoke(std::forward(fn)); + } + + /// @brief The owner executor. + /// @return The executor passed at construction. + [[nodiscard]] exec::IExecutor& owner() const noexcept { return _core->owner(); } + + /// @brief Whether a flush is posted and has not run yet. + /// @return True between the first write that queued an Effect and the flush. + [[nodiscard]] bool isFlushRequested() const noexcept { return _core->isFlushRequested(); } + + /// @brief The shared core every node holds. + /// @return The core. + [[nodiscard]] std::shared_ptr const& core() const noexcept { return _core; } + +private: + std::shared_ptr _core; +}; + +} // namespace morph::reactive +``` + +- [ ] **Step 5: Write `Signal` and `Effect`** + +Create `include/morph/reactive/signal.hpp` (Task 2 adds `Computed` to this file): + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include + +#include "detail/graph.hpp" +#include "runtime.hpp" + +/// @file +/// @brief `Signal`, `Computed` and `Effect`: the nodes of a reactive graph. +/// +/// Specified in `docs/spec/reactive/signals.md`. + +namespace morph::reactive { + +/// @brief A source value. Reading it with `get()` inside a tracked run subscribes that run. +/// +/// `set()` skips a value equal to the current one when `T` is equality-comparable; `mutate()` +/// always notifies. Writes are refused, and reported, off the owner or inside a Computed. +/// @tparam T The value type; move-constructible and move-assignable. +template +class Signal final : public detail::Node { +public: + /// @brief Constructs a value-initialised signal. + /// @param runtime The runtime this signal belongs to. + explicit Signal(Runtime& runtime) + requires std::default_initializable + : Node{runtime.core()}, _value{} {} + + /// @brief Constructs a signal holding @p initial. + /// @param runtime The runtime this signal belongs to. + /// @param initial The initial value. + Signal(Runtime& runtime, T initial) : Node{runtime.core()}, _value{std::move(initial)} {} + + ~Signal() override = default; + Signal(Signal const&) = delete; + Signal& operator=(Signal const&) = delete; + Signal(Signal&&) = delete; + Signal& operator=(Signal&&) = delete; + + /// @brief Reads the value, subscribing the innermost tracked run. + /// @return The current value; valid until the next write. + [[nodiscard]] T const& get() const { + static_cast(core().checkOwner()); + trackRead(); + return _value; + } + + /// @brief Reads the value without subscribing anything. + /// @return The current value; valid until the next write. + [[nodiscard]] T const& peek() const noexcept { return _value; } + + /// @brief Replaces the value and notifies observers, unless it equals the current one. + /// @param value The new value. + void set(T value) { + if (!writable()) { + return; + } + if constexpr (std::equality_comparable) { + if (_value == value) { + return; + } + } + _value = std::move(value); + notify(); + } + + /// @brief Changes the value in place and always notifies observers. + /// @tparam F A callable taking `T&`. + /// @param fn The mutation. + template + requires std::invocable + void mutate(F&& fn) { + if (!writable()) { + return; + } + std::invoke(fn, _value); + notify(); + } + +private: + [[nodiscard]] bool writable() const noexcept { + if (!core().checkOwner()) { + return false; + } + if (core().isComputing()) { + core().report(detail::site::kSetInComputed); + return false; + } + return true; + } + + void notify() { + detail::BatchScope const batch{core()}; + markObserversStale(detail::Colour::Dirty); + } + + bool recompute() override { return false; } + + T _value; +}; + +/// @brief A side effect: runs once on construction, then in a flush after any source it read changed. +/// +/// An exception escaping it is a defect: reported (`detail::site::kEffectThrew`), and the flush stops. +class Effect final : public detail::Node { +public: + /// @tparam F A callable taking no arguments. + /// @param runtime The runtime this effect belongs to. + /// @param fn The body; every signal it reads becomes a dependency. + template + requires std::invocable + Effect(Runtime& runtime, F fn) : Node{runtime.core()}, _fn{std::move(fn)} { + forceDirty(); + if (!core().checkOwner()) { + return; + } + try { + updateIfNecessary(); + } catch (...) { + core().report(detail::site::kEffectThrew); + } + } + + ~Effect() override = default; + Effect(Effect const&) = delete; + Effect& operator=(Effect const&) = delete; + Effect(Effect&&) = delete; + Effect& operator=(Effect&&) = delete; + +private: + [[nodiscard]] bool isEffect() const noexcept override { return true; } + + bool recompute() override { + detail::TrackingFrame frame{core(), this}; + try { + std::invoke(_fn); + } catch (...) { + mergeSources(frame.takeSources()); + throw; + } + adoptSources(frame.takeSources()); + return false; + } + + std::function _fn; +}; + +} // namespace morph::reactive +``` + +- [ ] **Step 6: Register the headers** + +In `CMakeLists.txt`, in the `FILE_SET HEADERS` block of `target_sources(morph …)`, add after +`include/morph/util/datetime.hpp`: + +```cmake + include/morph/reactive/runtime.hpp + include/morph/reactive/signal.hpp +``` + +and in the `FILE_SET morph_detail_headers` block, after `include/morph/core/detail/owner_affinity.hpp`: + +```cmake + include/morph/reactive/detail/graph.hpp +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[reactive]"` +Expected: PASS, `All tests passed (… assertions in 15 test cases)`. + +Then mutate once to prove the scheduling tests measure something: in `RuntimeCore::requestFlush`, delete +`_isFlushRequested ||` from the guard, rebuild, and run again. Expected: FAIL in "exactly one post per +idle-to-pending transition". Restore the line. + +- [ ] **Step 8: Commit** + +```bash +git add include/morph/reactive tests/test_reactive_signal.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(reactive): signal graph core, Runtime, Signal and Effect + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 2: `Computed` and glitch-freedom + +**Files:** +- Modify: `include/morph/reactive/signal.hpp` — add `Computed` between `Signal` and `Effect` +- Modify: `tests/CMakeLists.txt` — add `test_reactive_computed.cpp` after `test_reactive_signal.cpp` +- Test: `tests/test_reactive_computed.cpp` + +**Interfaces:** +- Consumes: everything Task 1 produces. +- Produces: `reactive::Computed(Runtime&, F)` with `get() const -> T const&` (tracked; rethrows a failed + computation, retries on the next read) and `peek() const -> T const&` (untracked). + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_reactive_computed.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace { + +using morph::reactive::Computed; +using morph::reactive::Effect; +using morph::reactive::Runtime; +using morph::reactive::Signal; +using Owner = morph::testing::StepExecutor; + +class NoDefault { +public: + explicit NoDefault(int value) : _value{value} {} + [[nodiscard]] int value() const { return _value; } + bool operator==(NoDefault const&) const = default; + +private: + int _value; +}; + +struct Opaque { + int value = 0; +}; + +} // namespace + +static_assert(!std::is_copy_constructible_v> && !std::is_move_constructible_v>); + +TEST_CASE("reactive::Computed: lazy, cached, recomputed only after a source changes", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal base{runtime, 2}; + int calls = 0; + Computed twice{runtime, [&] { + ++calls; + return base.get() * 2; + }}; + CHECK(calls == 0); + CHECK(twice.get() == 4); + CHECK(twice.get() == 4); + CHECK(calls == 1); + base.set(3); + CHECK(twice.get() == 6); + CHECK(calls == 2); +} + +TEST_CASE("reactive::Computed: a diamond is seen consistently, once per change", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal base{runtime, 1}; + Computed twice{runtime, [&] { return base.get() * 2; }}; + Computed thrice{runtime, [&] { return base.get() * 3; }}; + Computed sum{runtime, [&] { return twice.get() + thrice.get(); }}; + std::vector seen; + Effect const log{runtime, [&] { seen.push_back(sum.get()); }}; + base.set(2); + owner.runAll(); + CHECK(seen == std::vector{5, 10}); +} + +TEST_CASE("reactive::Computed: an Effect reading A before Computed(A) never sees them disagree", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal base{runtime, 1}; + Computed twice{runtime, [&] { return base.get() * 2; }}; + std::vector> seen; + Effect const log{runtime, [&] { + int const raw = base.get(); + seen.emplace_back(raw, twice.get()); + }}; + base.set(5); + owner.runAll(); + REQUIRE(seen.size() == 2); + for (auto const& [raw, doubled] : seen) { + CHECK(doubled == raw * 2); + } +} + +TEST_CASE("reactive::Computed: an unchanged result stops propagation", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal base{runtime, 1}; + Computed positive{runtime, [&] { return base.get() > 0; }}; + int runs = 0; + Effect const watch{runtime, [&] { + ++runs; + static_cast(positive.get()); + }}; + base.set(2); + owner.runAll(); + CHECK(runs == 1); + base.set(-1); + owner.runAll(); + CHECK(runs == 2); +} + +TEST_CASE("reactive::Computed: a non-comparable result always propagates", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal base{runtime, 1}; + Computed wrapped{runtime, [&] { return Opaque{base.get() > 0 ? 1 : 0}; }}; + int runs = 0; + Effect const watch{runtime, [&] { + ++runs; + static_cast(wrapped.get()); + }}; + base.set(2); + owner.runAll(); + CHECK(runs == 2); +} + +TEST_CASE("reactive::Computed: T need not be default-constructible", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal base{runtime, 7}; + Computed boxed{runtime, [&] { return NoDefault{base.get()}; }}; + CHECK(boxed.get().value() == 7); +} + +TEST_CASE("reactive::Computed: a throw reaches the reader, the next read retries", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal divisor{runtime, 0}; + int calls = 0; + Computed quotient{runtime, [&] { + ++calls; + if (divisor.get() == 0) { + throw std::domain_error{"division by zero"}; + } + return 100 / divisor.get(); + }}; + CHECK_THROWS_AS(quotient.get(), std::domain_error); + CHECK_THROWS_AS(quotient.get(), std::domain_error); + CHECK(calls == 2); + CHECK(runtime.core()->tracking() == nullptr); + divisor.set(4); + CHECK(quotient.get() == 25); +} + +TEST_CASE("reactive::Computed: a computed that failed still hears about later changes", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal divisor{runtime, 0}; + Computed quotient{runtime, [&] { + if (divisor.get() == 0) { + throw std::domain_error{"division by zero"}; + } + return 100 / divisor.get(); + }}; + int seen = -1; + Signal ready{runtime, false}; + Effect const watch{runtime, [&] { + if (ready.get()) { + seen = quotient.get(); + } + }}; + CHECK_THROWS_AS(quotient.get(), std::domain_error); + divisor.set(5); + ready.set(true); + owner.runAll(); + CHECK(seen == 20); +} + +TEST_CASE("reactive::Computed: peek does not subscribe", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal base{runtime, 1}; + Computed twice{runtime, [&] { return base.get() * 2; }}; + int runs = 0; + Effect const watch{runtime, [&] { + ++runs; + static_cast(twice.peek()); + }}; + base.set(2); + CHECK(owner.pending() == 0); + CHECK(twice.peek() == 4); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `no template named 'Computed' in namespace 'morph::reactive'`. + +- [ ] **Step 3: Implement `Computed`** + +In `include/morph/reactive/signal.hpp`, add `#include `, `#include ` and +`#include ` to the includes, and insert between `Signal` and `Effect`: + +```cpp +/// @brief A derived value: computed lazily on first read, cached, and recomputed only after a +/// source it read really changed. +/// +/// Equality-gated when `T` is equality-comparable: an unchanged result stops propagation. A +/// computation that throws rethrows to the reader, and the next read retries it. Writing a signal +/// inside the computation, or reading the Computed from inside itself, is refused and reported. +/// @tparam T The value type; move-constructible. It need not be default-constructible. +template +class Computed final : public detail::Node { +public: + /// @tparam F A callable taking no arguments and returning something convertible to `T`. + /// @param runtime The runtime this computed belongs to. + /// @param fn The computation; every signal or computed it reads becomes a dependency. + template + requires std::invocable && std::convertible_to, T> + Computed(Runtime& runtime, F fn) : Node{runtime.core()}, _fn{std::move(fn)} { + forceDirty(); + } + + ~Computed() override = default; + Computed(Computed const&) = delete; + Computed& operator=(Computed const&) = delete; + Computed(Computed&&) = delete; + Computed& operator=(Computed&&) = delete; + + /// @brief Reads the value, bringing it up to date first, and subscribes the innermost tracked run. + /// @return The current value; valid until the next recomputation. + /// @throws Whatever the computation throws; `std::logic_error` when read from inside itself. + [[nodiscard]] T const& get() const { + // Reading is logically const; the cache and the dependency links are bookkeeping. + auto& self = const_cast(*this); // NOLINT(cppcoreguidelines-pro-type-const-cast) + static_cast(core().checkOwner()); + if (_computing) { + core().report(detail::site::kComputedReadsItself); + throw std::logic_error{"morph::reactive: a Computed read itself"}; + } + trackRead(); + if (_error != nullptr) { + self.forceDirty(); + } + self.updateIfNecessary(); + // Always engaged here: a node starts Dirty, and a failed computation rethrows above. + return _value.value(); + } + + /// @brief Reads the value, bringing it up to date first, without subscribing anything. + /// @return The current value; valid until the next recomputation. + /// @throws Whatever the computation throws. + [[nodiscard]] T const& peek() const { + detail::TrackingFrame const untracked{core(), nullptr}; + return get(); + } + +private: + class Computing { + public: + explicit Computing(Computed& self) noexcept : _self{&self} { + self._computing = true; + self.core().enterComputed(); + } + ~Computing() { + _self->_computing = false; + _self->core().leaveComputed(); + } + Computing(Computing const&) = delete; + Computing& operator=(Computing const&) = delete; + Computing(Computing&&) = delete; + Computing& operator=(Computing&&) = delete; + + private: + Computed* _self; + }; + + bool recompute() override { + Computing const computing{*this}; + detail::TrackingFrame frame{core(), this}; + std::optional fresh; + try { + fresh.emplace(std::invoke(_fn)); + } catch (...) { + mergeSources(frame.takeSources()); + _error = std::current_exception(); + throw; + } + adoptSources(frame.takeSources()); + _error = nullptr; + if constexpr (std::equality_comparable) { + if (_value.has_value() && *_value == *fresh) { + return false; + } + } + _value.emplace(std::move(*fresh)); + return true; + } + + std::function _fn; + std::optional _value; + std::exception_ptr _error; + bool _computing = false; +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[reactive]"` +Expected: PASS. + +Mutation check: in `Node::updateIfNecessary`, replace the Check walk's body with nothing (so a Check node +never pulls its sources) and rebuild. Expected: FAIL in "a diamond is seen consistently" or "an unchanged result +stops propagation". Restore. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/reactive/signal.hpp tests/test_reactive_computed.cpp tests/CMakeLists.txt +git commit -m "wip(reactive): Computed, glitch-free and equality-gated + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 3: Misuse is reported, then refused + +The reporting paths were written in Tasks 1–2; this task pins each one with a test that installs the owner +probe, and proves each test can fail. + +**Files:** +- Modify: `tests/CMakeLists.txt` — add `test_reactive_misuse.cpp` after `test_reactive_computed.cpp` +- Test: `tests/test_reactive_misuse.cpp` + +**Interfaces:** +- Consumes: `detail::site::*`, `Runtime`, `Signal`, `Computed`, `Effect`; `morph::testing::OwnerProbeRecorder` + (`tests/owner_probe_recorder.hpp`, constructed with `owner.coreExecutor()`, queried with `count(site)`). +- Produces: nothing new. + +- [ ] **Step 1: Write the tests** + +Create `tests/test_reactive_misuse.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "owner_probe_recorder.hpp" +#include "test_support.hpp" + +namespace { + +using morph::reactive::Computed; +using morph::reactive::Effect; +using morph::reactive::Runtime; +using morph::reactive::RuntimeOptions; +using morph::reactive::Signal; +using Owner = morph::testing::StepExecutor; +using Probe = morph::testing::OwnerProbeRecorder; +namespace site = morph::reactive::detail::site; + +} // namespace + +TEST_CASE("reactive misuse: an exception escaping an Effect stops that flush", "[reactive][misuse]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + Signal value{runtime, 0}; + Effect const thrower{runtime, [&] { + if (value.get() == 1) { + throw std::runtime_error{"boom"}; + } + }}; + int laterRuns = 0; + Effect const later{runtime, [&] { + static_cast(value.get()); + ++laterRuns; + }}; + value.set(1); + REQUIRE(owner.runOne()); + CHECK(probe.count(site::kEffectThrew) == 1); + CHECK(laterRuns == 1); + owner.runAll(); // what was still queued runs in a flush of its own + CHECK(laterRuns == 2); +} + +TEST_CASE("reactive misuse: an Effect that throws on construction is reported", "[reactive][misuse]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + Effect const thrower{runtime, [] { throw std::runtime_error{"boom"}; }}; + CHECK(probe.count(site::kEffectThrew) == 1); +} + +TEST_CASE("reactive misuse: a write cycle is reported and the flush stops", "[reactive][misuse]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner, RuntimeOptions{.maxEffectRunsPerFlush = 10}}; + Signal ping{runtime, 0}; + Signal pong{runtime, 0}; + Effect const forward{runtime, [&] { pong.set(ping.get() + 1); }}; + Effect const backward{runtime, [&] { ping.set(pong.get() + 1); }}; + owner.runAll(); + CHECK(probe.count(site::kWriteCycle) == 1); + CHECK(owner.pending() == 0); +} + +TEST_CASE("reactive misuse: set() inside a Computed is dropped", "[reactive][misuse]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + Signal base{runtime, 0}; + Signal other{runtime, 0}; + Computed sneaky{runtime, [&] { + other.set(7); + return base.get(); + }}; + CHECK(sneaky.get() == 0); + CHECK(other.peek() == 0); + CHECK(probe.count(site::kSetInComputed) == 1); +} + +TEST_CASE("reactive misuse: a Computed reading itself throws logic_error", "[reactive][misuse]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + std::function body; + Computed loop{runtime, [&] { return body(); }}; + body = [&] { return loop.get() + 1; }; + CHECK_THROWS_AS(loop.get(), std::logic_error); + CHECK(probe.count(site::kComputedReadsItself) == 1); +} + +TEST_CASE("reactive misuse: a write from a foreign thread is dropped", "[reactive][misuse]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + Signal value{runtime, 0}; + std::thread{[&] { value.set(1); }}.join(); + CHECK(value.peek() == 0); + CHECK(probe.count(site::kOffOwner) == 1); +} + +TEST_CASE("reactive misuse: a Runtime destroyed before its nodes", "[reactive][misuse]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + auto runtime = std::make_unique(owner); + Signal value{*runtime, 0}; + int runs = 0; + Effect const watch{*runtime, [&] { + static_cast(value.get()); + ++runs; + }}; + value.set(1); + runtime.reset(); + CHECK(probe.count(site::kRuntimeOutlived) == 1); + owner.runAll(); // the posted flush finds the core detached + CHECK(runs == 1); +} + +TEST_CASE("reactive misuse: a flush inside a widget event is re-posted", "[reactive][misuse]") { + Owner owner; + Probe const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + Signal value{runtime, 0}; + int runs = 0; + Effect const watch{runtime, [&] { + static_cast(value.get()); + ++runs; + }}; + value.set(1); + runtime.widgetEvent([&] { REQUIRE(owner.runOne()); }); + CHECK(probe.count(site::kFlushInWidgetEvent) == 1); + CHECK(runs == 1); + owner.runAll(); + CHECK(runs == 2); +} +``` + +- [ ] **Step 2: Run the tests** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[misuse]"` +Expected: PASS — the behaviour landed in Tasks 1–2. + +- [ ] **Step 3: Prove each test can fail** + +Make each change, rebuild, run `"[misuse]"`, confirm the named test fails, then restore: + +| Change | Test that must fail | +|---|---| +| In `RuntimeCore::flush`, replace the `catch (...)` body with `report(site::kEffectThrew);` (no `stopped`/`break`) | "an exception escaping an Effect stops that flush" | +| In `RuntimeCore::flush`, delete the `countRun` block | "a write cycle is reported" — the flush loops forever, so run it as `timeout 30 ./build/reactive/tests/morph_tests "a write cycle*"` and expect a non-zero exit | +| In `Signal::writable`, delete the `isComputing()` block | "set() inside a Computed is dropped" | +| In `Computed::get`, delete the `_computing` block | "a Computed reading itself" (stack overflow) | +| In `Signal::writable`, replace `if (!core().checkOwner())` with `if (false)` | "a write from a foreign thread is dropped" | +| In `~Runtime`, delete `_core->detach();` | "a Runtime destroyed before its nodes" | + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_reactive_misuse.cpp tests/CMakeLists.txt +git commit -m "wip(reactive): pin every reported-then-refused misuse + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 4: `Scope` + +**Files:** +- Create: `include/morph/reactive/scope.hpp` +- Modify: `CMakeLists.txt` — add `include/morph/reactive/scope.hpp` after `include/morph/reactive/signal.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_reactive_scope.cpp` after `test_reactive_misuse.cpp` +- Test: `tests/test_reactive_scope.cpp` + +**Interfaces:** +- Consumes: `Runtime`, `Signal`, `Computed`, `Effect`. +- Produces: `reactive::Scope(Runtime&)`: `make(Args&&...) -> T&`, `adopt(std::unique_ptr) -> T&`, + `effect(F) -> Effect&`, `computed(F) -> Computed&`, `clear()`, `size()`, `runtime()`. Destroys owned + objects in reverse creation order. Part 2 mounts each Switch case and ForEach row into one. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_reactive_scope.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace { + +using morph::reactive::Runtime; +using morph::reactive::Scope; +using morph::reactive::Signal; +using Owner = morph::testing::StepExecutor; + +class Witness { +public: + Witness(std::vector& log, std::string name) : _log{&log}, _name{std::move(name)} {} + ~Witness() { _log->push_back(_name); } + Witness(Witness const&) = delete; + Witness& operator=(Witness const&) = delete; + Witness(Witness&&) = delete; + Witness& operator=(Witness&&) = delete; + +private: + std::vector* _log; + std::string _name; +}; + +} // namespace + +TEST_CASE("reactive::Scope: destroys what it owns in reverse creation order", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + std::vector log; + { + Scope scope{runtime}; + scope.make(log, "first"); + scope.adopt(std::make_unique(log, "second")); + scope.make(log, "third"); + CHECK(scope.size() == 3); + } + CHECK(log == std::vector{"third", "second", "first"}); +} + +TEST_CASE("reactive::Scope: an effect it owns stops when the scope is cleared", "[reactive]") { + Owner owner; + Runtime runtime{owner}; + Signal value{runtime, 0}; + Scope scope{runtime}; + int runs = 0; + scope.effect([&] { + static_cast(value.get()); + ++runs; + }); + auto& doubled = scope.computed([&] { return value.get() * 2; }); + CHECK(doubled.get() == 0); + scope.clear(); + CHECK(scope.size() == 0); + value.set(1); + CHECK(owner.pending() == 0); + CHECK(runs == 1); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `'morph/reactive/scope.hpp' file not found`. + +- [ ] **Step 3: Implement `Scope`** + +Create `include/morph/reactive/scope.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include + +#include "../attributes.hpp" +#include "runtime.hpp" +#include "signal.hpp" + +/// @file +/// @brief `morph::reactive::Scope`: owns reactive nodes and objects, and destroys them in reverse. +/// +/// Specified in `docs/spec/reactive/signals.md`. + +namespace morph::reactive { + +/// @brief Owns effects, computeds and adopted objects; destroys them in reverse creation order. +/// +/// A mounted view, a Switch case and a ForEach row each own one, so tearing one down stops every +/// binding it made before anything those bindings point at goes away. +class Scope { +public: + /// @param runtime The runtime the nodes made here belong to. Borrowed: it must outlive the scope. + explicit Scope(Runtime& runtime MORPH_LIFETIMEBOUND) noexcept : _rt{&runtime} {} + + ~Scope() { clear(); } + Scope(Scope const&) = delete; + Scope& operator=(Scope const&) = delete; + Scope(Scope&&) = delete; + Scope& operator=(Scope&&) = delete; + + /// @brief Constructs a `T` from @p args and owns it. + /// @tparam T The object type. + /// @tparam Args Constructor argument types. + /// @param args Forwarded to `T`'s constructor. + /// @return The new object, alive until the scope is cleared or destroyed. + template + T& make(Args&&... args) { + return adopt(std::make_unique(std::forward(args)...)); + } + + /// @brief Takes ownership of @p owned. + /// @tparam T The object type. + /// @param owned The object; must not be null. + /// @return The object, alive until the scope is cleared or destroyed. + template + T& adopt(std::unique_ptr owned) { + _owned.reserve(_owned.size() + 1); + T& ref = *owned; + _owned.emplace_back(owned.release(), [](void* ptr) noexcept { std::default_delete{}(static_cast(ptr)); }); + return ref; + } + + /// @brief Makes an Effect owned by this scope. + /// @tparam F A callable taking no arguments. + /// @param fn The effect body. + /// @return The Effect. + template + Effect& effect(F&& fn) { + return make(*_rt, std::forward(fn)); + } + + /// @brief Makes a Computed owned by this scope; its type is what @p fn returns. + /// @tparam F A callable taking no arguments. + /// @param fn The computation. + /// @return The Computed. + template + auto& computed(F&& fn) { + using Value = std::remove_cvref_t>; + return make>(*_rt, std::forward(fn)); + } + + /// @brief Destroys everything owned, newest first. + void clear() noexcept { + while (!_owned.empty()) { + _owned.pop_back(); + } + } + + /// @brief How many objects the scope owns. + /// @return The count. + [[nodiscard]] std::size_t size() const noexcept { return _owned.size(); } + + /// @brief The runtime. + /// @return The runtime passed at construction. + [[nodiscard]] Runtime& runtime() const noexcept { return *_rt; } + +private: + Runtime* _rt; + std::vector> _owned; +}; + +} // namespace morph::reactive +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[reactive]"` +Expected: PASS. Mutation check: make `clear()` destroy front-first +(`_owned.erase(_owned.begin())`); expected FAIL in "destroys what it owns in reverse creation order". Restore. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/reactive/scope.hpp tests/test_reactive_scope.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(reactive): Scope owns nodes and destroys them in reverse + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 5: `Store`, `ExhaustiveUpdate` and `request()` + +**Files:** +- Create: `include/morph/reactive/store.hpp` +- Modify: `CMakeLists.txt` — add `include/morph/reactive/store.hpp` after `include/morph/reactive/scope.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_reactive_store.cpp` after `test_reactive_scope.cpp` +- Test: `tests/test_reactive_store.cpp` + +**Interfaces:** +- Consumes: `Runtime` (`batch`, `untracked`, `core()`), `detail::site::kSendInUpdate`; + `morph::bridge::BridgeHandler::execute(Action) -> Completion::Result>` + (`core/bridge.hpp`); `Completion::then(CallbackScope const&, …)` / `onError(CallbackScope const&, …)`; + `morph::model::ActionTraits::Result` (`core/registry.hpp`). +- Produces: + - `reactive::ExhaustiveUpdate` — concept: `Update&` invocable as + `(ViewState&, Alt const&)` for every alternative of the `std::variant` `Msg`. + - `reactive::Store(Runtime&, Init, Update)` — `send(Msg)`, `state() -> ViewState const&`, + `action(Msg) -> std::function`. + - `reactive::request(Store&, BridgeHandler&, CallbackScope const&, Action, ToMsg, ToFailMsg)`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_reactive_store.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "owner_probe_recorder.hpp" +#include "test_support.hpp" + +namespace { + +using morph::reactive::Effect; +using morph::reactive::ExhaustiveUpdate; +using morph::reactive::Runtime; +using morph::reactive::Signal; +using morph::reactive::Store; +using Owner = morph::testing::StepExecutor; + +template +struct Overloaded : Fs... { + using Fs::operator()...; +}; + +struct Counter { + Signal count; + Signal label; + Signal armed; +}; + +struct Inc {}; +struct Rename { + std::string to; +}; +struct Arm {}; +using CounterMsg = std::variant; + +Counter initCounter(Runtime& runtime) { + return Counter{.count{runtime, 0}, .label{runtime, "idle"}, .armed{runtime, false}}; +} + +auto const updateCounter = Overloaded{ + [](Counter& state, Inc const&) { state.count.set(state.count.peek() + 1); }, + [](Counter& state, Rename const& msg) { state.label.set(msg.to); }, + [](Counter& state, Arm const&) { + state.count.set(10); + state.label.set("armed"); + state.armed.set(true); + }, +}; + +auto const partialUpdate = Overloaded{ + [](Counter& state, Inc const&) { state.count.set(state.count.peek() + 1); }, + [](Counter& state, Rename const& msg) { state.label.set(msg.to); }, +}; + +using InitFn = Counter (*)(Runtime&); + +} // namespace + +static_assert(ExhaustiveUpdate); +static_assert(!ExhaustiveUpdate); +static_assert(std::is_constructible_v, Runtime&, InitFn, decltype(updateCounter)>); +static_assert(!std::is_constructible_v, Runtime&, InitFn, decltype(partialUpdate)>); + +TEST_CASE("reactive::Store: send applies the update for that alternative", "[reactive][store]") { + Owner owner; + Runtime runtime{owner}; + Store store{runtime, initCounter, updateCounter}; + store.send(Inc{}); + store.send(Rename{"two"}); + CHECK(store.state().count.peek() == 1); + CHECK(store.state().label.peek() == "two"); +} + +TEST_CASE("reactive::Store: a three-field update is one flush", "[reactive][store]") { + Owner owner; + Runtime runtime{owner}; + Store store{runtime, initCounter, updateCounter}; + int runs = 0; + Effect const watch{runtime, [&] { + static_cast(store.state().count.get()); + static_cast(store.state().label.get()); + static_cast(store.state().armed.get()); + ++runs; + }}; + store.send(Arm{}); + CHECK(owner.pending() == 1); + owner.runAll(); + CHECK(runs == 2); +} + +TEST_CASE("reactive::Store: reading one field tracks exactly that field", "[reactive][store]") { + Owner owner; + Runtime runtime{owner}; + Store store{runtime, initCounter, updateCounter}; + Effect const watch{runtime, [&] { static_cast(store.state().label.get()); }}; + store.send(Inc{}); + CHECK(owner.pending() == 0); +} + +TEST_CASE("reactive::Store: action() sends its message each time it is called", "[reactive][store]") { + Owner owner; + Runtime runtime{owner}; + Store store{runtime, initCounter, updateCounter}; + auto const inc = store.action(Inc{}); + inc(); + inc(); + CHECK(store.state().count.peek() == 2); +} + +TEST_CASE("reactive::Store: send() inside an update is dropped and reported", "[reactive][store][misuse]") { + Owner owner; + morph::testing::OwnerProbeRecorder const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + Store* self = nullptr; + auto const reentrant = Overloaded{ + [&](Counter&, Inc const&) { self->send(Arm{}); }, + [](Counter&, Rename const&) {}, + [](Counter& state, Arm const&) { state.armed.set(true); }, + }; + Store store{runtime, initCounter, reentrant}; + self = &store; + store.send(Inc{}); + CHECK_FALSE(store.state().armed.peek()); + CHECK(probe.count(morph::reactive::detail::site::kSendInUpdate) == 1); + store.send(Arm{}); // the guard was released + CHECK(store.state().armed.peek()); +} + +// ── request() over a real LocalBackend ───────────────────────────────────── + +struct StorePingAction { + int value = 0; +}; +struct StorePingFail {}; + +struct StorePingModel { + int execute(StorePingAction action) { return action.value * 2; } + int execute(StorePingFail) { throw std::runtime_error("ping failed"); } +}; + +BRIDGE_REGISTER_MODEL(StorePingModel, "Test_StorePingModel") +BRIDGE_REGISTER_ACTION(StorePingModel, StorePingAction, "Test_StorePingAction") +BRIDGE_REGISTER_ACTION(StorePingModel, StorePingFail, "Test_StorePingFail") + +namespace { + +struct Reply { + Signal value; + Signal deliveries; + Signal error; +}; + +struct Doubled { + int value; +}; +struct Failed { + std::exception_ptr error; +}; +using ReplyMsg = std::variant; + +Reply initReply(Runtime& runtime) { + return Reply{.value{runtime, 0}, .deliveries{runtime, 0}, .error{runtime, ""}}; +} + +std::string describe(std::exception_ptr const& error) { + try { + std::rethrow_exception(error); + } catch (std::exception const& exception) { + return exception.what(); + } catch (...) { + return "unknown"; + } +} + +auto const updateReply = Overloaded{ + [](Reply& state, Doubled const& msg) { + state.value.set(msg.value); + state.deliveries.set(state.deliveries.peek() + 1); + }, + [](Reply& state, Failed const& msg) { state.error.set(describe(msg.error)); }, +}; + +auto const toDoubled = [](int value) { return Doubled{value}; }; +auto const toFailed = [](std::exception_ptr error) { return Failed{std::move(error)}; }; + +template +bool pumpUntil(morph::exec::MainThreadExecutor& owner, Pred const& done) { + for (int step = 0; step < 400 && !done(); ++step) { + owner.runFor(std::chrono::milliseconds{5}); + } + return done(); +} + +struct Wiring { + morph::exec::ThreadPoolExecutor pool{1}; + morph::exec::MainThreadExecutor owner; + morph::bridge::Bridge bridge{std::make_unique(pool), owner}; + morph::bridge::BridgeHandler handler{bridge, &owner}; +}; + +} // namespace + +TEST_CASE("reactive::request: a result arrives as its Msg", "[reactive][store]") { + Wiring wiring; + Runtime runtime{wiring.owner}; + Store store{runtime, initReply, updateReply}; + morph::async::CallbackScope const scope; + morph::reactive::request(store, wiring.handler, scope, StorePingAction{21}, toDoubled, toFailed); + REQUIRE(pumpUntil(wiring.owner, [&] { return store.state().value.peek() == 42; })); +} + +TEST_CASE("reactive::request: a failure arrives as a Msg carrying the exception", "[reactive][store]") { + Wiring wiring; + Runtime runtime{wiring.owner}; + Store store{runtime, initReply, updateReply}; + morph::async::CallbackScope const scope; + morph::reactive::request(store, wiring.handler, scope, StorePingFail{}, toDoubled, toFailed); + REQUIRE(pumpUntil(wiring.owner, [&] { return store.state().error.peek() == "ping failed"; })); +} + +TEST_CASE("reactive::request: a destroyed CallbackScope gates the reply", "[reactive][store]") { + Wiring wiring; + Runtime runtime{wiring.owner}; + Store store{runtime, initReply, updateReply}; + auto gone = std::make_unique(); + morph::async::CallbackScope const live; + morph::reactive::request(store, wiring.handler, *gone, StorePingAction{21}, toDoubled, toFailed); + gone.reset(); + // Same model instance, so the second reply is delivered after the first would have been. + morph::reactive::request(store, wiring.handler, live, StorePingAction{5}, toDoubled, toFailed); + REQUIRE(pumpUntil(wiring.owner, [&] { return store.state().value.peek() == 10; })); + CHECK(store.state().deliveries.peek() == 1); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `'morph/reactive/store.hpp' file not found`. + +- [ ] **Step 3: Implement the Store** + +Create `include/morph/reactive/store.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include + +#include "../attributes.hpp" +#include "../core/bridge.hpp" +#include "../core/callback_scope.hpp" +#include "../core/registry.hpp" +#include "runtime.hpp" + +/// @file +/// @brief `morph::reactive::Store`: user-intent view state, changed only by exhaustive messages. +/// +/// Specified in `docs/spec/reactive/store.md`. + +namespace morph::reactive { + +namespace detail { + +/// @brief Whether @p Update handles every alternative of the variant @p Msg. +/// @tparam Update The update callable. +/// @tparam ViewState The state type. +/// @tparam Msg Anything but a `std::variant`: never exhaustive. +template +struct UpdateCoversAll : std::false_type {}; + +/// @brief Whether @p Update handles every alternative of `std::variant`. +/// @tparam Update The update callable. +/// @tparam ViewState The state type. +/// @tparam Alts The message alternatives. +template +struct UpdateCoversAll> + : std::bool_constant<(std::invocable && ...)> {}; + +/// @brief Marks a Store as inside its update for the guard's lifetime. +class UpdateGuard { +public: + /// @param flag The Store's in-update flag; set now, cleared on destruction. + explicit UpdateGuard(bool& flag) noexcept : _flag{&flag} { flag = true; } + ~UpdateGuard() { *_flag = false; } + UpdateGuard(UpdateGuard const&) = delete; + UpdateGuard& operator=(UpdateGuard const&) = delete; + UpdateGuard(UpdateGuard&&) = delete; + UpdateGuard& operator=(UpdateGuard&&) = delete; + +private: + bool* _flag; +}; + +} // namespace detail + +/// @brief An update that handles every message: invocable as `(ViewState&, Alt const&)` for each +/// alternative `Alt` of the `std::variant` @p Msg. +/// @tparam Update The update callable. +/// @tparam ViewState The state type. +/// @tparam Msg The message variant. +template +concept ExhaustiveUpdate = detail::UpdateCoversAll::value; + +/// @brief User-intent view state — what is selected, typed, open — changed only by messages. +/// +/// `ViewState` is a struct of `Signal` fields, built in place by `init` (signals cannot move). +/// Every `send()` runs the update in one batch, untracked, so N field writes are one flush. +/// The view reads `state()`; only the update writes. +/// @tparam ViewState A struct of `Signal` fields. +/// @tparam Msg A `std::variant` of message types. +template +class Store { +public: + /// @tparam Init Callable `(Runtime&) -> ViewState`, returning by prvalue. + /// @tparam Update Callable satisfying `ExhaustiveUpdate`. + /// @param runtime The runtime. Borrowed: it must outlive the Store. + /// @param init Builds the initial state. + /// @param update Applies one message to the state. + template + requires std::invocable && + std::same_as, ViewState> && + ExhaustiveUpdate + Store(Runtime& runtime MORPH_LIFETIMEBOUND, Init init, Update update) + : _rt{&runtime}, + _state{init(runtime)}, + _update{[upd = std::move(update)](ViewState& state, Msg const& msg) mutable { + std::visit([&](auto const& alternative) { upd(state, alternative); }, msg); + }} {} + + ~Store() = default; + Store(Store const&) = delete; + Store& operator=(Store const&) = delete; + Store(Store&&) = delete; + Store& operator=(Store&&) = delete; + + /// @brief Applies @p msg in one batch. Refused off the owner, and inside an update. + /// @param msg The message. + void send(Msg msg) { + auto& core = *_rt->core(); + if (!core.checkOwner()) { + return; + } + if (_inUpdate) { + core.report(detail::site::kSendInUpdate); + return; + } + _rt->batch([&] { + _rt->untracked([&] { + detail::UpdateGuard const guard{_inUpdate}; + _update(_state, msg); + }); + }); + } + + /// @brief The state, for reading. A field's `get()` tracks exactly that field. + /// @return The state. + [[nodiscard]] ViewState const& state() const noexcept { return _state; } + + /// @brief A callable that sends @p msg each time it is called — what a button binds to. + /// @param msg The message, copied into the callable. + /// @return The callable; it refers to this Store, which must outlive it. + [[nodiscard]] std::function action(Msg msg) { + return [this, msg = std::move(msg)] { send(msg); }; + } + +private: + Runtime* _rt; + ViewState _state; + std::function _update; + bool _inUpdate = false; +}; + +/// @brief Executes @p action and sends its result, or its failure, to @p store as one message. +/// +/// The low-level primitive `Query` and `Mutation` are built on. Callbacks land on the handler's +/// GUI executor, which must be the Store's owner; each delivery is one batch. @p scope gates both. +/// @tparam ViewState The Store's state type. +/// @tparam Msg The Store's message variant. +/// @tparam Model The handler's model type. +/// @tparam Sharing The handler's sharing policy. +/// @tparam Action The action type. +/// @tparam ToMsg Callable `(Result const&) -> alternative of Msg`. +/// @tparam ToFailMsg Callable `(std::exception_ptr) -> alternative of Msg`. +/// @param store Receives the message. Must outlive every delivery @p scope does not gate. +/// @param handler Executes the action. +/// @param scope Gate: a reply arriving after it is stopped or destroyed is dropped. +/// @param action The action. +/// @param toMsg Maps the result to a message. +/// @param toFailMsg Maps the failure to a message. +template +void request(Store& store, bridge::BridgeHandler& handler, + async::CallbackScope const& scope, Action action, ToMsg toMsg, ToFailMsg toFailMsg) { + using Result = typename model::ActionTraits::Result; + handler.execute(std::move(action)) + .then(scope, [&store, toMsg = std::move(toMsg)](Result const& result) { store.send(Msg{toMsg(result)}); }) + .onError(scope, [&store, toFailMsg = std::move(toFailMsg)](std::exception_ptr error) { + store.send(Msg{toFailMsg(std::move(error))}); + }); +} + +} // namespace morph::reactive +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[store]"` +Expected: PASS. Mutation check: in `request()`, drop the `scope` argument from `.then(…)`; expected FAIL in +"a destroyed CallbackScope gates the reply" (`deliveries == 2`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/reactive/store.hpp tests/test_reactive_store.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(reactive): Store with exhaustive updates, and request() + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 6: `Query` — a derived async resource + +**Files:** +- Create: `include/morph/reactive/control.hpp` +- Modify: `CMakeLists.txt` — add `include/morph/reactive/control.hpp` after `include/morph/reactive/store.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_reactive_query.cpp` after `test_reactive_store.cpp` +- Test: `tests/test_reactive_query.cpp` + +**Interfaces:** +- Consumes: `Runtime`, `Signal`, `Effect`, `detail::RuntimeCore::checkOwner()`; + `async::Completion::makeSettleable(IExecutor*) -> {Completion, Completion::Promise}` (tests); + `async::CallbackScope::reset()` (the supersede verb); `BridgeHandler::execute`, `BridgeHandler::subscribe( + CallbackScope const&, std::function)`. +- Produces (Task 7 and Parts 2–4 rely on these): + - `reactive::errorMessage(std::exception_ptr const&) -> std::string` — `what()`, `"unknown error"` for a + non-`std::exception`, empty for null. + - `reactive::Refetchable` — `virtual void refetch() = 0`. + - `reactive::Query::Result>`: + `Query(Runtime&, std::function(A const&)> fetch, std::function()> key)`, + `Query(Runtime&, bridge::BridgeHandler&, key)`, `pending() -> bool`, + `value() -> std::optional const&`, `error() -> std::exception_ptr`, `refetch()`, + `refreshOn(bridge::BridgeHandler&)`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_reactive_query.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "owner_probe_recorder.hpp" +#include "test_support.hpp" + +namespace { + +using morph::async::Completion; +using morph::reactive::Effect; +using morph::reactive::Query; +using morph::reactive::Runtime; +using morph::reactive::Signal; +using Owner = morph::testing::StepExecutor; +namespace site = morph::reactive::detail::site; + +struct Lookup { + int id = 0; + bool operator==(Lookup const&) const = default; +}; + +// No operator==: every re-run of the key re-issues. +struct OpaqueLookup { + int id = 0; +}; + +// Stands in for a server: every fetch is held until the test settles it, in any order. +class FakeServer { +public: + explicit FakeServer(morph::exec::IExecutor& owner) : _owner{&owner} {} + + template + Completion fetch(A const& action) { + auto [completion, promise] = Completion::makeSettleable(_owner); + _calls.push_back(Call{.id = action.id, .promise = std::move(promise)}); + return std::move(completion); + } + + template + auto via() { + return [this](A const& action) { return fetch(action); }; + } + + void resolve(std::size_t index, std::string value) { _calls.at(index).promise.resolve(std::move(value)); } + + void reject(std::size_t index, std::string const& what) { + _calls.at(index).promise.reject(std::make_exception_ptr(std::runtime_error{what})); + } + + [[nodiscard]] std::size_t calls() const { return _calls.size(); } + [[nodiscard]] int idOf(std::size_t index) const { return _calls.at(index).id; } + +private: + struct Call { + int id; + Completion::Promise promise; + }; + morph::exec::IExecutor* _owner; + std::vector _calls; +}; + +using Detail = Query; + +} // namespace + +TEST_CASE("reactive::Query: issues its key on construction and delivers the value", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + FakeServer server{owner}; + Detail detail{runtime, server.via(), [] { return std::optional{Lookup{1}}; }}; + REQUIRE(server.calls() == 1); + CHECK(detail.pending()); + server.resolve(0, "one"); + owner.runAll(); + CHECK_FALSE(detail.pending()); + CHECK(detail.value() == std::optional{"one"}); + CHECK(detail.error() == nullptr); +} + +TEST_CASE("reactive::Query: a key change mid-flight drops the stale reply", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + FakeServer server{owner}; + Signal selected{runtime, 1}; + Detail detail{runtime, server.via(), [&] { return std::optional{Lookup{selected.get()}}; }}; + selected.set(2); + owner.runAll(); + REQUIRE(server.calls() == 2); + CHECK(server.idOf(1) == 2); + + server.resolve(1, "two"); + owner.runAll(); + server.resolve(0, "one"); // A lands after B + owner.runAll(); + CHECK(detail.value() == std::optional{"two"}); + CHECK_FALSE(detail.pending()); +} + +TEST_CASE("reactive::Query: a superseded failure leaves error() clear", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + FakeServer server{owner}; + Signal selected{runtime, 1}; + Detail detail{runtime, server.via(), [&] { return std::optional{Lookup{selected.get()}}; }}; + selected.set(2); + owner.runAll(); + server.resolve(1, "two"); + server.reject(0, "late failure"); + owner.runAll(); + CHECK(detail.error() == nullptr); + CHECK(detail.value() == std::optional{"two"}); +} + +TEST_CASE("reactive::Query: a nullopt key is idle", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + FakeServer server{owner}; + Signal> selected{runtime, std::nullopt}; + Detail detail{runtime, server.via(), [&]() -> std::optional { + if (auto const id = selected.get()) { + return Lookup{*id}; + } + return std::nullopt; + }}; + CHECK(server.calls() == 0); + CHECK_FALSE(detail.pending()); + CHECK_FALSE(detail.value().has_value()); + + selected.set(1); + owner.runAll(); + server.resolve(0, "one"); + owner.runAll(); + CHECK(detail.value() == std::optional{"one"}); + + selected.set(std::nullopt); + owner.runAll(); + CHECK_FALSE(detail.value().has_value()); + CHECK_FALSE(detail.pending()); + CHECK(server.calls() == 1); + detail.refetch(); // idle stays idle + CHECK(server.calls() == 1); +} + +TEST_CASE("reactive::Query: error, then success clears it; value survives a refetch", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + FakeServer server{owner}; + Detail detail{runtime, server.via(), [] { return std::optional{Lookup{1}}; }}; + server.reject(0, "down"); + owner.runAll(); + CHECK(morph::reactive::errorMessage(detail.error()) == "down"); + CHECK_FALSE(detail.pending()); + + detail.refetch(); + CHECK(server.calls() == 2); + CHECK(detail.pending()); + server.resolve(1, "one"); + owner.runAll(); + CHECK(detail.error() == nullptr); + + detail.refetch(); + CHECK(detail.pending()); + CHECK(detail.value() == std::optional{"one"}); +} + +TEST_CASE("reactive::Query: an unchanged comparable key issues nothing", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + FakeServer server{owner}; + Signal unrelated{runtime, 0}; + Detail detail{runtime, server.via(), [&] { + static_cast(unrelated.get()); + return std::optional{Lookup{1}}; + }}; + unrelated.set(1); + owner.runAll(); + CHECK(server.calls() == 1); +} + +TEST_CASE("reactive::Query: a non-comparable key re-issues on every re-run", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + FakeServer server{owner}; + Signal unrelated{runtime, 0}; + Query detail{runtime, server.via(), [&] { + static_cast(unrelated.get()); + return std::optional{OpaqueLookup{1}}; + }}; + unrelated.set(1); + owner.runAll(); + CHECK(server.calls() == 2); +} + +TEST_CASE("reactive::Query: a destroyed Query gates late replies", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + FakeServer server{owner}; + auto detail = std::make_unique(runtime, server.via(), [] { return std::optional{Lookup{1}}; }); + detail.reset(); + server.resolve(0, "late"); + owner.runAll(); // a delivery into the destroyed Query would be a use-after-free under ASan + CHECK(server.calls() == 1); +} + +TEST_CASE("reactive::Query: an Effect reading it re-runs on each state change", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + FakeServer server{owner}; + Detail detail{runtime, server.via(), [] { return std::optional{Lookup{1}}; }}; + std::vector frames; + Effect const render{runtime, [&] { + frames.push_back(detail.pending() ? "loading" + : detail.value().value_or("empty")); + }}; + server.resolve(0, "one"); + owner.runAll(); + CHECK(frames == std::vector{"loading", "one"}); +} + +// Review Focus 4. +TEST_CASE("reactive::Query: a throwing key is reported and leaves the query as it was", + "[reactive][control][misuse]") { + Owner owner; + morph::testing::OwnerProbeRecorder const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + FakeServer server{owner}; + Signal selected{runtime, 1}; + Detail detail{runtime, server.via(), [&] { + if (selected.get() == 2) { + throw std::invalid_argument{"no such id"}; + } + return std::optional{Lookup{selected.get()}}; + }}; + server.resolve(0, "one"); + owner.runAll(); + selected.set(2); + owner.runAll(); + CHECK(probe.count(site::kEffectThrew) == 1); + CHECK(server.calls() == 1); + CHECK(detail.value() == std::optional{"one"}); +} + +TEST_CASE("reactive::Query: a delivery off the owner is reported and dropped", "[reactive][control][misuse]") { + Owner owner; + morph::testing::OwnerProbeRecorder const probe{owner.coreExecutor()}; + Runtime runtime{owner}; + morph::exec::MainThreadExecutor foreign; + FakeServer server{foreign}; + Detail detail{runtime, server.via(), [] { return std::optional{Lookup{1}}; }}; + server.resolve(0, "one"); + std::thread{[&] { foreign.runFor(std::chrono::milliseconds{50}); }}.join(); + CHECK(probe.count(site::kOffOwner) >= 1); + CHECK_FALSE(detail.value().has_value()); +} + +TEST_CASE("reactive::errorMessage: what(), a foreign throw, and null", "[reactive][control]") { + using morph::reactive::errorMessage; + CHECK(errorMessage(std::make_exception_ptr(std::runtime_error{"boom"})) == "boom"); + CHECK(errorMessage(std::make_exception_ptr(42)) == "unknown error"); + CHECK(errorMessage(nullptr).empty()); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `'morph/reactive/control.hpp' file not found`. + +- [ ] **Step 3: Implement `errorMessage`, `Refetchable` and `Query`** + +Create `include/morph/reactive/control.hpp` (Task 7 appends `Mutation` and `Subscription`): + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../attributes.hpp" +#include "../core/bridge.hpp" +#include "../core/callback_scope.hpp" +#include "../core/completion.hpp" +#include "../core/registry.hpp" +#include "runtime.hpp" +#include "signal.hpp" + +/// @file +/// @brief Declarative control: `Query`, `Mutation` and `Subscription` turn server interaction +/// into reactive state, so a controller states what it depends on and what a write +/// invalidates instead of sequencing calls by hand. +/// +/// Specified in `docs/spec/reactive/control.md`. + +namespace morph::reactive { + +/// @brief The one place an `exception_ptr` becomes display text. +/// @param error The captured exception, or null. +/// @return `what()` for a `std::exception`, `"unknown error"` for anything else, empty for null. +[[nodiscard]] inline std::string errorMessage(std::exception_ptr const& error) { + if (error == nullptr) { + return {}; + } + try { + std::rethrow_exception(error); + } catch (std::exception const& exception) { + return exception.what(); + } catch (...) { + return "unknown error"; + } +} + +/// @brief Something a `Mutation` can tell to fetch again after it succeeds. +class Refetchable { +public: + Refetchable() = default; + virtual ~Refetchable() = default; + Refetchable(Refetchable const&) = delete; + Refetchable& operator=(Refetchable const&) = delete; + Refetchable(Refetchable&&) = delete; + Refetchable& operator=(Refetchable&&) = delete; + + /// @brief Re-issues the current request; does nothing while idle. + virtual void refetch() = 0; +}; + +/// @brief A derived async resource: the result of fetching whatever its tracked key names. +/// +/// An internal Effect reads `key`. When the key changes — equality-gated when `A` is +/// equality-comparable, otherwise on every re-run — the query fetches it under a fresh generation +/// of its `CallbackScope`, so a reply for a superseded key is dropped (**latest wins**) and a call +/// that carries a stop source is asked to stop. `nullopt` means idle: nothing in flight, value and +/// error cleared. `value()` is kept while a refetch is in flight. +/// @tparam A The action (request) type. +/// @tparam R The result type; defaults to the action's registered result. +template ::Result> +class Query final : public Refetchable { +public: + /// @brief The tracked key: which request the query currently stands for, or `nullopt` for none. + using Key = std::function()>; + /// @brief Issues one request. + using Fetch = std::function(A const&)>; + + /// @brief Builds a query over any fetcher — the test seam, and the way to query a non-bridge source. + /// @param runtime The runtime. Borrowed: it must outlive the query. + /// @param fetch Issues a request; its `Completion` must deliver on the runtime's owner. + /// @param key The tracked key. + Query(Runtime& runtime MORPH_LIFETIMEBOUND, Fetch fetch, Key key) + : _rt{&runtime}, + _fetch{std::move(fetch)}, + _key{std::move(key)}, + _pending{runtime, false}, + _value{runtime, std::nullopt}, + _error{runtime, nullptr} { + _effect = std::make_unique(runtime, [this] { + std::optional next = _key(); + _rt->untracked([&] { onKey(std::move(next)); }); + }); + } + + /// @brief Builds a query that fetches through a bridge handler. + /// @tparam M The handler's model type. + /// @tparam S The handler's sharing policy. + /// @param runtime The runtime. Borrowed: it must outlive the query. + /// @param handler Executes the requests; its GUI executor must be the runtime's owner. Borrowed. + /// @param key The tracked key. + template + Query(Runtime& runtime MORPH_LIFETIMEBOUND, bridge::BridgeHandler& handler MORPH_LIFETIMEBOUND, Key key) + : Query(runtime, [&handler](A const& action) { return handler.execute(action); }, std::move(key)) {} + + ~Query() override = default; + Query(Query const&) = delete; + Query& operator=(Query const&) = delete; + Query(Query&&) = delete; + Query& operator=(Query&&) = delete; + + /// @brief Whether a request for the current key is in flight. Tracked. + /// @return True from issue to delivery. + [[nodiscard]] bool pending() const { return _pending.get(); } + + /// @brief The last successful result. Tracked. + /// @return The result, kept while a refetch is in flight; `nullopt` before the first or while idle. + /// Valid until the next delivery. + [[nodiscard]] std::optional const& value() const { return _value.get(); } + + /// @brief The last failure for the current key. Tracked. + /// @return The exception, or null; cleared by the next success. + [[nodiscard]] std::exception_ptr error() const { return _error.get(); } + + /// @brief Re-issues the current key under a new generation; idle stays idle. + void refetch() override { + if (!_rt->core()->checkOwner()) { + return; + } + if (_current.has_value()) { + issue(); + } + } + + /// @brief Re-fetches whenever the bridge publishes a @p Pub on @p handler's instance. + /// + /// Uses the handler's one subscription slot for `Pub`: two consumers of the same `Pub` use two + /// handlers. + /// @tparam Pub The published result type to listen for. + /// @tparam M The handler's model type. + /// @tparam S The handler's sharing policy. + /// @param handler The handler whose instance publishes. Borrowed: it must outlive the query. + template + void refreshOn(bridge::BridgeHandler& handler) { + handler.template subscribe(_lifetime, [this](Pub const&) { refetch(); }); + } + +private: + void onKey(std::optional next) { + if constexpr (std::equality_comparable) { + if (_keyed && next == _current) { + return; + } + } + _keyed = true; + _current = std::move(next); + issue(); + } + + void issue() { + _inflight.reset(); + if (!_current.has_value()) { + _rt->batch([&] { + _pending.set(false); + _value.set(std::nullopt); + _error.set(nullptr); + }); + return; + } + _pending.set(true); + async::Completion completion = _fetch(*_current); + completion + .then(_inflight, + [this](R const& result) { + if (!_rt->core()->checkOwner()) { + return; + } + _rt->batch([&] { + _value.set(result); + _error.set(nullptr); + _pending.set(false); + }); + }) + .onError(_inflight, [this](std::exception_ptr error) { + if (!_rt->core()->checkOwner()) { + return; + } + _rt->batch([&] { + _error.set(std::move(error)); + _pending.set(false); + }); + }); + } + + Runtime* _rt; + Fetch _fetch; + Key _key; + Signal _pending; + Signal> _value; + Signal _error; + std::optional _current; + bool _keyed = false; + std::unique_ptr _effect; + // Gates the subscription `refreshOn` installs. + async::CallbackScope _lifetime; + // Last member, so it is destroyed first: a reply still in flight finds it stopped. + async::CallbackScope _inflight; +}; + +} // namespace morph::reactive +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[control]"` +Expected: PASS. + +Mutation check: in `Query::issue`, delete `_inflight.reset();`. Expected FAIL in "a key change mid-flight drops the +stale reply" (`value() == "one"`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/reactive/control.hpp tests/test_reactive_query.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(reactive): Query, a latest-wins async resource keyed on tracked state + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 7: `Mutation` — a command that invalidates queries + +**Files:** +- Modify: `include/morph/reactive/control.hpp` — append `MutationOptions` and `Mutation` after `Query` +- Modify: `tests/CMakeLists.txt` — add `test_reactive_mutation.cpp` after `test_reactive_query.cpp` +- Test: `tests/test_reactive_mutation.cpp` + +**Interfaces:** +- Consumes: `Query`, `Refetchable`, `Runtime`, `Signal`, `async::CallbackScope`, `async::Completion`. +- Produces: + - `reactive::MutationOptions { std::vector invalidates; }` — the listed queries must be declared + before the mutation, so they outlive it. + - `reactive::Mutation::Result>`: + `Mutation(Runtime&, std::function(A)> run, MutationOptions = {})`, + `Mutation(Runtime&, bridge::BridgeHandler&, MutationOptions = {})`, `run(A)`, `pending() -> bool`, + `error() -> std::exception_ptr`, `lastResult() -> std::optional const&`, + `action(std::function()> make) -> std::function`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_reactive_mutation.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace { + +using morph::async::Completion; +using morph::reactive::Effect; +using morph::reactive::Mutation; +using morph::reactive::MutationOptions; +using morph::reactive::Query; +using morph::reactive::Runtime; +using morph::reactive::RuntimeOptions; +using morph::reactive::Signal; +using Owner = morph::testing::StepExecutor; + +struct Lookup { + int id = 0; + bool operator==(Lookup const&) const = default; +}; + +struct Save { + int id = 0; +}; + +// Holds every call until the test settles it. +template +class Held { +public: + explicit Held(morph::exec::IExecutor& owner) : _owner{&owner} {} + + Completion call() { + auto [completion, promise] = Completion::makeSettleable(_owner); + _promises.push_back(std::move(promise)); + return std::move(completion); + } + + void resolve(std::size_t index, Result value) { _promises.at(index).resolve(std::move(value)); } + + void reject(std::size_t index, std::string const& what) { + _promises.at(index).reject(std::make_exception_ptr(std::runtime_error{what})); + } + + [[nodiscard]] std::size_t calls() const { return _promises.size(); } + +private: + morph::exec::IExecutor* _owner; + std::vector::Promise> _promises; +}; + +} // namespace + +TEST_CASE("reactive::Mutation: pending() counts overlapping calls", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + Held server{owner}; + Mutation save{runtime, [&](Save) { return server.call(); }}; + CHECK_FALSE(save.pending()); + save.run(Save{1}); + save.run(Save{2}); + CHECK(save.pending()); + server.resolve(0, 10); + owner.runAll(); + CHECK(save.pending()); + server.resolve(1, 20); + owner.runAll(); + CHECK_FALSE(save.pending()); + CHECK(save.lastResult() == std::optional{20}); +} + +TEST_CASE("reactive::Mutation: success invalidates every listed query in one flush", "[reactive][control]") { + Owner owner; + int frames = 0; + Runtime runtime{owner, RuntimeOptions{.afterFlush = [&] { ++frames; }}}; + Held listServer{owner}; + Held detailServer{owner}; + Held saveServer{owner}; + Query list{runtime, [&](Lookup const&) { return listServer.call(); }, + [] { return std::optional{Lookup{0}}; }}; + Query detail{runtime, [&](Lookup const&) { return detailServer.call(); }, + [] { return std::optional{Lookup{1}}; }}; + Mutation save{runtime, [&](Save) { return saveServer.call(); }, + MutationOptions{.invalidates = {&list, &detail}}}; + Effect const render{runtime, [&] { + static_cast(list.pending()); + static_cast(detail.pending()); + static_cast(save.pending()); + }}; + listServer.resolve(0, "list"); + detailServer.resolve(0, "detail"); + owner.runAll(); + + save.run(Save{1}); + owner.runAll(); + int const before = frames; + saveServer.resolve(0, 1); + owner.runAll(); + CHECK(listServer.calls() == 2); + CHECK(detailServer.calls() == 2); + CHECK(frames == before + 1); + CHECK(list.pending()); + CHECK(list.value() == std::optional{"list"}); +} + +TEST_CASE("reactive::Mutation: a failure sets error() and invalidates nothing", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + Held listServer{owner}; + Held saveServer{owner}; + Query list{runtime, [&](Lookup const&) { return listServer.call(); }, + [] { return std::optional{Lookup{0}}; }}; + Mutation save{runtime, [&](Save) { return saveServer.call(); }, MutationOptions{.invalidates = {&list}}}; + save.run(Save{1}); + saveServer.reject(0, "conflict"); + owner.runAll(); + CHECK(morph::reactive::errorMessage(save.error()) == "conflict"); + CHECK_FALSE(save.pending()); + CHECK(listServer.calls() == 1); + + save.run(Save{2}); + saveServer.resolve(1, 2); + owner.runAll(); + CHECK(save.error() == nullptr); +} + +TEST_CASE("reactive::Mutation: action(make) issues only what make returns", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + Held server{owner}; + Signal amount{runtime, ""}; + Mutation save{runtime, [&](Save) { return server.call(); }}; + auto const submit = save.action([&]() -> std::optional { + if (amount.peek().empty()) { + return std::nullopt; + } + return Save{static_cast(amount.peek().size())}; + }); + submit(); + CHECK(server.calls() == 0); + amount.set("12"); + submit(); + CHECK(server.calls() == 1); +} + +// Review Focus 5. +TEST_CASE("reactive::Mutation: invalidating an idle query issues nothing", "[reactive][control]") { + Owner owner; + Runtime runtime{owner}; + Held listServer{owner}; + Held saveServer{owner}; + Query idle{runtime, [&](Lookup const&) { return listServer.call(); }, + [] { return std::optional{}; }}; + Mutation save{runtime, [&](Save) { return saveServer.call(); }, MutationOptions{.invalidates = {&idle}}}; + save.run(Save{1}); + saveServer.resolve(0, 1); + owner.runAll(); + CHECK(listServer.calls() == 0); + CHECK_FALSE(idle.pending()); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `no template named 'Mutation' in namespace 'morph::reactive'`. + +- [ ] **Step 3: Implement `Mutation`** + +In `include/morph/reactive/control.hpp`, insert before the closing `} // namespace morph::reactive`: + +```cpp +/// @brief What a `Mutation` does after it succeeds. +struct MutationOptions { + /// @brief Queries to refetch, in the same batch as the result, after every success. Declare them + /// before the mutation, so they outlive it. + std::vector invalidates; +}; + +/// @brief A command: issues a write and tracks it, then refetches what the write invalidates. +/// +/// `pending()` counts the calls in flight. A success records `lastResult()`, clears `error()` and +/// refetches every query in `MutationOptions::invalidates` — one batch, so one flush and one +/// frame. A failure records `error()` and invalidates nothing. +/// @tparam A The action type. +/// @tparam R The result type; defaults to the action's registered result. +template ::Result> +class Mutation final { +public: + /// @brief Issues one call. + using Run = std::function(A)>; + + /// @brief Builds a mutation over any runner — the test seam. + /// @param runtime The runtime. Borrowed: it must outlive the mutation. + /// @param run Issues a call; its `Completion` must deliver on the runtime's owner. + /// @param options The queries a success invalidates. + Mutation(Runtime& runtime MORPH_LIFETIMEBOUND, Run run, MutationOptions options = {}) + : _rt{&runtime}, + _run{std::move(run)}, + _options{std::move(options)}, + _inFlight{runtime, 0}, + _error{runtime, nullptr}, + _last{runtime, std::nullopt} {} + + /// @brief Builds a mutation that executes through a bridge handler. + /// @tparam M The handler's model type. + /// @tparam S The handler's sharing policy. + /// @param runtime The runtime. Borrowed: it must outlive the mutation. + /// @param handler Executes the calls; its GUI executor must be the runtime's owner. Borrowed. + /// @param options The queries a success invalidates. + template + Mutation(Runtime& runtime MORPH_LIFETIMEBOUND, bridge::BridgeHandler& handler MORPH_LIFETIMEBOUND, + MutationOptions options = {}) + : Mutation(runtime, [&handler](A action) { return handler.execute(std::move(action)); }, std::move(options)) {} + + ~Mutation() = default; + Mutation(Mutation const&) = delete; + Mutation& operator=(Mutation const&) = delete; + Mutation(Mutation&&) = delete; + Mutation& operator=(Mutation&&) = delete; + + /// @brief Issues @p action. Refused off the owner. + /// @param action The action; built by the caller per gesture, so an idempotency key minted + /// while building it is fresh per click. + void run(A action) { + if (!_rt->core()->checkOwner()) { + return; + } + _inFlight.set(_inFlight.peek() + 1); + async::Completion completion = _run(std::move(action)); + completion + .then(_lifetime, + [this](R const& result) { + if (!_rt->core()->checkOwner()) { + return; + } + _rt->batch([&] { + _last.set(result); + _error.set(nullptr); + _inFlight.set(_inFlight.peek() - 1); + for (Refetchable* const query : _options.invalidates) { + query->refetch(); + } + }); + }) + .onError(_lifetime, [this](std::exception_ptr error) { + if (!_rt->core()->checkOwner()) { + return; + } + _rt->batch([&] { + _error.set(std::move(error)); + _inFlight.set(_inFlight.peek() - 1); + }); + }); + } + + /// @brief A callable for a button: runs what @p make returns, or nothing when it returns `nullopt`. + /// + /// Bind the button's `enabled` to the same validity the `make` function checks, so a disabled + /// button and a refused run agree. + /// @param make Builds the action from current inputs, or `nullopt` when they are not valid. + /// @return The callable; it refers to this mutation, which must outlive it. + [[nodiscard]] std::function action(std::function()> make) { + return [this, make = std::move(make)] { + if (std::optional built = make()) { + run(std::move(*built)); + } + }; + } + + /// @brief Whether at least one call is in flight. Tracked. + /// @return True while the in-flight count is non-zero. + [[nodiscard]] bool pending() const { return _inFlight.get() != 0; } + + /// @brief The most recent failure. Tracked. + /// @return The exception, or null; cleared by the next success. + [[nodiscard]] std::exception_ptr error() const { return _error.get(); } + + /// @brief The most recent successful result. Tracked. + /// @return The result, or `nullopt` before the first success. Valid until the next success. + [[nodiscard]] std::optional const& lastResult() const { return _last.get(); } + +private: + Runtime* _rt; + Run _run; + MutationOptions _options; + Signal _inFlight; + Signal _error; + Signal> _last; + // Last member, so it is destroyed first: a reply still in flight finds it stopped. + async::CallbackScope _lifetime; +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[control]"` +Expected: PASS. + +Mutation check: delete the `for (… _options.invalidates …)` loop. Expected FAIL in "success invalidates every +listed query in one flush" (`listServer.calls() == 1`). Restore. (Moving the loop outside the batch would *not* +fail the frame count: every write in one delivery task joins the one flush already requested, which is the +coalescing Task 1 pins.) + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/reactive/control.hpp tests/test_reactive_mutation.cpp tests/CMakeLists.txt +git commit -m "wip(reactive): Mutation, a tracked command that invalidates queries in one batch + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 8: `Subscription`, and declarative control over a real bridge + +**Files:** +- Modify: `include/morph/reactive/control.hpp` — append `Subscription` after `Mutation` +- Modify: `tests/CMakeLists.txt` — add `test_reactive_control_bridge.cpp` after `test_reactive_mutation.cpp` +- Test: `tests/test_reactive_control_bridge.cpp` + +**Interfaces:** +- Consumes: `Query`, `Mutation`, `MutationOptions`; `bridge::Bridge`, `bridge::BridgeHandler`, + `backend::LocalBackend`, `exec::ThreadPoolExecutor`, `exec::MainThreadExecutor::runFor`. +- Produces: `reactive::Subscription(Runtime&, bridge::BridgeHandler&)` with + `latest() -> std::optional const&` (tracked). + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_reactive_control_bridge.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct ReactiveTimes { + int value = 0; + bool operator==(ReactiveTimes const&) const = default; +}; +struct ReactiveReadHits { + bool operator==(ReactiveReadHits const&) const = default; +}; +struct ReactiveBump {}; +struct ReactiveBumped { + int hits = 0; +}; + +struct ReactiveHitModel { + int hits = 0; + int execute(ReactiveTimes action) { return action.value * 10; } + int execute(ReactiveReadHits) { return hits; } + ReactiveBumped execute(ReactiveBump) { return ReactiveBumped{++hits}; } +}; + +BRIDGE_REGISTER_MODEL(ReactiveHitModel, "Test_ReactiveHitModel") +BRIDGE_REGISTER_ACTION(ReactiveHitModel, ReactiveTimes, "Test_ReactiveTimes") +BRIDGE_REGISTER_ACTION(ReactiveHitModel, ReactiveReadHits, "Test_ReactiveReadHits") +BRIDGE_REGISTER_ACTION(ReactiveHitModel, ReactiveBump, "Test_ReactiveBump") + +namespace { + +using morph::reactive::Mutation; +using morph::reactive::MutationOptions; +using morph::reactive::Query; +using morph::reactive::Runtime; +using morph::reactive::Signal; +using morph::reactive::Subscription; + +struct Wiring { + morph::exec::ThreadPoolExecutor pool{1}; + morph::exec::MainThreadExecutor owner; + morph::bridge::Bridge bridge{std::make_unique(pool), owner}; + morph::bridge::BridgeHandler handler{bridge, &owner}; +}; + +template +bool pumpUntil(morph::exec::MainThreadExecutor& owner, Pred const& done) { + for (int step = 0; step < 400 && !done(); ++step) { + owner.runFor(std::chrono::milliseconds{5}); + } + return done(); +} + +} // namespace + +TEST_CASE("reactive control over a bridge: a Query re-fetches when its key changes", "[reactive][control]") { + Wiring wiring; + Runtime runtime{wiring.owner}; + Signal factor{runtime, 1}; + Query scaled{runtime, wiring.handler, [&] { return std::optional{ReactiveTimes{factor.get()}}; }}; + REQUIRE(pumpUntil(wiring.owner, [&] { return scaled.value() == std::optional{10}; })); + factor.set(5); + REQUIRE(pumpUntil(wiring.owner, [&] { return scaled.value() == std::optional{50}; })); +} + +TEST_CASE("reactive control over a bridge: a Mutation invalidates a Query", "[reactive][control]") { + Wiring wiring; + Runtime runtime{wiring.owner}; + Query hits{runtime, wiring.handler, [] { return std::optional{ReactiveReadHits{}}; }}; + Mutation bump{runtime, wiring.handler, MutationOptions{.invalidates = {&hits}}}; + REQUIRE(pumpUntil(wiring.owner, [&] { return hits.value() == std::optional{0}; })); + bump.run(ReactiveBump{}); + REQUIRE(pumpUntil(wiring.owner, [&] { return hits.value() == std::optional{1}; })); + CHECK(bump.lastResult().has_value()); +} + +TEST_CASE("reactive control over a bridge: refreshOn re-fetches when a result is published", + "[reactive][control]") { + Wiring wiring; + Runtime runtime{wiring.owner}; + Query hits{runtime, wiring.handler, [] { return std::optional{ReactiveReadHits{}}; }}; + hits.refreshOn(wiring.handler); + Mutation bump{runtime, wiring.handler}; // invalidates nothing itself + REQUIRE(pumpUntil(wiring.owner, [&] { return hits.value() == std::optional{0}; })); + bump.run(ReactiveBump{}); + REQUIRE(pumpUntil(wiring.owner, [&] { return hits.value() == std::optional{1}; })); +} + +TEST_CASE("reactive control over a bridge: a Subscription follows publishes", "[reactive][control]") { + Wiring wiring; + Runtime runtime{wiring.owner}; + Subscription latest{runtime, wiring.handler}; + Mutation bump{runtime, wiring.handler}; + CHECK_FALSE(latest.latest().has_value()); + bump.run(ReactiveBump{}); + bump.run(ReactiveBump{}); + REQUIRE(pumpUntil(wiring.owner, [&] { return latest.latest().has_value() && latest.latest()->hits == 2; })); +} + +TEST_CASE("reactive control over a bridge: a destroyed Subscription gates later publishes", "[reactive][control]") { + Wiring wiring; + Runtime runtime{wiring.owner}; + auto latest = std::make_unique>(runtime, wiring.handler); + Mutation bump{runtime, wiring.handler}; + latest.reset(); + bump.run(ReactiveBump{}); + REQUIRE(pumpUntil(wiring.owner, [&] { return bump.lastResult().has_value(); })); // ASan observes the gate +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `no template named 'Subscription' in namespace 'morph::reactive'`. + +- [ ] **Step 3: Implement `Subscription`** + +In `include/morph/reactive/control.hpp`, insert before the closing `} // namespace morph::reactive`: + +```cpp +/// @brief The latest `R` the bridge published on a handler's instance, as reactive state. +/// +/// Uses the handler's one subscription slot for `R`. The subscription is gated by a scope the +/// Subscription owns, so a publish after it is destroyed is dropped. +/// @tparam R The published result type. +template +class Subscription final { +public: + /// @tparam M The handler's model type. + /// @tparam S The handler's sharing policy. + /// @param runtime The runtime. Borrowed: it must outlive the subscription. + /// @param handler The handler whose instance publishes; its GUI executor must be the runtime's + /// owner. Borrowed: it must outlive the subscription. + template + Subscription(Runtime& runtime MORPH_LIFETIMEBOUND, bridge::BridgeHandler& handler MORPH_LIFETIMEBOUND) + : _rt{&runtime}, _latest{runtime, std::nullopt} { + handler.template subscribe(_lifetime, [this](R value) { + if (!_rt->core()->checkOwner()) { + return; + } + _latest.set(std::move(value)); + }); + } + + ~Subscription() = default; + Subscription(Subscription const&) = delete; + Subscription& operator=(Subscription const&) = delete; + Subscription(Subscription&&) = delete; + Subscription& operator=(Subscription&&) = delete; + + /// @brief The latest published value. Tracked. + /// @return The value, or `nullopt` before the first publish. Valid until the next publish. + [[nodiscard]] std::optional const& latest() const { return _latest.get(); } + +private: + Runtime* _rt; + Signal> _latest; + // Last member, so it is destroyed first: a publish still in flight finds it stopped. + async::CallbackScope _lifetime; +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[reactive]"` +Expected: PASS — every `[reactive]` case from Tasks 1–8. + +Mutation check: in `Query::refreshOn`, replace the body with nothing. Expected FAIL in "refreshOn re-fetches when a +result is published" (times out at `hits.value() == 1`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/reactive/control.hpp tests/test_reactive_control_bridge.cpp tests/CMakeLists.txt +git commit -m "wip(reactive): Subscription, and control over a real LocalBackend + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 9: `Scheduler`, `ManualScheduler` and `QueryOptions::refreshEvery` + +Timers are a control concern (a controller's polling is a query refreshed on a period), so the scheduler interface +lives here; `ui::Scheduler` (Part 2) is an alias, and each frontend implements it (EventLoop timers on the TUI, +`QTimer` on Qt Quick). + +**Files:** +- Create: `include/morph/reactive/scheduler.hpp`, `include/morph/reactive/testing/manual_scheduler.hpp` +- Modify: `include/morph/reactive/control.hpp` — `QueryOptions`, and a last `QueryOptions` parameter on both `Query` + constructors +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/reactive/control.hpp`: + `include/morph/reactive/scheduler.hpp` and `include/morph/reactive/testing/manual_scheduler.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_reactive_scheduler.cpp` after `test_reactive_control_bridge.cpp` +- Test: `tests/test_reactive_scheduler.cpp` + +**Interfaces:** +- Consumes: `Query` (Task 6), `Runtime`, `Signal`. +- Produces (the interface contract's Part 1 additions): `reactive::TimerHandle`, `reactive::Scheduler`, + `reactive::testing::ManualScheduler{advance, pendingTimers}`, `reactive::QueryOptions{scheduler, refreshEvery}`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_reactive_scheduler.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace { + +using morph::async::Completion; +using morph::reactive::Query; +using morph::reactive::QueryOptions; +using morph::reactive::Runtime; +using morph::reactive::TimerHandle; +using morph::reactive::testing::ManualScheduler; +using Owner = morph::testing::StepExecutor; +using namespace std::chrono_literals; + +struct Lookup { + int id = 0; + bool operator==(Lookup const&) const = default; +}; + +} // namespace + +TEST_CASE("reactive::ManualScheduler: after fires once at its deadline", "[reactive][scheduler]") { + ManualScheduler scheduler; + int fired = 0; + TimerHandle const handle = scheduler.after(100ms, [&] { ++fired; }); + scheduler.advance(99ms); + CHECK(fired == 0); + scheduler.advance(1ms); + CHECK(fired == 1); + scheduler.advance(1000ms); + CHECK(fired == 1); + CHECK(scheduler.pendingTimers() == 0); +} + +TEST_CASE("reactive::ManualScheduler: every repeats until cancelled", "[reactive][scheduler]") { + ManualScheduler scheduler; + int fired = 0; + TimerHandle handle = scheduler.every(10ms, [&] { ++fired; }); + scheduler.advance(35ms); + CHECK(fired == 3); + handle.cancel(); + CHECK_FALSE(handle.active()); + scheduler.advance(100ms); + CHECK(fired == 3); +} + +TEST_CASE("reactive::TimerHandle: destruction cancels; moving transfers", "[reactive][scheduler]") { + ManualScheduler scheduler; + int fired = 0; + { + TimerHandle const transient = scheduler.every(10ms, [&] { ++fired; }); + } + scheduler.advance(50ms); + CHECK(fired == 0); + + TimerHandle outer; + { + TimerHandle inner = scheduler.every(10ms, [&] { ++fired; }); + outer = std::move(inner); + } + scheduler.advance(10ms); + CHECK(fired == 1); +} + +TEST_CASE("reactive::ManualScheduler: a callback may cancel itself", "[reactive][scheduler]") { + ManualScheduler scheduler; + int fired = 0; + TimerHandle handle; + handle = scheduler.every(10ms, [&] { + ++fired; + handle.cancel(); + }); + scheduler.advance(100ms); + CHECK(fired == 1); +} + +TEST_CASE("reactive::ManualScheduler: a handle outliving its scheduler is safe", "[reactive][scheduler]") { + TimerHandle handle; + { + ManualScheduler scheduler; + handle = scheduler.every(10ms, [] {}); + } + handle.cancel(); // must not touch the destroyed scheduler (ASan observes) + CHECK_FALSE(handle.active()); +} + +TEST_CASE("reactive::ManualScheduler: every refuses a non-positive period", "[reactive][scheduler]") { + ManualScheduler scheduler; + CHECK_THROWS_AS(static_cast(scheduler.every(0ms, [] {})), std::invalid_argument); +} + +TEST_CASE("reactive::Query: refreshEvery re-fetches on the scheduler's period", "[reactive][scheduler][control]") { + Owner owner; + Runtime runtime{owner}; + ManualScheduler scheduler; + std::vector::Promise> promises; + auto fetch = [&](Lookup const&) { + auto [completion, promise] = Completion::makeSettleable(&owner); + promises.push_back(std::move(promise)); + return std::move(completion); + }; + auto query = std::make_unique>(runtime, fetch, [] { return std::optional{Lookup{1}}; }, + QueryOptions{.scheduler = &scheduler, + .refreshEvery = 1000ms}); + CHECK(promises.size() == 1); + scheduler.advance(1000ms); + CHECK(promises.size() == 2); + scheduler.advance(2000ms); + CHECK(promises.size() == 4); + query.reset(); + CHECK(scheduler.pendingTimers() == 0); +} + +TEST_CASE("reactive::Query: refreshEvery without a scheduler is refused", "[reactive][scheduler][control]") { + Owner owner; + Runtime runtime{owner}; + auto fetch = [&](Lookup const&) { return Completion{}; }; + CHECK_THROWS_AS((Query{runtime, fetch, [] { return std::optional{Lookup{1}}; }, + QueryOptions{.refreshEvery = 1000ms}}), + std::invalid_argument); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `'morph/reactive/scheduler.hpp' file not found`. + +- [ ] **Step 3: Implement the scheduler interface** + +Create `include/morph/reactive/scheduler.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include + +/// @file +/// @brief `morph::reactive::Scheduler`: timers a frontend runs on the runtime's owner executor. +/// +/// Specified in `docs/spec/reactive/control.md`, "Timed refresh". + +namespace morph::reactive { + +/// @brief Owns one scheduled timer and cancels it when destroyed. Move-only. +class TimerHandle { +public: + /// @brief A handle that owns no timer. + TimerHandle() = default; + + /// @param cancel Cancels the timer; called at most once, and must not throw. + explicit TimerHandle(std::function cancel) : _cancel{std::move(cancel)} {} + + ~TimerHandle() { cancel(); } + TimerHandle(TimerHandle const&) = delete; + TimerHandle& operator=(TimerHandle const&) = delete; + + /// @brief Takes over @p other's timer; @p other then owns none. + /// @param other The handle to move from. + TimerHandle(TimerHandle&& other) noexcept : _cancel{std::exchange(other._cancel, {})} {} + + /// @brief Cancels this handle's timer, then takes over @p other's. + /// @param other The handle to move from. + /// @return `*this`. + TimerHandle& operator=(TimerHandle&& other) noexcept { + if (this != &other) { + cancel(); + _cancel = std::exchange(other._cancel, {}); + } + return *this; + } + + /// @brief Cancels the timer, if this handle still owns one. + void cancel() noexcept { + if (auto const stop = std::exchange(_cancel, {})) { + stop(); + } + } + + /// @brief Whether this handle still owns a timer to cancel. + /// @return False after `cancel()`, after a move from it, and for a default-constructed handle. + [[nodiscard]] bool active() const noexcept { return static_cast(_cancel); } + +private: + std::function _cancel; +}; + +/// @brief Runs callbacks after a delay or on a period, on the runtime's owner executor. +/// +/// Every frontend provides one (spec 1 §5b); a test uses `testing::ManualScheduler`. +class Scheduler { +public: + Scheduler() = default; + virtual ~Scheduler() = default; + Scheduler(Scheduler const&) = delete; + Scheduler& operator=(Scheduler const&) = delete; + Scheduler(Scheduler&&) = delete; + Scheduler& operator=(Scheduler&&) = delete; + + /// @brief Runs @p fn once, @p delay from now. + /// @param delay How long to wait. + /// @param fn The callback. + /// @return The handle; destroying it before the deadline cancels the call. + [[nodiscard]] virtual TimerHandle after(std::chrono::milliseconds delay, std::function fn) = 0; + + /// @brief Runs @p fn every @p period, starting one period from now. + /// @param period The interval; must be positive. + /// @param fn The callback. + /// @return The handle; destroying it stops the repetition. + /// @throws std::invalid_argument when @p period is not positive. + [[nodiscard]] virtual TimerHandle every(std::chrono::milliseconds period, std::function fn) = 0; +}; + +} // namespace morph::reactive +``` + +Create `include/morph/reactive/testing/manual_scheduler.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../scheduler.hpp" + +/// @file +/// @brief `morph::reactive::testing::ManualScheduler`: a scheduler whose time moves only when a test says so. + +namespace morph::reactive::testing { + +/// @brief A deterministic `Scheduler`: `advance()` moves its clock and fires what fell due, in deadline order, on +/// the calling thread. +class ManualScheduler final : public Scheduler { +public: + ManualScheduler() = default; + ~ManualScheduler() override = default; + ManualScheduler(ManualScheduler const&) = delete; + ManualScheduler& operator=(ManualScheduler const&) = delete; + ManualScheduler(ManualScheduler&&) = delete; + ManualScheduler& operator=(ManualScheduler&&) = delete; + + [[nodiscard]] TimerHandle after(std::chrono::milliseconds delay, std::function fn) override { + return add(delay, std::chrono::milliseconds{0}, std::move(fn)); + } + + [[nodiscard]] TimerHandle every(std::chrono::milliseconds period, std::function fn) override { + if (period.count() <= 0) { + throw std::invalid_argument{"ManualScheduler::every: the period must be positive"}; + } + return add(period, period, std::move(fn)); + } + + /// @brief Moves the clock forward by @p by, firing every timer that falls due, earliest first; a periodic + /// timer fires once per elapsed period. + /// @param by How far to move. + void advance(std::chrono::milliseconds by) { + auto const target = _state->now + by; + for (;;) { + auto& timers = _state->timers; + auto const due = std::ranges::min_element(timers, {}, [](Timer const& timer) { return timer.deadline; }); + if (due == timers.end() || due->deadline > target) { + break; + } + _state->now = due->deadline; + std::function const fire = due->fn; + if (due->period.count() > 0) { + due->deadline += due->period; + } else { + timers.erase(due); + } + fire(); + } + _state->now = target; + } + + /// @brief How many timers are scheduled. + /// @return The count. + [[nodiscard]] std::size_t pendingTimers() const { return _state->timers.size(); } + +private: + struct Timer { + std::uint64_t id; + std::chrono::milliseconds deadline; + std::chrono::milliseconds period; + std::function fn; + }; + struct State { + std::chrono::milliseconds now{0}; + std::uint64_t nextId = 0; + std::vector timers; + }; + + TimerHandle add(std::chrono::milliseconds delay, std::chrono::milliseconds period, std::function fn) { + auto const timerId = ++_state->nextId; + _state->timers.push_back(Timer{.id = timerId, .deadline = _state->now + delay, .period = period, .fn = std::move(fn)}); + return TimerHandle{[weak = std::weak_ptr{_state}, timerId] { + if (auto const state = weak.lock()) { + std::erase_if(state->timers, [timerId](Timer const& timer) { return timer.id == timerId; }); + } + }}; + } + + std::shared_ptr _state = std::make_shared(); +}; + +} // namespace morph::reactive::testing +``` + +- [ ] **Step 4: Add `QueryOptions` to `Query`** + +In `include/morph/reactive/control.hpp`, add `#include `, `#include ` and +`#include "scheduler.hpp"`, and before `Query`: + +```cpp +/// @brief How a `Query` refreshes besides key changes. +struct QueryOptions { + /// @brief Runs the timed refresh; required when `refreshEvery` is positive. + Scheduler* scheduler = nullptr; + /// @brief Re-fetch the current key this often; zero means no timed refresh. + std::chrono::milliseconds refreshEvery{0}; +}; +``` + +Change the two `Query` constructors to take `QueryOptions options = {}` as their last parameter (the handler +constructor forwards it), and at the end of the fetcher constructor's body, after `_effect` is created: + +```cpp + if (options.refreshEvery.count() > 0) { + if (options.scheduler == nullptr) { + throw std::invalid_argument{"Query: refreshEvery needs a scheduler"}; + } + _timer = options.scheduler->every(options.refreshEvery, [this] { refetch(); }); + } +``` + +Add the member `TimerHandle _timer;` directly after `std::unique_ptr _effect;` (so it is destroyed before +the effect and the signals the refresh touches), and document both new constructor parameters with +`@param options Timed refresh; see QueryOptions.` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[scheduler]"` +Expected: PASS. Mutation check: drop `_timer = ` (discard the handle, so the timer is cancelled immediately). +Expected FAIL in "refreshEvery re-fetches on the scheduler's period". Restore. + +- [ ] **Step 6: Commit** + +```bash +git add include/morph/reactive/scheduler.hpp include/morph/reactive/testing include/morph/reactive/control.hpp \ + tests/test_reactive_scheduler.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(reactive): Scheduler, ManualScheduler and a Query's timed refresh + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 10: Specs, maps and changelog + +**Files:** +- Create: `docs/spec/reactive/signals.md`, `docs/spec/reactive/store.md`, `docs/spec/reactive/control.md` +- Modify: `docs/spec/README.md` — "Start here" map +- Modify: `docs/ARCHITECTURE.md` — "Namespace map" table and "Header map" +- Modify: `CHANGELOG.md` — `## [Unreleased]` → `### Added` +- Modify: `docs/spec/pinned_facts.toml`, `tests/test_pinned_facts.cpp` + +**Interfaces:** +- Consumes: the API exactly as Tasks 1–9 ship it. Where this text and the code disagree, fix the text. +- Produces: the authoritative spec Parts 2–4 read before touching `morph::reactive`. + +- [ ] **Step 1: Write `docs/spec/reactive/signals.md`** + +```markdown +# Signals — design + +Design spec for `morph::reactive::Runtime`, `Signal`, `Computed`, `Effect` and `Scope` +(`include/morph/reactive/{runtime,signal,scope}.hpp`, internals in `detail/graph.hpp`): the +fine-grained reactive graph a morph UI's state, controllers and view bindings are built from. + +## Contents + +- [Nodes](#nodes) +- [Scheduling](#scheduling) +- [The algorithm](#the-algorithm) +- [Misuse: reported, then refused](#misuse-reported-then-refused) +- [Lifetimes](#lifetimes) +- [Relation to the bridge](#relation-to-the-bridge) +- [Design decisions](#design-decisions) + +## Nodes + +| Type | Behaviour | +|---|---| +| `Signal` | A source. `get()` subscribes the innermost tracked run; `peek()` does not. `set(v)` skips a value equal to the current one when `T` is `std::equality_comparable`; `mutate(f)` always notifies. | +| `Computed` | Lazy, cached in a `std::optional` (so `T` needs no default constructor), equality-gated: an unchanged result stops propagation. A throwing computation rethrows to the reader; the next read retries it. | +| `Effect` | Runs once in its constructor, then in a flush after any source it read changed. | +| `Scope` | Owns nodes and adopted objects; destroys them newest first. | + +Every node is non-copyable and non-movable: other nodes hold raw pointers to it. Every node takes +the `Runtime&`; there is no global and no `thread_local`. + +## Scheduling + +- A write outside `Runtime::batch` is a batch of one; batches nest. +- When the outermost batch ends with effects queued and no flush requested, the runtime posts + **one** flush to its owner. A `QtExecutor` does not coalesce posts; this flag does. +- No Effect runs inside `set()`. That makes a write safe from a Qt handler or a `Completion` + callback, and lets a controller write several signals before anything observes them. +- Writes made by effects during a flush are processed by that same flush. +- The posted flush holds the core weakly and checks whether the `Runtime` is gone. +- `RuntimeOptions::afterFlush` runs after every flush that ran at least one Effect: a frontend + schedules its redraw there. +- `Runtime::widgetEvent(f)` runs a widget callback as one batch; a flush that starts inside one is + refused and re-posted, so a remount never destroys a widget whose native handler is on the stack. + +## The algorithm + +Push-dirty, pull-value, three colours (Clean < Check < Dirty), as in Reactively and Preact signals: + +1. `set()` marks direct observers Dirty and their transitive observers Check. An Effect leaving + Clean is queued. +2. The flush takes effects in queue order. For a Check node it brings each source up to date in + read order, stopping as soon as one reports a change (which makes the node Dirty). Only a Dirty + node recomputes. A Computed whose result is unchanged does not dirty its observers. +3. A run records what it read in a `TrackingFrame`; afterwards the node's sources are replaced by + that set, so a source it stopped reading no longer reaches it. A run that throws keeps the union + of old and new sources, so later changes still reach it. + +Because a node is pulled only through its sources and recomputed only when a source really +changed, an Effect reading `A` and `Computed(A)` never sees one updated and the other stale. + +An Effect that writes a signal it is reading does not re-run itself for that write: it is mid-run +(Dirty), so the write cannot raise it further, and it ends Clean. + +## Misuse: reported, then refused + +A misuse is reported through `morph::exec::detail::noteOwner(site, owner, false)` — an assertion +in a debug build, an observation when a test installs `OwnerProbeRecorder` — and then refused the +same way in every build, so behaviour does not depend on `NDEBUG`. Site names are +`detail::site::*`. + +| Misuse | Site | Refusal | +|---|---|---| +| An operation off the owner (`OwnerAffinity`: inside an owner task, or on the constructing thread outside every task) | `kOffOwner` | The write or delivery is dropped; a read proceeds untracked of the violation | +| An exception escaping an Effect | `kEffectThrew` | The flush stops; what is still queued runs in a new flush | +| One Effect run more than `maxEffectRunsPerFlush` (default 100) times in a flush | `kWriteCycle` | The flush stops and its queue is dropped | +| `set()`/`mutate()` while a Computed computes | `kSetInComputed` | The write is dropped | +| A Computed reading itself | `kComputedReadsItself` | The read throws `std::logic_error` | +| `Store::send` inside an update | `kSendInUpdate` | The message is dropped | +| A `Runtime` destroyed while nodes are alive | `kRuntimeOutlived` | Nodes keep the core alive; flushes do nothing | +| A flush starting inside `widgetEvent` | `kFlushInWidgetEvent` | Re-posted | + +## Lifetimes + +- Destroying a node unlinks it from its sources and observers, removes it from the flush queue and + from every open tracking frame. Destroying a queued Effect during a flush is safe. +- A `Scope` destroys what it owns newest first: bindings die before the widgets they point at. +- The `Runtime` and every node must be destroyed on the owner. + +## Relation to the bridge + +This layer is UI-side only. It consumes `Completion` and `BridgeHandler::subscribe` like any +caller and never enters `BridgeHandler`; nothing in the bridge knows a reactive runtime exists. + +## Design decisions + +| Decision | Why | +|---|---| +| Fine-grained signals, not re-render plus diff | A bound property updates exactly the widget setter that depends on it; no virtual tree, no diff, and widget identity (focus, selection) survives updates. | +| Flushes are always posted | Running effects inside `set()` re-enters view code from wherever the write happened — a Qt handler, a `Completion` callback — and makes intermediate states visible. | +| One coalescing flag in the core | The owner executor may not coalesce (`QtExecutor` does not); N writes must still be one flush. | +| Report-then-refuse through `noteOwner` | One mechanism, already observable in morph's tests, with the same refusal in release builds. | +| Nodes hold the core by `shared_ptr` | A node outliving its `Runtime` is a defect, but not a use-after-free. | +``` + +- [ ] **Step 2: Write `docs/spec/reactive/store.md`** + +```markdown +# Store — design + +Design spec for `morph::reactive::Store`, `ExhaustiveUpdate` and `request()` +(`include/morph/reactive/store.hpp`): user-intent view state changed only by messages. + +## Shape + +- `ViewState` is a struct of `Signal` fields: what is selected, what is being typed, which tab + is open. It is not called *model*: in morph a model is the domain object a + `BridgeHandler` talks to. +- `Msg` is a `std::variant` of message types. +- `Store(Runtime&, Init, Update)`: `init(runtime)` returns the `ViewState` by prvalue — signals cannot + move, so the Store's member is initialised from that prvalue directly. `Update` must satisfy + `ExhaustiveUpdate`: invocable as `(ViewState&, Alt const&)` for every + alternative. A missing alternative is a constraint failure at the Store's construction. + +## Behaviour + +- `send(msg)` applies the update in one batch, untracked: N field writes, one flush. +- `state()` returns `ViewState const&`; a field's `get()` tracks exactly that field. +- `action(msg)` returns a `std::function` that sends a copy of `msg`. +- `send` off the owner is dropped (`kOffOwner`); `send` inside an update is dropped + (`kSendInUpdate`). + +## `request()` + +`request(store, handler, scope, action, toMsg, toFailMsg)` executes `action` and sends +`toMsg(result)` or `toFailMsg(exception_ptr)` as one message. Errors travel per call inside the +message, never as a shared error string. `scope` gates both callbacks. It is the primitive `Query` +and `Mutation` are built on; a controller reaches for those first ([control.md](control.md)). + +## Lifetime rule + +The `Store`, the `Runtime` and every mounted view outlive any callback that can still fire. The +`CallbackScope` is the **last** member of the owning object, so it is destroyed first and gates +anything in flight. + +## Out of scope + +Elm-style `Cmd` values. Server interaction a state change implies is a `Query` keyed on that state, +so an update never needs to issue a request. +``` + +- [ ] **Step 3: Write `docs/spec/reactive/control.md`** + +```markdown +# Declarative control — design + +Design spec for `morph::reactive::Query`, `Mutation`, `MutationOptions`, `Subscription`, +`Refetchable` and `errorMessage` (`include/morph/reactive/control.hpp`): server interaction as +reactive state, so a controller states what it depends on and what a write invalidates, and the +runtime decides when to talk to the model. + +## The problem + +A hand-written controller sequences calls: selecting an account calls `reloadHistory`, a deposit's +`.then` calls `refresh`, every call repeats a rethrow/catch to produce an error string, and `busy` +is a counter kept by hand. Two defects follow from that shape: + +- **Stale replies win.** Select A, then B; if A's reply lands after B's, the screen shows A's data + under B. +- **Invalidation is a call graph.** What a write must refresh lives in whichever `.then` remembered + to call `refresh()`. + +## `Query` + +`R` defaults to `model::ActionTraits::Result`. Constructed from a `BridgeHandler` (the model is +deduced) or from any `std::function(A const&)>` fetcher — the test seam, and the way to +query a non-bridge source — plus a tracked key `std::function()>`. + +- An internal Effect reads the key. A changed key — equality-gated when `A` is + `std::equality_comparable`, otherwise every re-run — is fetched under a fresh generation of the + query's `CallbackScope` (`reset()`). Give an action `operator== = default` to get the skip. +- **Latest wins:** a reply from a superseded generation is dropped, success and failure alike, and a + call carrying a stop source is asked to stop. +- `nullopt` is **idle**: nothing in flight; `value()` and `error()` cleared; `refetch()` does nothing. +- Tracked reads: `pending()`; `value()` — the last result, kept while a refetch is in flight; + `error()` — cleared by the next success. +- `refreshOn(handler)` re-fetches whenever the bridge publishes a `Pub` on the handler's + instance. It uses the handler's one subscription slot for `Pub`: two consumers use two handlers. +- A key function that throws is an Effect that threw (`kEffectThrew`); the query keeps its state. +- A delivery off the runtime's owner is reported (`kOffOwner`) and dropped. +- **Timed refresh:** `QueryOptions{.scheduler, .refreshEvery}` re-fetches the current key every period on a + `Scheduler` (`scheduler.hpp`), whose callbacks run on the runtime's owner; idle stays idle; destroying the query + cancels the timer. `testing::ManualScheduler` drives it deterministically. + +## `Mutation` + +Constructed from a `BridgeHandler` or a `std::function(A)>` runner, plus +`MutationOptions{ .invalidates = {&queryA, &queryB} }`. Declare the queries before the mutation, +so they outlive it. + +- `run(action)` issues one call; `pending()` is true while any call is in flight. +- On success: `lastResult()` set, `error()` cleared, every listed query refetched — one batch. + Invalidating an idle query issues nothing. +- On failure: `error()` set; nothing invalidated. +- `action(make)` returns a button callable: `make` returns the action, or `nullopt` when inputs are + invalid. Bind the button's `enabled` to the same validity. + +## `Subscription` + +`latest()` is a tracked `std::optional`, fed by `handler.subscribe` and gated by a scope the +Subscription owns. + +## `errorMessage` + +`what()` for a `std::exception`, `"unknown error"` for anything else, empty for null. Views bind +to it through a `Computed`. + +## The controller + +A controller is a plain user struct, not a framework base class. It owns, in this order: handlers, +a `Store` if it has user-intent state, Queries, Mutations, Subscriptions, and `Computed` +projections (row formatting, masking, aggregates). Its `CallbackScope`s, inside the nodes above, +are destroyed first. It includes nothing from `morph::ui`, `morph::tui` or a toolkit, so it is +testable with an executor and a local bridge and no backend mounted, and the same controller drives +every frontend. + +## Design decisions + +| Decision | Why | +|---|---| +| Generations through `CallbackScope::reset()` | It is morph's supersede verb: it gates the stale reply and asks a stoppable call to stop, with no counter to keep. | +| Keep `value()` while refetching | A list does not blank between reloads; `pending()` says a reload is happening. | +| Invalidate in the success batch | The result, the cleared error, the pending count and the refetches are one flush and one frame. | +| A fetcher constructor beside the handler one | Any ordering of replies can be forced in a test with `Completion::makeSettleable`. | +| No paging or optimistic-write policy | Not needed by the screens that prove this layer; one that two examples need becomes an option rather than being hand-rolled twice. | +``` + +- [ ] **Step 4: Update the maps and the changelog** + +In `docs/spec/README.md`, under "Start here — specs by the question they answer", add after the +"**Schema-driven UI**" group: + +```markdown +**Reactive state and control** +[`reactive/signals.md`](reactive/signals.md) · +[`reactive/store.md`](reactive/store.md) · +[`reactive/control.md`](reactive/control.md) +``` + +In `docs/ARCHITECTURE.md`, "Namespace map" table, add after the `morph::forms` row: + +```markdown +| `morph::reactive` | Signal graph, view state and declarative control | `Runtime`, `RuntimeOptions`, `Signal`, `Computed`, `Effect`, `Scope`, `Store`, `ExhaustiveUpdate`, `request()`, `Query`, `Mutation`, `MutationOptions`, `Subscription`, `Refetchable`, `errorMessage` | +``` + +and in "Header map", after the last `####` sub-section of "Library headers (`include/morph/`)", add: + +```markdown +#### `reactive/` — signal graph, view state, declarative control + +| Header | Responsibility | +|---|---| +| `reactive/runtime.hpp` | `Runtime`, `RuntimeOptions` — owner, batching, one posted flush | +| `reactive/signal.hpp` | `Signal`, `Computed`, `Effect` | +| `reactive/scope.hpp` | `Scope` — owns nodes, destroys them newest first | +| `reactive/store.hpp` | `Store`, `ExhaustiveUpdate`, `request()` | +| `reactive/control.hpp` | `Query`, `QueryOptions`, `Mutation`, `MutationOptions`, `Subscription`, `Refetchable`, `errorMessage` | +| `reactive/scheduler.hpp` | `Scheduler`, `TimerHandle`; `reactive/testing/manual_scheduler.hpp`: `ManualScheduler` | +| `reactive/detail/graph.hpp` | `RuntimeCore`, `Node`, `TrackingFrame`, colours, misuse site names (detail) | +``` + +In `CHANGELOG.md`, under `## [Unreleased]` → `### Added`, add as the first entry: + +```markdown +- **`morph::reactive`: a signal graph, view state and declarative control.** Header-only, in the + base `morph` target. + - `Signal`, `Computed`, `Effect` and `Scope` over a `Runtime` bound to an owner executor: + glitch-free, batched, one posted flush per change, misuse reported through the owner probe and + then refused. + - `Store` with a compile-time exhaustive update, and `request()`. + - `Query` (a latest-wins async resource keyed on tracked state), `Mutation` (a tracked command + that refetches the queries it invalidates in one batch) and `Subscription`, over a + `BridgeHandler` or any `Completion`-returning fetcher; `errorMessage`. + - Specified in `docs/spec/reactive/`. +``` + +- [ ] **Step 5: Pin the two constants the specs state** + +Append to `docs/spec/pinned_facts.toml`: + +```toml + +# ── morph::reactive ───────────────────────────────────────────────────────── +REACTIVE_MAX_EFFECT_RUNS_PER_FLUSH = 100 # RuntimeOptions{}.maxEffectRunsPerFlush; include/morph/reactive/runtime.hpp +REACTIVE_UNKNOWN_ERROR_TEXT = "unknown error" # errorMessage() for a non-std exception; include/morph/reactive/control.hpp +``` + +Append to `tests/test_pinned_facts.cpp` (add `#include `, +`#include ` and `#include ` to its includes): + +```cpp +// ── morph::reactive ────────────────────────────────────────────────────────── +// +// Run-time checks: RuntimeOptions holds a std::function, so it is not a literal type. + +TEST_CASE("pinned-facts: morph::reactive defaults", "[pinned-facts]") { + REQUIRE(morph::reactive::RuntimeOptions{}.maxEffectRunsPerFlush == + static_cast(morph::pinned_facts::kExpected_REACTIVE_MAX_EFFECT_RUNS_PER_FLUSH)); + REQUIRE(morph::reactive::errorMessage(std::make_exception_ptr(42)) == + morph::pinned_facts::kExpected_REACTIVE_UNKNOWN_ERROR_TEXT); +} +``` + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[pinned-facts]"` +(re-configure first if the generated header lacks the new keys: `cmake build/reactive`). +Expected: PASS. Change the toml value to 101, re-configure and rebuild: expected FAIL. Restore. + +- [ ] **Step 6: Build the docs with warnings as errors** + +```bash +cmake -S . -B build/docs -G Ninja -DMORPH_BUILD_DOCUMENTATION=ON -DMORPH_BUILD_TESTS=OFF -DMORPH_BUILD_EXAMPLES=OFF +cmake --build build/docs --target doc +``` + +Expected: exits 0. A warning naming a `morph/reactive` symbol is a missing `@param`/`@tparam`/`@return`: fix the +header, not the Doxygen configuration. + +- [ ] **Step 7: Commit** + +```bash +git add docs/spec/reactive docs/spec/README.md docs/ARCHITECTURE.md CHANGELOG.md docs/spec/pinned_facts.toml \ + tests/test_pinned_facts.cpp +git commit -m "wip(reactive): specify morph::reactive + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 11: Whole-part verification + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Strict build and the full suite** + +```bash +cmake --build build/reactive && ctest --test-dir build/reactive --output-on-failure +``` + +Expected: every test passes, not only `[reactive]` — `VERIFY_INTERFACE_HEADER_SETS` compiles each new public +header standalone as part of the build. + +- [ ] **Step 2: Sanitizers** (Linux; on macOS use an ASan configure of the same tree) + +```bash +cmake --preset clang-asan && cmake --build --preset clang-asan +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-asan/tests/morph_tests asan +./build/clang-asan/tests/morph_tests "[reactive]" +cmake --preset clang-tsan && cmake --build --preset clang-tsan +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-tsan/tests/morph_tests tsan +./build/clang-tsan/tests/morph_tests "[reactive]" +``` + +Expected: clean. The destroyed-Query, destroyed-Subscription and queued-Effect-destroyed cases rely on ASan as +their observer; the instrumentation check is what makes a clean run mean something. + +- [ ] **Step 3: clang-tidy over the changed lines** — the recipe in CONTRIBUTING, "Running the `clang-tidy-diff` + gate locally", with `origin/master...HEAD` and the file count asserted non-zero. + +Expected: no findings. + +- [ ] **Step 4: Install/export** + +```bash +bash scripts/check_install_export.sh +``` + +Expected: passes — its consumer translation unit includes every installed public header, so a `reactive/` header +that needs an uninstalled `detail/` header fails here. + +- [ ] **Step 5: Commit any fixes** + +```bash +git add -A include/morph/reactive tests docs +git commit -m "wip(reactive): fixes from the sanitizer, tidy and install gates + +Signed-off-by: Christian Parpart " +``` + +Skip the commit if there was nothing to fix, and say so in the hand-off. + +- [ ] **Step 6: Squash this part into its one commit** + +Follow the master plan's "Squashing a part" procedure with key `reactive` and this message: + +```text +reactive: signal graph, view state and declarative control + +morph::reactive, header-only in the base morph target: Signal, Computed, +Effect and Scope over a Runtime bound to an owner executor (glitch-free, +batched, one posted flush per change, misuse reported through the owner +probe and then refused); Store with a compile-time +exhaustive update and request(); Query, Mutation and Subscription over a +BridgeHandler or any Completion-returning fetcher. Specified in +docs/spec/reactive/. + +Signed-off-by: Christian Parpart +``` diff --git a/docs/superpowers/plans/2026-10-04-declarative-ui-tui-10-examples-forms-and-retire.md b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-10-examples-forms-and-retire.md new file mode 100644 index 000000000..f6cce6e9b --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-10-examples-forms-and-retire.md @@ -0,0 +1,3882 @@ +# Declarative UI, Part 10 — the forms demo, then retiring the QML renderer and the Qt client stack + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Move `examples/forms` onto the injected frontend (`morph_forms_app`: `LabApp` through +`forms::appShellView`, runtime by default, typed with `--typed`), then delete everything the program replaced — +the shipped QML forms renderer and its Qt components, `examples/common`'s Qt client stack, and `morph_add_rung`'s +old client conventions — leaving no reference behind but history. + +**Architecture:** Two groups, two commits. `forms-demo` adds a toolkit-free app library (`examples/forms/app/`, +target `forms_app`): one `forms::AppShellSession` over `LabApp`'s `app-*` document, which makes each screen's +session from the served JSON documents, all of them submitting through `forms::handlerSubmitter` over one +in-process `BridgeHandler`; `--typed` swaps the two form screens for `forms::Form`s in a shell view +of the app's own. One binary (`examples/forms/ui/main.cpp`, target `morph_forms_app`) picks the terminal UI or Qt +Quick at run time; the QML demo goes. `retire` then removes `src/qt/forms`, `include/morph/qt/{forms,bridge}`, the +`qt_forms`/`forms_qml`/`forms_qmlplugin` components and `MORPH_BUILD_FORMS_QML`, the `examples/common` presenter +stack, and the rung conventions nothing uses any more, and rewrites the documents that described them. + +**Tech Stack:** C++23; `morph::reactive`, `morph::ui`, the forms engine (`include/morph/forms/engine/`), +`morph::tui`, `morph::qt_quick`, `examples/common/app` (Part 6); CMake; Catch2 v3; GitHub Actions YAML; Markdown. + +**Spec:** `docs/superpowers/specs/2026-10-04-examples-migration-design.md` (spec 4) §3, §4 (removals), §5 (forms +demo), §6, §7, §8 item 3, §9; `docs/superpowers/specs/2026-10-04-forms-engine-design.md` (spec 2) §5, §8, §11, §12. +Interface contract: `docs/superpowers/plans/2026-10-04-declarative-ui-tui-interfaces.md`, including "Conventions for +every example application". + +This is **Part 10 of 11** of the declarative-UI program, and its last. Read the master plan +`docs/superpowers/plans/2026-10-04-declarative-ui-tui.md` first: it fixes the branch, the 18-commit history, the +`wip()` convention and the squash every group ends with. Parts 0–9 have landed: this part produces commits +17 (`examples/forms: …`) and 18 (`forms, examples: retire the QML renderer and the Qt client stack`), then hands +over to the master plan's "Finishing the branch". + +## Global Constraints + +- Everything in Part 1's "Global Constraints" applies unchanged (SPDX first line, `#pragma once`, naming, + identifier length, no history or issue numbers in comments and docs, full Doxygen under `include/morph`, + `-Weverything -Werror`, clang-tidy clean, the sign-off trailer). +- clang-tidy's `readability-identifier-length` holds in every code block, tests included: no parameter, lambda + parameter or local shorter than three characters except `i j k x y n N fn cb op` (parameters) and + `i j k x y lk cb op fn` (variables); data members are exempt. Headers under `include/**` and `examples/forms/app/**` + use `.at()` or iteration, never an unchecked `operator[]` on a container. +- `examples/forms/app/` is toolkit-free: target `forms_app` links `morph::morph` and `morph_ladder_app_common` + (Part 6, Qt-free by construction) and nothing else, so an include of `morph/tui/…`, `morph/qt_quick/…` or a Qt + header there fails to build (spec 4 §7 rule 1). +- Views are bindings only; every conditional and format lives in a session, a `Computed` or a plain function the + view binds (spec 4 §7 rule 2). +- Client code lives in `lab::client` and exposes the contract's + `[[nodiscard]] std::unique_ptr lab::client::makeApplication(ui::AppContext& ctx, + examples::AppEnvironment const& env)`. The demo's one knob, `--typed`, is not an `AppEnvironment` field, so a + second overload takes it as a third argument and `ui/main.cpp` passes `entranceFromArgs(argc, argv)` — the one + line by which its `main` differs from spec 4 §3's shape (stated in the `forms-demo` squash body). +- `examples/forms` is not a ladder rung: it is configured from the root `CMakeLists.txt`'s "Demo executable" + section, before the Tests section and before the "Example applications on an injected frontend" block that adds + `examples/common/app` (Part 6). It therefore names `morph_test_main`, `morph_ladder_app_common` and + `morph_example_testkit` by their plain target names, which CMake resolves at generate time, as `examples/bank` + does; Part 6's root block adds `examples/common/app` whenever `examples/forms` exists (every native configure + with examples on). The app's own tests wait the way the framework's suite does — + `morph::testing::waitUntil` over a `StepExecutor` owner (`tests/test_support.hpp`), never a sleep — and the + application test uses Part 6's `FakeAppContext` and `pumpUntil`. +- Smoke tests run in a binary of their own whose `main` is `morph_test_main` (`forms_app_smoke_tests`), because + the Qt Quick frontend constructs its own application object (Part 6, "The frontend smoke harness"). +- `ui::testing::RecordingBackend` is read exactly as Part 2 writes it: kinds `Text`, `Button`, `TextInput`, + `Select`, `Menu`, `Table`, `Dialog`, `Busy`, a stack as `Column`/`Row` by its axis (so a `ui::forEach` mounts as a + `Column`), a `Switch` as `Slot`; a prop is its setter's name without `set`, first letter lower-cased (`text`, + `label`, `items`, `enabled`, `open`, `title`), a bool `"true"`/`"false"`, a `Menu`'s `items` `"[A,B]"`; the mount + skips a `Common` setter whose value is the constant default and always calls it for a binding; a `Dialog`'s content + exists only while it is open; `chooseIndex` on a `Menu` runs that item's action; `find` returns the lowest live id. +- The Part 5 names this part consumes beyond the interface contract are listed once, in Task 2's Interfaces block, + exactly as Part 5's plan declares them; Task 2 Step 0 confirms them against the headers. +- `examples/forms` gets no `CHANGELOG.md` line (CONTRIBUTING: example-only). The `retire` commit gets one + "Removed" entry naming every replacement. +- A defect found in `include/morph` or `src` outside this part's scope is filed per `AGENTS.md`; one found in the + forms engine *by this part's tests* is fixed in the forms-engine headers in the same group, with an engine test + beside it, and named in that group's squash body. +- Commits end with `Signed-off-by: Christian Parpart `. + +## Review Focus + +1. **The wizard's Next is enabled before step one's submission succeeded** — the step gate is the reply, not the + edit (Task 3 test "Intake wizard: Next waits for RegisterSample's reply"). +2. **A Bind prefill into a `Choice` whose options were fetched before the referenced row existed** — the + `WizardSession` builds every step's `FormSession` up front, so step two's `sampleId` options (`ListSamples`) are + answered before `RegisterSample` creates sample 100. Part 5's `clearStaleSelection` clears a selection that is + not among options *answered for the current parents*; the independent query's key never changes, so without a + re-fetch on step entry the prefill is cleared at once (Part 5 Task 14b engine test "entering a step refetches its Choice + options…", Task 3 test "Intake wizard: the prefilled sample id survives step two's options loading"). +3. **The typed and runtime entrances disagree on the wire** — same input, different body bytes (Task 5 test + "both entrances build the same ComputeDryDensity body"). +4. **A confirm-guarded collection action that runs without confirmation** — dismissing the dialog must issue + nothing (Task 4 test "Samples: Delete asks first, and dismissing deletes nothing"). +5. **Leaving a screen and coming back resets it** — the app shell remounts the screen's view, but its session + (the wizard's step, a form's drafts) must outlive the view (Task 3 test "Intake wizard: the step survives + leaving the screen and coming back"). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `examples/forms/lab_views.hpp` (new) | `SamplesView` and its `BRIDGE_REGISTER_VIEW`, split out of `lab_schemas.hpp` so `LabApp` can name it | +| `examples/forms/lab_wizard.hpp` | `IntakeWizard`; `LabApp` gains the `Samples` menu entry and `ViewScreen<"samples", SamplesView>` | +| `examples/forms/lab_schemas.hpp` | The served documents (`schemasJson`, `wizardSchemasJson`, `appSchemaJson`, `viewsJson`) | +| `examples/forms/app/lab_documents.{hpp,cpp}` | `LabDocuments` (the four JSON documents as text), `memberJson`, `lookupIn`, `sourcesOf` (the shell's `AppShellSources`) | +| `examples/forms/app/lab_link.hpp` | `LabLink`: the one `BridgeHandler`, and `forms::handlerSubmitter`/`handlerChoiceFetcher` over it | +| `examples/forms/app/typed_lab_forms.{hpp,cpp}` | `TypedLabForms`: the two `forms::Form` screens and their typed reply lines | +| `examples/forms/app/lab_shell.{hpp,cpp}` | `Entrance`, `entranceFromArgs`, `kLabAppId`, `parseShell`, `LabShell` (the `AppShellSession`; the typed entrance's shell view) | +| `examples/forms/app/lab_application.{hpp,cpp}` | `LabApplication` (`ui::Application`) and both `makeApplication` overloads | +| `examples/forms/ui/main.cpp` (new) | The composition root: environment, frontend selection, the factory | +| `examples/forms/tests/*` (new) | `lab_test_support.hpp`, `.clang-tidy`, schema, shell, wizard, collection, typed and application tests; `smoke/test_lab_frontend_smoke.cpp` | +| `examples/forms/CMakeLists.txt` | `forms_app`, `morph_forms_app`, `forms_app_tests`, `forms_app_smoke_tests`; the CLI targets unchanged | +| `examples/forms/gui_qml/` | Deleted | +| `CMakeLists.txt` (root) | the QML-renderer comments | +| `examples/forms/README.md`, `README.md`, `docs/ARCHITECTURE.md` | The demo's description | +| `src/qt/forms/`, `include/morph/qt/forms/`, `include/morph/qt/bridge/` | Deleted | +| `CMakeLists.txt`, `cmake/morphConfig.cmake.in`, `CMakePresets.json` | Option, targets, install components, header guard | +| `scripts/check_forms_qml_install.sh` | Deleted | +| `scripts/test_check_install_export.sh`, `scripts/check_coverage_objects.sh`, `scripts/coverage.sh` | References to removed targets | +| `.github/workflows/ci.yml`, `nightly-slow-checks.yml`, `wasm-ladder.yml` | `MORPH_BUILD_FORMS_QML`, the forms-QML install job, the AUTOMOC step, the `gui_wasm` fallback | +| `examples/common/gui/`, `examples/common/testkit/qml_surface.hpp`, `testkit_src/qml_surface.cpp`, five self-tests | Deleted | +| `examples/common/CMakeLists.txt`, `testkit/testkit_main.cpp`, `testkit/{pump,client_pool,backend_rig,journey}.hpp` | The remaining testkit, Qt-free wording | +| `cmake/morph_add_rung.cmake` | Old conventions and the `src/headless` block removed; a refusal for a leftover old client directory | +| `examples/TESTING.md`, `examples/IMPLEMENTATION.md`, `examples/LADDER.md` | The toolkit-free app on an injected frontend | +| `docs/spec/forms/*.md`, `docs/GETTING-STARTED.md`, `docs/todo.md`, `docs/spec/offline/offline.md`, `CONTRIBUTING.md`, comments in `include/`, `tests/`, `cmake/`, `examples/*/.clang-tidy`, `codecov.yml` | Every remaining reference | +| `CHANGELOG.md` | `[Unreleased]` → Removed | + +## Build and test commands + +```bash +# forms-demo group: both frontends (spec 4 §6), examples on (the default) +cmake -S . -B build/forms -G Ninja -DCMAKE_BUILD_TYPE=Debug \ + -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON +cmake --build build/forms --target morph_tests forms_app_tests forms_app_smoke_tests morph_forms_app morph_forms_demo +./build/forms/tests/morph_tests "[forms-engine][wizard]" +ctest --test-dir build/forms -L forms-demo --output-on-failure +ctest --test-dir build/forms -R 'forms_html_math|forms_repl_roundtrip' --output-on-failure + +# the same tree with no frontend: the library and its tests must still build (spec 4 §6) +cmake -S . -B build/forms-nofe -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build/forms-nofe --target forms_app_tests && ctest --test-dir build/forms-nofe -L forms-demo + +# retire group: the master plan's build/all, now without MORPH_BUILD_FORMS_QML +cmake -S . -B build/all -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all \ + -DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_NET=ON +cmake --build build/all && QT_QPA_PLATFORM=offscreen ctest --test-dir build/all --output-on-failure +``` + +Every configure prints `morph: warnings: ... strict=ON`; if it does not, stop and say so (CONTRIBUTING). + +--- + +## Group `forms-demo` — `examples/forms: one app, any frontend, runtime and typed forms` + +### Task 1: `LabApp` routes to the samples view + +**Files:** +- Create: `examples/forms/lab_views.hpp` +- Modify: `examples/forms/lab_schemas.hpp` — remove `SamplesView`, its `using` and its `BRIDGE_REGISTER_VIEW`; + include `lab_views.hpp`; the `@file` comment +- Modify: `examples/forms/lab_wizard.hpp` — include `lab_views.hpp`; `LabApp`; the `@file` comment +- Modify: `examples/forms/CMakeLists.txt` — add the `forms_app_tests` block after the CLI's demo-level tests +- Create: `examples/forms/tests/.clang-tidy` (a copy of `examples/bank/tests/.clang-tidy`) +- Test: `examples/forms/tests/test_lab_app_schema.cpp` + +**Interfaces:** +- Consumes: `morph::app::ViewScreen` (Part 5, `include/morph/forms/app.hpp`, Task 15: `id()`, `kind()` = `"view"`, `ref()` = + `views::ViewTraits::typeId()`); `morph::app::appSchemaJson()` (`include/morph/forms/app.hpp:123`); + `morph::views::describeAction`, `BindEntry`, `ActionScope`, `BRIDGE_REGISTER_VIEW` (`include/morph/forms/views.hpp`). +- Produces: `lab::SamplesView` in `lab_views.hpp`; `LabApp` with menu `Density, Measure, Intake, Samples` and + screens `density` (form), `measure` (form), `intake` (wizard), `samples` (view → `SamplesView`). + +- [ ] **Step 1: Write the failing test** + +Create `examples/forms/tests/test_lab_app_schema.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include "lab_schemas.hpp" + +TEST_CASE("LabApp: Samples is a menu entry routed to a view screen", "[forms-demo]") { + std::string const app = lab::appSchemaJson(); + CHECK(app.contains(R"({"label":"Samples","screen":"samples"})")); + CHECK(app.contains(R"("samples":{"kind":"view","ref":"SamplesView"})")); +} + +TEST_CASE("LabApp: the menu keeps its order", "[forms-demo]") { + std::string const app = lab::appSchemaJson(); + auto const density = app.find(R"("screen":"density")"); + auto const measure = app.find(R"("screen":"measure")"); + auto const intake = app.find(R"("screen":"intake")"); + auto const samples = app.find(R"("screen":"samples")"); + REQUIRE(samples != std::string::npos); + CHECK(density < measure); + CHECK(measure < intake); + CHECK(intake < samples); +} + +TEST_CASE("lab::viewsJson: SamplesView keeps its query and its actions", "[forms-demo]") { + std::string const views = lab::viewsJson(); + CHECK(views.contains(R"("v-query":"ListSamples")")); + CHECK(views.contains(R"("action":"EditSample")")); + CHECK(views.contains(R"("action":"DeleteSample")")); + CHECK(views.contains(R"("action":"CreateSample")")); +} +``` + +Copy the clang-tidy configuration the bank suite uses (it subtracts only `bugprone-chained-comparison`, which +Catch2's `REQUIRE(a == b)` expansion trips): + +```bash +cp examples/bank/tests/.clang-tidy examples/forms/tests/.clang-tidy +``` + +In `examples/forms/CMakeLists.txt`, after the closing `endif()` of the existing `if(MORPH_BUILD_TESTS)` block that +registers `forms_html_math` and `forms_repl_roundtrip`, add: + +```cmake +# The app's own tests: headless, on ui::testing::RecordingBackend, over an in-process LabModel. Registered like +# examples/bank's suite: this directory is configured before the root CMakeLists.txt's "Tests" section, so +# morph_test_main is named by its plain target name, which CMake resolves at generate time. +if(MORPH_BUILD_TESTS) + find_package(Catch2 3 CONFIG QUIET) + if(NOT Catch2_FOUND) + message(WARNING "Catch2 not found; forms_app_tests will not be built.") + else() + add_executable(forms_app_tests + tests/test_lab_app_schema.cpp + ) + target_include_directories(forms_app_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/tests + ${PROJECT_SOURCE_DIR}/tests) + target_link_libraries(forms_app_tests PRIVATE morph::morph morph_test_main) + target_compile_features(forms_app_tests PRIVATE cxx_std_23) + apply_warnings(forms_app_tests) + apply_bigobj(forms_app_tests) + if(DEFINED AF_SANITIZER) + apply_sanitizers(forms_app_tests ${AF_SANITIZER}) + endif() + if(AF_COVERAGE) + apply_coverage(forms_app_tests TEST) + endif() + list(APPEND CMAKE_MODULE_PATH ${Catch2_DIR}) + include(Catch) + catch_discover_tests(forms_app_tests + DISCOVERY_MODE PRE_TEST + PROPERTIES LABELS forms-demo) + endif() +endif() +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cmake -S . -B build/forms -G Ninja -DCMAKE_BUILD_TYPE=Debug \ + -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON +cmake --build build/forms --target forms_app_tests +./build/forms/examples/forms/forms_app_tests "[forms-demo]" +``` + +Expected: FAIL — "LabApp: Samples is a menu entry routed to a view screen" (both `CHECK`s) and "LabApp: the menu +keeps its order" (`REQUIRE( samples != std::string::npos )`); the `viewsJson` case passes. + +- [ ] **Step 3: Implement** + +Create `examples/forms/lab_views.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// The demo's view descriptors (docs/spec/forms/views.md). `SamplesView` lists `ListSamples`' rows, opens +/// `EditSample` for a row, deletes a row behind a confirmation, and creates a new sample. Split from +/// lab_schemas.hpp so `LabApp` (lab_wizard.hpp) can route a menu entry to it. + +#include +#include +#include + +#include "lab_model.hpp" + +namespace lab { + +/// @brief The samples list/master-detail screen. +/// +/// `kEditBind` binds only the row key, as views.md's `v-rowAction` example does: the editor opens with `name` +/// blank, so `EditSample` — which submits automatically once ready — runs only after the user types a name. +struct SamplesView { + using kind = morph::views::CollectionView; + using query = ListSamples; + + static constexpr std::string_view title = "Samples"; + + static constexpr std::array kEditBind{ + morph::views::BindEntry{.actionField = "id", .rowField = "id"}, + }; + static constexpr auto rowAction = + morph::views::describeAction({}, morph::views::ActionScope::Row, kEditBind); + + static constexpr std::array kDeleteBind{ + morph::views::BindEntry{.actionField = "id", .rowField = "id"}, + }; + static constexpr std::array actions{ + morph::views::describeAction("Delete", morph::views::ActionScope::Row, kDeleteBind, true), + morph::views::describeAction("New", morph::views::ActionScope::Collection), + }; +}; + +} // namespace lab + +using lab::SamplesView; + +BRIDGE_REGISTER_VIEW(SamplesView, "SamplesView") +``` + +In `examples/forms/lab_schemas.hpp`: delete the `SamplesView` struct with its doc comment, the +`using lab::SamplesView;` line and the `BRIDGE_REGISTER_VIEW(SamplesView, "SamplesView")` line; add +`#include "lab_views.hpp"` after `#include "lab_model.hpp"`; drop `#include ` (no longer used there); and +replace the `@file` comment with: + +```cpp +/// @file +/// The documents every forms client is served, from one source: `{actionType: schema}` for the standalone +/// action forms, `{wizardId: schema}`, the `app-*` document and `{viewId: schema}`. The console demo prints +/// and embeds the first; `morph_forms_app` reads all four as JSON text, the way a remote client would read them +/// from a describe endpoint. +``` + +In `examples/forms/lab_wizard.hpp`: add `#include "lab_views.hpp"` after `#include "lab_model.hpp"`; replace +the `@file` comment's last sentence ("`LabApp` is the app-shell descriptor the QML reference renderer's +AppShell.qml loads as its navigation root.") with "`LabApp` is the app-shell descriptor `morph_forms_app` +renders through `forms::appShellView`."; and replace `LabApp` and its doc comment with: + +```cpp +/// @brief The demo's app shell: a density calculator, a standalone measurement form, the intake wizard and the +/// samples collection. +using LabApp = morph::app::App< + "Lab console", + std::tuple, morph::app::MenuEntry<"Measure", "measure">, + morph::app::MenuEntry<"Intake", "intake">, morph::app::MenuEntry<"Samples", "samples">>, + std::tuple, + morph::app::FormScreen<"measure", RecordMeasurement>, + morph::app::WizardScreen<"intake", IntakeWizard>, morph::app::ViewScreen<"samples", SamplesView>>>; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake --build build/forms --target forms_app_tests morph_forms_demo +./build/forms/examples/forms/forms_app_tests "[forms-demo]" +ctest --test-dir build/forms -R 'forms_html_math|forms_repl_roundtrip' --output-on-failure +``` + +Expected: 3 test cases pass; the two CLI tests pass unchanged (they read `schemasJson()`, which did not change). + +Mutation check: in `LabApp`, delete `morph::app::ViewScreen<"samples", SamplesView>` from the screens tuple +(keep the menu entry). Rebuild: "LabApp: Samples is a menu entry routed to a view screen" fails on its second +`CHECK`. Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/forms/lab_views.hpp examples/forms/lab_schemas.hpp examples/forms/lab_wizard.hpp \ + examples/forms/CMakeLists.txt examples/forms/tests/.clang-tidy examples/forms/tests/test_lab_app_schema.cpp +git commit -m "wip(forms-demo): route LabApp's Samples entry to SamplesView + +Signed-off-by: Christian Parpart " +``` + +--- +### Task 2: The runtime shell — form screens from the served documents + +**Files:** +- Create: `examples/forms/app/lab_documents.hpp`, `examples/forms/app/lab_documents.cpp` +- Create: `examples/forms/app/lab_link.hpp` +- Create: `examples/forms/app/lab_shell.hpp`, `examples/forms/app/lab_shell.cpp` +- Create: `examples/forms/tests/lab_test_support.hpp` +- Modify: `examples/forms/CMakeLists.txt` — the `forms_app` library before the tests block; `forms_app_tests` + sources and link +- Test: `examples/forms/tests/test_lab_shell_view.cpp` + +**Interfaces:** +- Consumes (contract): `reactive::Runtime` (Part 1); `ui::Node`, `ui::Mounted`, `ui::testing::RecordingBackend` + (Part 2); `forms::FormSession` (`field`, `body`, `lastReply`, `lastError`), `forms::Submitter`, + `forms::ChoiceFetcher`, `forms::SchemaError`, `forms::appShellView`, `forms::handlerSubmitter`, + `forms::handlerChoiceFetcher` (Part 5); `bridge::Bridge`, `bridge::BridgeHandler(Bridge&, IExecutor*)` + (`include/morph/core/bridge.hpp`); `morph::testing::StepExecutor`, `waitUntil`, `WaitBudget` + (`tests/test_support.hpp`). +- Consumes (Part 5, beyond the contract, as Part 5's plan declares them — Step 0 confirms each): + ```cpp + namespace morph::forms { + // form_session.hpp (Task 6) + class FieldState { public: [[nodiscard]] reactive::Signal& text() noexcept; /* … */ }; + // collection.hpp (Task 13) + using SchemaLookup = std::function(std::string_view actionType)>; + // app_shell.hpp (Task 15) + enum class ScreenKind : std::uint8_t { Form, Wizard, View }; + struct AppMenuEntry { std::string label{}; std::string screen{}; }; + struct AppScreen { std::string id{}; ScreenKind kind = ScreenKind::Form; std::string ref{}; }; + class AppShellModel { public: + [[nodiscard]] static std::expected fromSchema(std::string_view appId, + std::string_view appJson); + id(), title(), menu() -> std::span, screens() -> std::span, + find(std::string_view) -> AppScreen const*; }; + struct AppShellSources { SchemaLookup actions{}; SchemaLookup wizards{}; SchemaLookup views{}; }; + class AppShellSession { public: // starts on the first menu entry's screen + AppShellSession(reactive::Runtime&, AppShellModel, AppShellSources, Submitter, ChoiceFetcher, + FormSessionOptions = {}); + model(), current() -> std::string const&, select(std::string_view), title(), menuLabel(std::size_t), + form(std::string_view screenId) -> FormSession*, wizard(…) -> WizardSession*, + collection(…) -> CollectionSession*; }; // each made the first time it is asked for, then kept + } + ``` + `AppShellSession` builds every screen's session itself, from `AppShellSources`, and `appShellView` renders the + session it made (`formView`, `wizardView` or `collectionView`), or the error text + `this screen's document is not available: ` when the source has no document for it. The runtime entrance is + therefore nothing but the right sources and submitter; the typed entrance (Task 5) is a shell view of the app's + own over the same session, so Part 5 needs no renderer hook. +- Produces (namespace `lab::client`): `LabDocuments{app, schemas, wizards, views}`, `labDocuments()`, + `memberJson(objectJson, key)`, `lookupIn(objectJson) -> forms::SchemaLookup`, + `sourcesOf(LabDocuments const&) -> forms::AppShellSources` (actions only here; Tasks 3–4 add the rest); + `LabLink(bridge, callbacks)` with `handler()`, `submitter()`, `choices()`; `kLabAppId`, `parseShell(appJson)`, + `LabShell(runtime, bridge, callbacks)` with `session()`, `view()`; test support `lab::testing::LabRig`, `menuOf`, + `shows`, `withProp`, `the`, `kDensity…kSamples`. + +- [ ] **Step 0: Confirm the Part 5 names against the headers** + +```bash +grep -n 'enum class ScreenKind\|struct AppScreen\|struct AppShellSources\|class AppShellModel\|class AppShellSession\|AppShellSession(reactive\|fromSchema(std::string_view appId\|FormSession\* form(\|WizardSession\* wizard(\|CollectionSession\* collection(' \ + include/morph/forms/engine/app_shell.hpp +grep -n 'using SchemaLookup' include/morph/forms/engine/collection.hpp +grep -n 'Signal& text()' include/morph/forms/engine/form_session.hpp +grep -n 'Submitter handlerSubmitter\|ChoiceFetcher handlerChoiceFetcher' include/morph/forms/engine/handler_submitter.hpp +``` + +Expected: every name above, with the shape the Interfaces block gives. This is a confirmation: Part 5's plan is +the source the block was written from. A difference means Part 5 deviated in its implementation — follow the +header, and list the difference in Task 8's squash body. + +- [ ] **Step 1: Write the failing test** + +Create `examples/forms/tests/lab_test_support.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// The forms demo's test rig: a reactive runtime on a hand-stepped owner, and an in-process LabModel behind a +/// real worker pool. Replies reach the owner only when a test drives it, so "before the reply" is a state a test +/// can stand in rather than race. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace lab::testing { + +using morph::ui::testing::RecordingBackend; + +/// @brief Menu positions of `LabApp`'s screens. +inline constexpr std::size_t kDensity = 0; +/// @brief See `kDensity`. +inline constexpr std::size_t kMeasure = 1; +/// @brief See `kDensity`. +inline constexpr std::size_t kIntake = 2; +/// @brief See `kDensity`. +inline constexpr std::size_t kSamples = 3; + +/// @brief Owner executor, runtime, worker pool and bridge, destroyed bridge-first so no model runs on a dead pool. +struct LabRig { + /// @brief The runtime's and the bridge's owner; runs only when a test drives it. + morph::testing::StepExecutor owner; + /// @brief The reactive runtime every session in the test binds to. + morph::reactive::Runtime runtime{owner}; + /// @brief Where LabModel's actions run. + morph::exec::ThreadPoolExecutor pool{2}; + /// @brief The in-process bridge; replies are delivered on `owner`. + morph::bridge::Bridge bridge{std::make_unique(pool), owner}; + + /// @brief Runs the owner's queue until @p done holds, delivering replies as the pool produces them. + /// @tparam Pred A callable returning `bool`. + /// @param done The condition to wait for. + /// @return `true` if @p done held before the default wait budget ran out. + template + [[nodiscard]] bool driveUntil(Pred done) { + return morph::testing::waitUntil([&] { + owner.runAll(); + return done(); + }); + } + + /// @brief Delivers replies until the owner has had nothing to run for @p quiet, so a claim made after it is + /// about the settled state rather than the moment before a late reply. + /// @param quiet How long the owner's queue must stay empty. + void settle(std::chrono::milliseconds quiet = std::chrono::milliseconds{250}) { + while (morph::testing::waitUntil([&] { return owner.pending() > 0; }, morph::testing::WaitBudget{quiet})) { + owner.runAll(); + } + } +}; + +/// @brief Whether a `Text` widget shows exactly @p text. +/// @param backend The backend to search. +/// @param text The text to look for. +/// @return `true` if one exists. +[[nodiscard]] inline bool shows(RecordingBackend const& backend, std::string_view text) { + return backend.find("Text", "text", text).has_value(); +} + +/// @brief Every widget of @p kind whose @p prop equals @p value, in creation order. +/// @param backend The backend to search. +/// @param kind The widget kind. +/// @param prop The prop name. +/// @param value The prop value. +/// @return The matching widget ids. +[[nodiscard]] inline std::vector withProp(RecordingBackend const& backend, std::string_view kind, + std::string_view prop, std::string_view value) { + std::vector found; + for (int const widgetId : backend.all(kind)) { + if (backend.prop(widgetId, prop) == value) { + found.push_back(widgetId); + } + } + return found; +} + +/// @brief The first widget of @p kind whose @p prop equals @p value; fails the test when there is none. +/// @param backend The backend to search. +/// @param kind The widget kind. +/// @param prop The prop name. +/// @param value The prop value. +/// @return The widget id. +[[nodiscard]] inline int the(RecordingBackend const& backend, std::string_view kind, std::string_view prop, + std::string_view value) { + auto const found = backend.find(kind, prop, value); + REQUIRE(found.has_value()); + return *found; +} + +/// @brief The app shell's one menu; fails the test when there is not exactly one. +/// @param backend The backend to search. +/// @return The menu's widget id. +[[nodiscard]] inline int menuOf(RecordingBackend const& backend) { + auto const menus = backend.all("Menu"); + REQUIRE(menus.size() == 1); + return menus.front(); +} + +} // namespace lab::testing +``` + +Create `examples/forms/tests/test_lab_shell_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +#include "app/lab_documents.hpp" +#include "app/lab_shell.hpp" +#include "lab_schemas.hpp" +#include "lab_test_support.hpp" + +using lab::testing::LabRig; +using lab::testing::menuOf; +using lab::testing::shows; +using morph::ui::Mounted; +using morph::ui::testing::RecordingBackend; + +TEST_CASE("Lab shell: the menu lists the four screens and opens on the first", "[forms-demo]") { + LabRig rig; + lab::client::LabShell shell{rig.runtime, rig.bridge, rig.owner}; + RecordingBackend backend; + Mounted const mounted{rig.runtime, backend, shell.view()}; + rig.owner.runAll(); + + CHECK(backend.prop(menuOf(backend), "items") == "[Density,Measure,Intake,Samples]"); + CHECK(shows(backend, "Lab console")); + CHECK(shell.session().current() == "density"); + CHECK(shows(backend, "Oven-dry mass of the specimen")); // ComputeDryDensity.massDry's help text +} + +TEST_CASE("Lab shell: choosing an entry swaps the screen and tears the old one down", "[forms-demo]") { + LabRig rig; + lab::client::LabShell shell{rig.runtime, rig.bridge, rig.owner}; + RecordingBackend backend; + Mounted const mounted{rig.runtime, backend, shell.view()}; + rig.owner.runAll(); + + backend.chooseIndex(menuOf(backend), lab::testing::kMeasure); + rig.owner.runAll(); + CHECK(shell.session().current() == "measure"); + CHECK(shows(backend, "Sample under test")); // RecordMeasurement.sampleId's help text + CHECK_FALSE(shows(backend, "Oven-dry mass of the specimen")); +} + +TEST_CASE("lab::client::parseShell: LabApp's document reads; one the engine cannot read is refused", "[forms-demo]") { + auto const model = lab::client::parseShell(lab::appSchemaJson()); + CHECK(model.id() == "LabApp"); + CHECK(model.menu().size() == 4); + CHECK_THROWS_AS(lab::client::parseShell("[]"), std::runtime_error); + CHECK_THROWS_AS(lab::client::parseShell(R"({"app-screens":{"chart":{"kind":"chart","ref":"DensityChart"}}})"), + std::runtime_error); +} + +TEST_CASE("Lab shell: the density form submits through the in-process LabModel", "[forms-demo]") { + LabRig rig; + lab::client::LabShell shell{rig.runtime, rig.bridge, rig.owner}; + morph::forms::FormSession* const density = shell.session().form("density"); + REQUIRE(density != nullptr); + + density->field("massDry").text().set("2650.5"); + density->field("volume").text().set("1"); + REQUIRE(rig.driveUntil([&] { return density->lastReply().has_value(); })); + CHECK(density->lastError() == nullptr); + CHECK(density->lastReply()->contains(R"("num":5301)")); // 2650.5 kg/m³ is 5301/2, exactly + CHECK(density->lastReply()->contains(R"("den":2)")); +} + +TEST_CASE("lab::client::memberJson and sourcesOf: each screen's document, read from the served text", "[forms-demo]") { + CHECK(lab::client::memberJson(R"({"A":{"x":1},"B":[2]})", "A") == std::optional{R"({"x":1})"}); + CHECK(lab::client::memberJson(R"({"A":{"x":1}})", "C") == std::nullopt); + CHECK(lab::client::memberJson("[1,2]", "A") == std::nullopt); + CHECK(lab::client::memberJson("not json", "A") == std::nullopt); + + auto const sources = lab::client::sourcesOf(lab::client::labDocuments()); + REQUIRE(sources.actions); + CHECK(sources.actions("ComputeDryDensity").has_value()); + CHECK_FALSE(sources.actions("ListSamples").has_value()); // a query, never a form of its own +} +``` + +In `examples/forms/CMakeLists.txt`, change the `add_executable(forms_app_tests …)` source list to: + +```cmake + add_executable(forms_app_tests + tests/test_lab_app_schema.cpp + tests/test_lab_shell_view.cpp + ) +``` + +and its link line to `target_link_libraries(forms_app_tests PRIVATE forms_app morph_test_main)`. + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cmake --build build/forms --target forms_app_tests +``` + +Expected: FAIL to configure or build — `forms_app` is not a target and the app headers do not exist +(`fatal error: 'app/lab_documents.hpp' file not found`). + +- [ ] **Step 3: Implement** + +Create `examples/forms/app/lab_documents.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// The four JSON documents a forms client is served, held as text, and the lookups the app shell reads each +/// screen's document through. The runtime entrance builds every screen from these strings and nothing else. + +#include +#include +#include +#include + +namespace lab::client { + +/// @brief The documents a forms client needs to build the demo's screens. +struct LabDocuments { + std::string app; ///< The `app-*` document: title, menu, screens. + std::string schemas; ///< `{actionType: schema}` for every standalone action form. + std::string wizards; ///< `{wizardId: schema}` for every wizard. + std::string views; ///< `{viewId: schema}` for every view. +}; + +/// @brief The documents `lab_schemas.hpp` emits for the lab model. +/// @return All four documents. +[[nodiscard]] LabDocuments labDocuments(); + +/// @brief One member of a JSON object, re-serialised as JSON text. +/// @param objectJson A JSON object. +/// @param key The member's name. +/// @return The member's JSON text; `nullopt` when @p objectJson is not a JSON object or has no such member. +[[nodiscard]] std::optional memberJson(std::string_view objectJson, std::string_view key); + +/// @brief A lookup into one `{id: document}` object, as an app-shell source reads it. +/// @param objectJson The object; the lookup keeps its own copy. +/// @return A lookup returning `memberJson(objectJson, id)`. +[[nodiscard]] morph::forms::SchemaLookup lookupIn(std::string objectJson); + +/// @brief Where the app shell finds each screen's document: the served text, through `lookupIn`. +/// @param documents The served documents. +/// @return The sources. +[[nodiscard]] morph::forms::AppShellSources sourcesOf(LabDocuments const& documents); + +} // namespace lab::client +``` + +Create `examples/forms/app/lab_documents.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "app/lab_documents.hpp" + +#include +#include +#include +#include +#include + +#include "lab_schemas.hpp" + +namespace lab::client { + +LabDocuments labDocuments() { + return LabDocuments{ + .app = lab::appSchemaJson(), + .schemas = lab::schemasJson(), + .wizards = lab::wizardSchemasJson(), + .views = lab::viewsJson(), + }; +} + +std::optional memberJson(std::string_view objectJson, std::string_view key) { + // generic_u64 keeps integers beyond 2^53 exact, which a schema's exact bounds can carry. + glz::generic_u64 dom{}; + if (glz::read_json(dom, objectJson) || !dom.is_object()) { + return std::nullopt; + } + auto const& members = dom.get_object(); + auto const found = members.find(std::string{key}); + if (found == members.end()) { + return std::nullopt; + } + std::string text; + if (glz::write_json(found->second, text)) { + return std::nullopt; + } + return text; +} + +morph::forms::SchemaLookup lookupIn(std::string objectJson) { + return [document = std::move(objectJson)](std::string_view key) { return memberJson(document, key); }; +} + +morph::forms::AppShellSources sourcesOf(LabDocuments const& documents) { + return morph::forms::AppShellSources{ + .actions = lookupIn(documents.schemas), + .wizards = {}, + .views = {}, + }; +} + +} // namespace lab::client +``` + +Create `examples/forms/app/lab_link.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// `LabLink`: the one handler every screen talks through. Under `LocalBackend` one handler is one LabModel +/// instance, so a sample the wizard registers is the one the measurement form's sample picker lists and the +/// Samples table shows — which is why the forms submit through `forms::handlerSubmitter` over this handler rather +/// than through `forms::bridgeSubmitter`, which would keep handlers (and so model instances) of its own. + +#include +#include +#include +#include + +#include "lab_model.hpp" + +namespace lab::client { + +/// @brief A `BridgeHandler` with the forms engine's submit and options entry points over it. +class LabLink { +public: + /// @brief Registers one LabModel instance on @p bridge. + /// @param bridge The bridge the model lives behind. + /// @param callbacks Where replies are delivered: the runtime's owner. + LabLink(morph::bridge::Bridge& bridge, morph::exec::IExecutor& callbacks) + : _callbacks{&callbacks}, _handler{bridge, &callbacks} {} + + LabLink(LabLink const&) = delete; + LabLink& operator=(LabLink const&) = delete; + LabLink(LabLink&&) = delete; + LabLink& operator=(LabLink&&) = delete; + ~LabLink() = default; + + /// @brief The handler, for typed access (`forms::Form`). + /// @return The handler. + [[nodiscard]] morph::bridge::BridgeHandler& handler() noexcept { return _handler; } + + /// @brief Every submission, through this link's handler. + /// @return `forms::handlerSubmitter` over the handler; it must not outlive the link. + [[nodiscard]] morph::forms::Submitter submitter() { return morph::forms::handlerSubmitter(*_callbacks, _handler); } + + /// @brief Every `Choice`'s options, through this link's handler. + /// @return `forms::handlerChoiceFetcher` over the handler; it must not outlive the link. + [[nodiscard]] morph::forms::ChoiceFetcher choices() { + return morph::forms::handlerChoiceFetcher(*_callbacks, _handler); + } + +private: + morph::exec::IExecutor* _callbacks; + morph::bridge::BridgeHandler _handler; +}; + +} // namespace lab::client +``` + +Create `examples/forms/app/lab_shell.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// `LabShell`: the demo's controller — the link to LabModel and the app-shell session that navigates between the +/// screens and keeps each screen's session. Toolkit-free: it is mounted on a frontend by `LabApplication` and on +/// `ui::testing::RecordingBackend` by the tests. + +#include +#include +#include +#include +#include +#include + +#include "app/lab_documents.hpp" +#include "app/lab_link.hpp" + +namespace lab::client { + +/// @brief `LabApp`'s registered id (`BRIDGE_REGISTER_APP` in lab_wizard.hpp): the stem of its translation keys. +inline constexpr std::string_view kLabAppId = "LabApp"; + +/// @brief Parses the `app-*` document. +/// @param appJson The document. +/// @return The model, with id `kLabAppId`. +/// @throws std::runtime_error when the document does not parse. +[[nodiscard]] morph::forms::AppShellModel parseShell(std::string_view appJson); + +/// @brief The demo's controller: one LabModel link, and the shell session over the served documents. +class LabShell { +public: + /// @brief Opens the shell on the first menu entry; each screen's session is made when it is first shown. + /// @param runtime The runtime every session binds to. + /// @param bridge The bridge LabModel lives behind. + /// @param callbacks Where replies are delivered: @p runtime's owner. + LabShell(morph::reactive::Runtime& runtime, morph::bridge::Bridge& bridge, morph::exec::IExecutor& callbacks); + + LabShell(LabShell const&) = delete; + LabShell& operator=(LabShell const&) = delete; + LabShell(LabShell&&) = delete; + LabShell& operator=(LabShell&&) = delete; + ~LabShell() = default; + + /// @brief The app-shell session: the current screen, and every screen's session. + /// @return The session. + [[nodiscard]] morph::forms::AppShellSession& session() noexcept { return _session; } + + /// @brief The shell's view: the title, and the menu beside the current screen. + /// @return `forms::appShellView(session())`. + [[nodiscard]] morph::ui::Node view(); + +private: + LabShell(morph::reactive::Runtime& runtime, morph::bridge::Bridge& bridge, morph::exec::IExecutor& callbacks, + LabDocuments const& documents); + + LabLink _link; + morph::forms::AppShellSession _session; // after _link: its submitter routes through _link's handler +}; + +} // namespace lab::client +``` + +Create `examples/forms/app/lab_shell.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "app/lab_shell.hpp" + +#include +#include +#include +#include + +namespace lab::client { + +morph::forms::AppShellModel parseShell(std::string_view appJson) { + auto model = morph::forms::AppShellModel::fromSchema(kLabAppId, appJson); + if (!model) { + throw std::runtime_error{ + std::format("app document: {} (at '{}')", model.error().message, model.error().path)}; + } + return *std::move(model); +} + +LabShell::LabShell(morph::reactive::Runtime& runtime, morph::bridge::Bridge& bridge, morph::exec::IExecutor& callbacks) + : LabShell{runtime, bridge, callbacks, labDocuments()} {} + +LabShell::LabShell(morph::reactive::Runtime& runtime, morph::bridge::Bridge& bridge, morph::exec::IExecutor& callbacks, + LabDocuments const& documents) + : _link{bridge, callbacks}, + _session{runtime, parseShell(documents.app), sourcesOf(documents), _link.submitter(), _link.choices()} {} + +morph::ui::Node LabShell::view() { return morph::forms::appShellView(_session); } + +} // namespace lab::client +``` + +In `examples/forms/CMakeLists.txt`, before the first `if(MORPH_BUILD_TESTS)` block, add: + +```cmake +# ── forms_app: the demo's app library ──────────────────────────────────────── +# Toolkit-free by construction: it links morph and nothing else, so a frontend or Qt include in app/ does not +# build. The binary (ui/main.cpp) and the tests link it. +add_library(forms_app STATIC + app/lab_documents.cpp + app/lab_shell.cpp +) +target_include_directories(forms_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(forms_app PUBLIC morph::morph) +target_compile_features(forms_app PUBLIC cxx_std_23) +apply_warnings(forms_app) +apply_bigobj(forms_app) +if(DEFINED AF_SANITIZER) + apply_sanitizers(forms_app ${AF_SANITIZER}) +endif() +if(AF_COVERAGE) + apply_coverage(forms_app) +endif() +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake --build build/forms --target forms_app_tests +./build/forms/examples/forms/forms_app_tests "[forms-demo]" +``` + +Expected: 8 test cases pass. + +Mutation check: in `sourcesOf`, set `.actions = {}`. Rebuild: "Lab shell: the menu lists the four screens and opens +on the first" fails on `CHECK( shows(backend, "Oven-dry mass of the specimen") )` (the shell shows +`this screen's document is not available: density`), and "the density form submits…" fails at +`REQUIRE( density != nullptr )`. Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/forms/app examples/forms/tests/lab_test_support.hpp examples/forms/tests/test_lab_shell_view.cpp \ + examples/forms/CMakeLists.txt +git commit -m "wip(forms-demo): the runtime shell, form screens from the served documents + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 3: The intake wizard screen + +**Files:** +- Modify: `examples/forms/app/lab_documents.cpp` — `sourcesOf` serves the wizard documents +- Modify: `examples/forms/CMakeLists.txt` — add `tests/test_lab_wizard.cpp` to `forms_app_tests` +- Test: `examples/forms/tests/test_lab_wizard.cpp` + +**Interfaces:** +- Consumes: `forms::AppShellSession::select`, `wizard(screenId)`; `forms::WizardSession::current()`, + `step(std::size_t) -> FormSession&`; `forms::wizardView` (rendered by `appShellView`: a heading + `" (n / m)"`, the step title as muted text, the step's form in a `Switch`, `Back`/`Next` buttons whose + `enabled` is bound to `canBack()`/`canNext()`) (Part 5, Task 14); Part 5 Task 14b's re-fetch on step entry. +- Produces: `sourcesOf(documents).wizards`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/forms/tests/test_lab_wizard.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "app/lab_shell.hpp" +#include "lab_test_support.hpp" + +using lab::testing::LabRig; +using lab::testing::menuOf; +using lab::testing::shows; +using lab::testing::the; +using morph::ui::Mounted; +using morph::ui::testing::RecordingBackend; + +namespace { + +/// @brief The shell over a fresh LabModel, mounted, on the intake screen. +struct Intake { + explicit Intake(LabRig& rig) + : shell{rig.runtime, rig.bridge, rig.owner}, mounted{rig.runtime, backend, shell.view()} { + shell.session().select("intake"); + rig.owner.runAll(); + } + + lab::client::LabShell shell; + RecordingBackend backend; + Mounted mounted; // last: destroyed first, while the shell's sessions still live +}; + +} // namespace + +TEST_CASE("Intake wizard: Next waits for RegisterSample's reply", "[forms-demo]") { + LabRig rig; + Intake intake{rig}; + RecordingBackend& backend = intake.backend; + + CHECK(shows(backend, "New sample")); // step one's title + int const next = the(backend, "Button", "label", "Next"); + CHECK(backend.prop(next, "enabled") == "false"); + + auto const inputs = backend.all("TextInput"); + REQUIRE(inputs.size() == 1); // RegisterSample has one field, name + backend.edit(inputs.front(), "Core 9"); + CHECK(backend.prop(next, "enabled") == "false"); // edited, but no reply has been delivered + + REQUIRE(rig.driveUntil([&] { return backend.prop(next, "enabled") == "true"; })); + backend.click(next); + rig.owner.runAll(); + CHECK(shows(backend, "First measurement")); + CHECK_FALSE(shows(backend, "New sample")); +} + +TEST_CASE("Intake wizard: the prefilled sample id survives step two's options loading", "[forms-demo]") { + LabRig rig; + Intake intake{rig}; + RecordingBackend& backend = intake.backend; + + backend.edit(backend.all("TextInput").front(), "Core 9"); + int const next = the(backend, "Button", "label", "Next"); + REQUIRE(rig.driveUntil([&] { return backend.prop(next, "enabled") == "true"; })); + backend.click(next); + rig.settle(); + + // A fresh LabModel hands out 100 first; step two's options were answered before sample 100 existed. + morph::forms::WizardSession* const wizard = intake.shell.session().wizard("intake"); + REQUIRE(wizard != nullptr); + CHECK(wizard->current() == 1); + CHECK(wizard->step(1).field("sampleId").text().peek() == "100"); +} + +TEST_CASE("Intake wizard: Back keeps step one's draft", "[forms-demo]") { + LabRig rig; + Intake intake{rig}; + RecordingBackend& backend = intake.backend; + + backend.edit(backend.all("TextInput").front(), "Core 9"); + int const next = the(backend, "Button", "label", "Next"); + REQUIRE(rig.driveUntil([&] { return backend.prop(next, "enabled") == "true"; })); + backend.click(next); + rig.owner.runAll(); + backend.click(the(backend, "Button", "label", "Back")); + rig.owner.runAll(); + + CHECK(shows(backend, "New sample")); + CHECK(backend.find("TextInput", "text", "Core 9").has_value()); +} + +TEST_CASE("Intake wizard: the step survives leaving the screen and coming back", "[forms-demo]") { + LabRig rig; + lab::client::LabShell shell{rig.runtime, rig.bridge, rig.owner}; + RecordingBackend backend; + Mounted const mounted{rig.runtime, backend, shell.view()}; + rig.owner.runAll(); + + backend.chooseIndex(menuOf(backend), lab::testing::kIntake); + rig.owner.runAll(); + backend.edit(backend.all("TextInput").front(), "Core 9"); + REQUIRE(rig.driveUntil([&] { return backend.prop(the(backend, "Button", "label", "Next"), "enabled") == "true"; })); + backend.click(the(backend, "Button", "label", "Next")); + rig.owner.runAll(); + REQUIRE(shows(backend, "First measurement")); + + backend.chooseIndex(menuOf(backend), lab::testing::kDensity); + rig.owner.runAll(); + backend.chooseIndex(menuOf(backend), lab::testing::kIntake); + rig.owner.runAll(); + CHECK(shows(backend, "First measurement")); + CHECK_FALSE(shows(backend, "New sample")); +} +``` + +Add `tests/test_lab_wizard.cpp` to the `forms_app_tests` source list. + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cmake --build build/forms --target forms_app_tests +./build/forms/examples/forms/forms_app_tests "Intake wizard:*" +``` + +Expected: FAIL — every case fails at `the(backend, "Button", "label", "Next")` (`REQUIRE( found.has_value() )`): with +no wizard source the shell shows `this screen's document is not available: intake`. + +- [ ] **Step 3: Implement** + +In `examples/forms/app/lab_documents.cpp`, `sourcesOf`: replace `.wizards = {},` with +`.wizards = lookupIn(documents.wizards),`. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake --build build/forms --target forms_app_tests +./build/forms/examples/forms/forms_app_tests "[forms-demo]" +``` + +Expected: 12 test cases pass. + +Mutation check: delete the `session.refreshOptions();` line Part 5 Task 14b added to `WizardSession::next()`. Rebuild: +"Intake wizard: the prefilled sample id survives step two's options loading" fails with `"" == "100"` — the demo's +own test detects Review Focus 2, not only the engine's. Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/forms/app/lab_documents.cpp examples/forms/tests/test_lab_wizard.cpp examples/forms/CMakeLists.txt +git commit -m "wip(forms-demo): the intake wizard screen + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 4: The samples collection screen + +**Files:** +- Modify: `examples/forms/app/lab_documents.cpp` — `sourcesOf` serves the view documents +- Modify: `examples/forms/CMakeLists.txt` — add `tests/test_lab_collection.cpp` +- Test: `examples/forms/tests/test_lab_collection.cpp` + +**Interfaces:** +- Consumes: `forms::collectionView` as Part 5 Task 13 renders it, through `appShellView`: a heading with the + view's title and a `Button` per collection action (`New`); a `Table` whose row key is the row's `v-rowKey` value + as a string `ui::Key` and whose last cell holds `Open` and each row action's button (`Delete`); the editor as a + `Dialog` titled with the row action's type (`EditSample`); the confirm `Dialog` titled `Confirm` (`Are you + sure?`, `Yes`, `No`), whose `onDismiss` cancels; `RecordingBackend::activateRow`, `dismiss` (Part 2). +- Produces: `sourcesOf(documents).views`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/forms/tests/test_lab_collection.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "app/lab_shell.hpp" +#include "lab_test_support.hpp" + +using lab::testing::LabRig; +using lab::testing::shows; +using lab::testing::the; +using lab::testing::withProp; +using morph::ui::Mounted; +using morph::ui::testing::RecordingBackend; + +namespace { + +/// @brief The shell over a fresh LabModel (samples 1, 2 and 7), mounted, on the samples screen. +struct Samples { + explicit Samples(LabRig& rig) + : shell{rig.runtime, rig.bridge, rig.owner}, mounted{rig.runtime, backend, shell.view()} { + shell.session().select("samples"); + } + + lab::client::LabShell shell; + RecordingBackend backend; + Mounted mounted; // last: destroyed first, while the shell's sessions still live +}; + +} // namespace + +TEST_CASE("Samples: the table lists the seeded samples", "[forms-demo]") { + LabRig rig; + Samples samples{rig}; + RecordingBackend& backend = samples.backend; + + REQUIRE(rig.driveUntil([&] { return shows(backend, "Crushed aggregate 0/32"); })); + CHECK(backend.all("Table").size() == 1); + CHECK(shows(backend, "Proctor A")); + CHECK(shows(backend, "Proctor B")); +} + +TEST_CASE("Samples: New creates a sample and the list refetches", "[forms-demo]") { + LabRig rig; + Samples samples{rig}; + RecordingBackend& backend = samples.backend; + REQUIRE(rig.driveUntil([&] { return shows(backend, "Proctor A"); })); + + backend.click(the(backend, "Button", "label", "New")); + REQUIRE(rig.driveUntil([&] { return shows(backend, "New sample"); })); // CreateSample's default name +} + +TEST_CASE("Samples: Delete asks first, and dismissing deletes nothing", "[forms-demo]") { + LabRig rig; + Samples samples{rig}; + RecordingBackend& backend = samples.backend; + REQUIRE(rig.driveUntil([&] { return shows(backend, "Proctor A"); })); + + auto const deletes = withProp(backend, "Button", "label", "Delete"); + REQUIRE(deletes.size() == 3); // one per row; rows are in ListSamples' order, Proctor A first + backend.click(deletes.front()); + rig.owner.runAll(); + auto const open = withProp(backend, "Dialog", "open", "true"); + REQUIRE(open.size() == 1); + CHECK(backend.prop(open.front(), "title") == "Confirm"); + CHECK(shows(backend, "Are you sure?")); + + backend.dismiss(open.front()); + rig.owner.runAll(); + CHECK(withProp(backend, "Dialog", "open", "true").empty()); + + // A create refetches the list; had the dismissed delete run, Proctor A would be gone from it. + backend.click(the(backend, "Button", "label", "New")); + REQUIRE(rig.driveUntil([&] { return shows(backend, "New sample"); })); + CHECK(shows(backend, "Proctor A")); +} + +TEST_CASE("Samples: activating a row opens EditSample prefilled with the row's id", "[forms-demo]") { + LabRig rig; + Samples samples{rig}; + RecordingBackend& backend = samples.backend; + REQUIRE(rig.driveUntil([&] { return shows(backend, "Proctor B"); })); + + backend.activateRow(backend.all("Table").front(), morph::ui::Key{std::string{"2"}}); + rig.owner.runAll(); + CHECK(backend.prop(the(backend, "Dialog", "title", "EditSample"), "open") == "true"); + REQUIRE(rig.driveUntil([&] { return backend.find("TextInput", "text", "2").has_value(); })); +} +``` + +Add `tests/test_lab_collection.cpp` to the `forms_app_tests` source list. + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cmake --build build/forms --target forms_app_tests +./build/forms/examples/forms/forms_app_tests "Samples:*" +``` + +Expected: FAIL — every case's first `REQUIRE(rig.driveUntil(…))` times out: with no view source the shell shows +`this screen's document is not available: samples`, so no row text appears. + +- [ ] **Step 3: Implement** + +In `examples/forms/app/lab_documents.cpp`, `sourcesOf`: replace `.views = {},` with +`.views = lookupIn(documents.views),`. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake --build build/forms --target forms_app_tests +./build/forms/examples/forms/forms_app_tests "[forms-demo]" +``` + +Expected: 16 test cases pass. + +Mutation check: in `include/morph/forms/engine/collection.hpp`'s `collectionView`, change the confirm dialog's +`.onDismiss = [list] { list->cancelConfirm(); }` to `.onDismiss = [list] { list->confirm(); }`. Rebuild: "Samples: +Delete asks first, and dismissing deletes nothing" fails on `CHECK( shows(backend, "Proctor A") )`. Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/forms/app/lab_documents.cpp examples/forms/tests/test_lab_collection.cpp examples/forms/CMakeLists.txt +git commit -m "wip(forms-demo): the samples collection as an app-shell view screen + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 5: The typed entrance — `--typed` + +**Files:** +- Create: `examples/forms/app/typed_lab_forms.hpp`, `examples/forms/app/typed_lab_forms.cpp` +- Modify: `examples/forms/app/lab_shell.hpp` — `Entrance`, `entranceFromArgs`; `LabShell` takes an `Entrance`, + gains `typed()`, `typedScreen()` and the private `typedView()` +- Modify: `examples/forms/app/lab_shell.cpp` — `entranceFromArgs`, the constructors, `view`, `typedScreen`, + `typedView` +- Modify: `examples/forms/CMakeLists.txt` — `app/typed_lab_forms.cpp` in `forms_app`; `tests/test_lab_typed.cpp` +- Test: `examples/forms/tests/test_lab_typed.cpp` + +**Interfaces:** +- Consumes: `forms::Form(runtime, handler, choices, options)`, `Form::session()`, `Form::value()`, + `Form::lastResult()`, `forms::FormModel::forAction()`, `forms::formView`, `wizardView`, `collectionView` + (contract, Part 5); `forms::AppShellSession::{model, current, select, title, menuLabel, form, wizard, + collection}`, `AppShellModel::find`, `ScreenKind`, `AppMenuEntry` (Task 2's block); `ui::forEach`, + `ui::menu`, `ui::MenuItem`, `ui::column`, `ui::row`, `ui::text` (Part 2); `morph::model::ActionTraits::typeId()` + (`include/morph/core/registry.hpp`); `morph::units::toString(Quantity)` (`include/morph/util/quantity.hpp`). +- Produces: `TypedLabForms(runtime, link)` with `density()`, `measurement()`, `screen(actionType) -> + std::optional`; `densityResultText(std::optional const&)`, + `measurementResultText(std::optional const&)`; `Entrance{Runtime, Typed}`, + `entranceFromArgs(argc, argv)`; `LabShell(runtime, bridge, callbacks, Entrance = Runtime)` with `typed()` and + `typedScreen(screenId)`. + +The typed entrance keeps the one `AppShellSession` — its navigation, its wizard and its collection — and draws it +with a shell view of its own, the same `Column{title, Row{Menu, ForEach over current()}}` shape `appShellView` +draws, whose per-screen choice is `LabShell::typedScreen`: a typed form screen for an action `TypedLabForms` types, +else the session's own screen. The session makes a screen's session only when asked, so the typed entrance never +builds a runtime `FormSession` for a typed screen. + +- [ ] **Step 1: Write the failing test** + +Create `examples/forms/tests/test_lab_typed.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/lab_documents.hpp" +#include "app/lab_shell.hpp" +#include "app/typed_lab_forms.hpp" +#include "lab_model.hpp" +#include "lab_test_support.hpp" + +using lab::client::Entrance; +using lab::testing::LabRig; +using lab::testing::menuOf; +using lab::testing::shows; +using morph::ui::Mounted; +using morph::ui::testing::RecordingBackend; + +TEST_CASE("entranceFromArgs: --typed anywhere selects the typed entrance", "[forms-demo]") { + std::array const plain{"morph_forms_app"}; + std::array const typed{"morph_forms_app", "--ui=tui", "--typed"}; + CHECK(lab::client::entranceFromArgs(1, plain.data()) == Entrance::Runtime); + CHECK(lab::client::entranceFromArgs(3, typed.data()) == Entrance::Typed); +} + +TEST_CASE("Typed entrance: the served ComputeDryDensity schema reads as the type's own", "[forms-demo]") { + auto const served = lab::client::memberJson(lab::client::labDocuments().schemas, "ComputeDryDensity"); + REQUIRE(served.has_value()); + auto const runtimeModel = morph::forms::FormModel::fromSchema("ComputeDryDensity", *served); + REQUIRE(runtimeModel.has_value()); + auto const typedModel = morph::forms::FormModel::forAction(); + CHECK(typedModel.actionType() == runtimeModel->actionType()); + REQUIRE(typedModel.fields().size() == runtimeModel->fields().size()); + for (std::size_t i = 0; i < typedModel.fields().size(); ++i) { + CHECK(typedModel.fields()[i].name == runtimeModel->fields()[i].name); + CHECK(typedModel.fields()[i].kind == runtimeModel->fields()[i].kind); + CHECK(typedModel.fields()[i].required == runtimeModel->fields()[i].required); + } +} + +TEST_CASE("Typed entrance: both entrances build the same ComputeDryDensity body", "[forms-demo]") { + LabRig rig; + lab::client::LabShell runtimeShell{rig.runtime, rig.bridge, rig.owner}; + lab::client::LabShell typedShell{rig.runtime, rig.bridge, rig.owner, Entrance::Typed}; + REQUIRE(typedShell.typed() != nullptr); + morph::forms::FormSession* const runtimeForm = runtimeShell.session().form("density"); + REQUIRE(runtimeForm != nullptr); + morph::forms::FormSession& typedForm = typedShell.typed()->density().session(); + + for (morph::forms::FormSession* const session : {runtimeForm, &typedForm}) { + session->field("massDry").text().set("2650.5"); + session->field("volume").text().set("1"); + } + rig.owner.runAll(); + + auto const runtimeBody = runtimeForm->body(); + REQUIRE(runtimeBody.has_value()); + CHECK(typedForm.body() == runtimeBody); + REQUIRE(typedShell.typed()->density().value().has_value()); +} + +TEST_CASE("Typed entrance: the density screen shows the reply as a Density", "[forms-demo]") { + LabRig rig; + lab::client::LabShell shell{rig.runtime, rig.bridge, rig.owner, Entrance::Typed}; + RecordingBackend backend; + Mounted const mounted{rig.runtime, backend, shell.view()}; + rig.owner.runAll(); + CHECK(backend.prop(menuOf(backend), "items") == "[Density,Measure,Intake,Samples]"); + + auto& density = shell.typed()->density(); + density.session().field("massDry").text().set("2650.5"); + density.session().field("volume").text().set("1"); + REQUIRE(rig.driveUntil([&] { return density.lastResult().has_value(); })); + rig.owner.runAll(); + + auto const line = lab::client::densityResultText(density.lastResult()); + CHECK(line.starts_with("Dry density: 2650.5")); + CHECK(shows(backend, line)); +} + +TEST_CASE("Typed entrance: the wizard and the samples come from the documents", "[forms-demo]") { + LabRig rig; + lab::client::LabShell shell{rig.runtime, rig.bridge, rig.owner, Entrance::Typed}; + RecordingBackend backend; + Mounted const mounted{rig.runtime, backend, shell.view()}; + rig.owner.runAll(); + + backend.chooseIndex(menuOf(backend), lab::testing::kIntake); + rig.owner.runAll(); + CHECK(shows(backend, "New sample")); + backend.chooseIndex(menuOf(backend), lab::testing::kSamples); + REQUIRE(rig.driveUntil([&] { return shows(backend, "Proctor A"); })); +} +``` + +Add `tests/test_lab_typed.cpp` to the `forms_app_tests` source list. + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cmake --build build/forms --target forms_app_tests +``` + +Expected: FAIL to compile — `'app/typed_lab_forms.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/forms/app/typed_lab_forms.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// The typed entrance's form screens: `forms::Form`s over the lab's two form actions, so a screen can show the +/// reply decoded into its C++ type. The engine reads the same schema either way (`FormModel::forAction()` is the +/// runtime reader over `schemaJson()`); the wizard and the collection have no typed facade, so both entrances +/// take those from the served documents. + +#include +#include +#include +#include +#include +#include + +#include "app/lab_link.hpp" +#include "lab_model.hpp" + +namespace lab::client { + +/// @brief The line the typed density screen shows for a reply. +/// @param result The typed reply, if one arrived. +/// @return `"Dry density: "`, or empty before the first reply. +[[nodiscard]] std::string densityResultText(std::optional const& result); + +/// @brief The line the typed measurement screen shows for a reply. +/// @param result The typed reply, if one arrived. +/// @return `"Recorded: "`, or empty before the first reply. +[[nodiscard]] std::string measurementResultText(std::optional const& result); + +/// @brief The two typed form screens. +class TypedLabForms { +public: + /// @brief Builds both forms on @p link's handler. + /// @param runtime The runtime the forms bind to; its owner is @p link's callback executor. + /// @param link The handler both forms submit through; it outlives this object. + TypedLabForms(morph::reactive::Runtime& runtime, LabLink& link); + + TypedLabForms(TypedLabForms const&) = delete; + TypedLabForms& operator=(TypedLabForms const&) = delete; + TypedLabForms(TypedLabForms&&) = delete; + TypedLabForms& operator=(TypedLabForms&&) = delete; + ~TypedLabForms() = default; + + /// @brief The typed screen of a form screen whose action this class types. + /// @param actionType The screen's `ref`: an action's registered type id. + /// @return The form and its typed reply line, or `std::nullopt` for an action it does not type. + [[nodiscard]] std::optional screen(std::string_view actionType); + + /// @brief The typed density form. + /// @return The form. + [[nodiscard]] morph::forms::Form& density() noexcept { return _density; } + + /// @brief The typed measurement form. + /// @return The form. + [[nodiscard]] morph::forms::Form& measurement() noexcept { return _measurement; } + +private: + morph::forms::Form _density; + morph::forms::Form _measurement; +}; + +} // namespace lab::client +``` + +Create `examples/forms/app/typed_lab_forms.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "app/typed_lab_forms.hpp" + +#include +#include +#include +#include +#include +#include + +namespace lab::client { + +namespace { + +/// @brief A typed form screen: the form, then the typed reply line. +morph::ui::Node typedScreen(morph::forms::FormSession& session, std::function resultLine) { + return morph::ui::column({ + .children = {morph::forms::formView(session), + morph::ui::text({.text = std::move(resultLine), .role = morph::ui::TextRole::Success})}, + .gap = 1, + }); +} + +} // namespace + +std::string densityResultText(std::optional const& result) { + return result ? "Dry density: " + morph::units::toString(*result) : std::string{}; +} + +std::string measurementResultText(std::optional const& result) { + return result ? "Recorded: " + result->summary : std::string{}; +} + +TypedLabForms::TypedLabForms(morph::reactive::Runtime& runtime, LabLink& link) + : _density{runtime, link.handler(), link.choices()}, _measurement{runtime, link.handler(), link.choices()} {} + +std::optional TypedLabForms::screen(std::string_view actionType) { + if (actionType == morph::model::ActionTraits::typeId()) { + return typedScreen(_density.session(), [this] { return densityResultText(_density.lastResult()); }); + } + if (actionType == morph::model::ActionTraits::typeId()) { + return typedScreen(_measurement.session(), + [this] { return measurementResultText(_measurement.lastResult()); }); + } + return std::nullopt; +} + +} // namespace lab::client +``` + +In `examples/forms/app/lab_shell.hpp`: add `#include `, `#include ` and +`#include "app/typed_lab_forms.hpp"`; before `parseShell` add + +```cpp +/// @brief How the form screens are built. +enum class Entrance : std::uint8_t { + Runtime, ///< From the served schema documents, by the app-shell session. + Typed, ///< From `forms::Form` over the lab's action types. +}; + +/// @brief The entrance the command line asks for: `--typed` anywhere selects `Entrance::Typed`. +/// @param argc Argument count. +/// @param argv Arguments; `argv[0]` is the program. +/// @return The entrance. +[[nodiscard]] Entrance entranceFromArgs(int argc, char const* const* argv); +``` + +and replace the `LabShell` class with + +```cpp +/// @brief The demo's controller: one LabModel link, the shell session over the served documents, and in the typed +/// entrance the typed form screens. +class LabShell { +public: + /// @brief Opens the shell on the first menu entry; each screen's session is made when it is first shown. + /// @param runtime The runtime every session binds to. + /// @param bridge The bridge LabModel lives behind. + /// @param callbacks Where replies are delivered: @p runtime's owner. + /// @param entrance How the form screens are built. + LabShell(morph::reactive::Runtime& runtime, morph::bridge::Bridge& bridge, morph::exec::IExecutor& callbacks, + Entrance entrance = Entrance::Runtime); + + LabShell(LabShell const&) = delete; + LabShell& operator=(LabShell const&) = delete; + LabShell(LabShell&&) = delete; + LabShell& operator=(LabShell&&) = delete; + ~LabShell() = default; + + /// @brief The app-shell session: the current screen, and every screen's session. + /// @return The session. + [[nodiscard]] morph::forms::AppShellSession& session() noexcept { return _session; } + + /// @brief The typed form screens. + /// @return The forms in the typed entrance; null in the runtime entrance. + [[nodiscard]] TypedLabForms* typed() noexcept { return _typed.get(); } + + /// @brief One screen as the typed entrance shows it: a typed form screen when its action is typed, else the + /// session's own screen, else a line saying its document is not available. + /// @param screenId An `app-screens` id. + /// @return The screen's view. + [[nodiscard]] morph::ui::Node typedScreen(std::string_view screenId); + + /// @brief The shell's view: the title, and the menu beside the current screen. + /// @return `forms::appShellView(session())` in the runtime entrance; the same shape over `typedScreen` in the + /// typed one. + [[nodiscard]] morph::ui::Node view(); + +private: + LabShell(morph::reactive::Runtime& runtime, morph::bridge::Bridge& bridge, morph::exec::IExecutor& callbacks, + Entrance entrance, LabDocuments const& documents); + + [[nodiscard]] morph::ui::Node typedView(); + + LabLink _link; + std::unique_ptr _typed; // after _link: both forms submit through its handler + morph::forms::AppShellSession _session; +}; +``` + +In `examples/forms/app/lab_shell.cpp`: add `#include `, `#include `, `#include `, +`#include `, `#include ` and `#include `; add + +```cpp +Entrance entranceFromArgs(int argc, char const* const* argv) { + if (argc < 2) { + return Entrance::Runtime; + } + for (char const* arg : std::span{argv, static_cast(argc)}.subspan(1)) { + if (std::string_view{arg} == "--typed") { + return Entrance::Typed; + } + } + return Entrance::Runtime; +} +``` + +and replace both constructor definitions and `view()` with: + +```cpp +LabShell::LabShell(morph::reactive::Runtime& runtime, morph::bridge::Bridge& bridge, morph::exec::IExecutor& callbacks, + Entrance entrance) + : LabShell{runtime, bridge, callbacks, entrance, labDocuments()} {} + +LabShell::LabShell(morph::reactive::Runtime& runtime, morph::bridge::Bridge& bridge, morph::exec::IExecutor& callbacks, + Entrance entrance, LabDocuments const& documents) + : _link{bridge, callbacks}, + _typed{entrance == Entrance::Typed ? std::make_unique(runtime, _link) : nullptr}, + _session{runtime, parseShell(documents.app), sourcesOf(documents), _link.submitter(), _link.choices()} {} + +morph::ui::Node LabShell::view() { + if (_typed == nullptr) { + return morph::forms::appShellView(_session); + } + return typedView(); +} + +morph::ui::Node LabShell::typedScreen(std::string_view screenId) { + morph::forms::AppScreen const* const screen = _session.model().find(screenId); + if (_typed != nullptr && screen != nullptr && screen->kind == morph::forms::ScreenKind::Form) { + if (auto typedNode = _typed->screen(screen->ref)) { + return *std::move(typedNode); + } + } + if (morph::forms::FormSession* const form = _session.form(screenId)) { + return morph::forms::formView(*form); + } + if (morph::forms::WizardSession* const wizard = _session.wizard(screenId)) { + return morph::forms::wizardView(*wizard); + } + if (morph::forms::CollectionSession* const collection = _session.collection(screenId)) { + return morph::forms::collectionView(*collection); + } + return morph::ui::text({.text = std::format("this screen's document is not available: {}", screenId), + .role = morph::ui::TextRole::Error}); +} + +morph::ui::Node LabShell::typedView() { + morph::forms::AppShellSession* const shell = &_session; + std::vector items; + std::size_t index = 0; + for (morph::forms::AppMenuEntry const& entry : shell->model().menu()) { + items.push_back(morph::ui::MenuItem{.label = shell->menuLabel(index), + .onSelect = [shell, screen = entry.screen] { shell->select(screen); }}); + ++index; + } + auto content = morph::ui::forEach( + [shell] { return std::vector{shell->current()}; }, + [](std::string const& screenId) { return morph::ui::Key{screenId}; }, + [this](morph::reactive::Signal const& slot) { return typedScreen(slot.peek()); }); + return morph::ui::column({ + .children = {morph::ui::text({.text = shell->title(), .role = morph::ui::TextRole::Heading}), + morph::ui::row({.children = {morph::ui::menu({.items = std::move(items)}), std::move(content)}, + .gap = 2})}, + .gap = 1, + }); +} +``` + +In `examples/forms/CMakeLists.txt`, add `app/typed_lab_forms.cpp` to `forms_app`'s sources after +`app/lab_shell.cpp`. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake --build build/forms --target forms_app_tests +./build/forms/examples/forms/forms_app_tests "[forms-demo]" +``` + +Expected: 21 test cases pass. + +Mutation check: in `labDocuments()` (`app/lab_documents.cpp`), make the runtime documents disagree with the types +by one declared precision — replace the `return` with: + +```cpp + LabDocuments documents{.app = lab::appSchemaJson(), .schemas = lab::schemasJson(), + .wizards = lab::wizardSchemasJson(), .views = lab::viewsJson()}; + constexpr std::string_view kFour = R"("x-decimalPlaces":4)"; + if (auto const found = documents.schemas.find(kFour); found != std::string::npos) { + documents.schemas.replace(found, kFour.size(), R"("x-decimalPlaces":3)"); + } + return documents; +``` + +Rebuild: "Typed entrance: both entrances build the same ComputeDryDensity body" fails on its `CHECK` (the runtime +body carries `"dp":3` for `volume`, the typed one `"dp":4`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/forms/app examples/forms/tests/test_lab_typed.cpp examples/forms/CMakeLists.txt +git commit -m "wip(forms-demo): the typed entrance, --typed + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 6: `morph_forms_app` on the injected frontend; the QML demo goes + +**Files:** +- Create: `examples/forms/app/lab_application.hpp`, `examples/forms/app/lab_application.cpp` +- Create: `examples/forms/ui/main.cpp` +- Modify: `examples/forms/CMakeLists.txt` — `app/lab_application.cpp` and the `morph_ladder_app_common` link in + `forms_app`; the `morph_forms_app` binary; `tests/test_lab_application.cpp`; the `forms_app_smoke_tests` binary; + the header comment; remove the `if(MORPH_BUILD_FORMS_QML) add_subdirectory(gui_qml) endif()` block +- Delete: `examples/forms/gui_qml/` (CMakeLists.txt, FormsController.{hpp,cpp}, main.cpp, qml/AppShell.qml, + qml/Main.qml) +- Modify: `CMakeLists.txt` — the two comments that name + `examples/forms/gui_qml` (the "Qt/QML forms renderer setup" block before `if(MORPH_BUILD_FORMS_QML)` and the + "Qt/QML forms renderer (optional)" block before `add_subdirectory(src/qt/forms)`) +- Test: `examples/forms/tests/test_lab_application.cpp`, `examples/forms/tests/smoke/test_lab_frontend_smoke.cpp` + +**Interfaces:** +- Consumes: `ui::Application`, `ui::AppContext`, `ui::ApplicationFactory`, `ui::FrontendOption`, + `ui::selectFrontend` (Part 2, `morph/ui/frontend.hpp`); `tui::frontendOption()` (Part 3, `morph/tui/frontend.hpp`); + `qt_quick::frontendOption(int&, char**)` (Part 4, `morph/qt_quick/frontend.hpp`); from Part 6: + `examples::AppEnvironment` (`app/app_environment.hpp`), `examples::connect`, `examples::LocalSetup{setupDatabase, + workers}`, `examples::Connection::{bridge, callbacks}` (`app/transport.hpp`), target `morph_ladder_app_common`; + `examples::testing::FakeAppContext` (`testkit/fake_app_context.hpp`, `owner()` a `MainThreadExecutor`), + `examples::testing::pumpUntil` (`testkit/wait.hpp`), `examples::testing::runFrontendSmoke`, `SmokeFrontend{Tui, + QtQuick}` (`testkit/frontend_smoke.hpp`), target `morph_example_testkit` (links every built frontend and defines + `MORPH_EXAMPLE_HAS_TUI`/`MORPH_EXAMPLE_HAS_QT_QUICK`, both PUBLIC); CMake `morph_add_example_ui(TARGET … SOURCES … + LIBRARIES …)` (`cmake/morph_example_app.cmake`). +- Produces: `lab::client::LabApplication(ui::AppContext&, examples::AppEnvironment const&, Entrance)`; + `lab::client::makeApplication(ui::AppContext&, examples::AppEnvironment const&)` (the contract's convention, the + runtime entrance) and `makeApplication(ui::AppContext&, examples::AppEnvironment const&, Entrance)`; the binary + `morph_forms_app`; the test binary `forms_app_smoke_tests`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/forms/tests/test_lab_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/lab_application.hpp" +#include "lab_test_support.hpp" + +using lab::testing::menuOf; +using lab::testing::shows; +using morph::examples::AppEnvironment; +using morph::examples::testing::FakeAppContext; +using morph::examples::testing::pumpUntil; +using morph::ui::Mounted; +using morph::ui::testing::RecordingBackend; + +TEST_CASE("makeApplication: the runtime entrance mounts the shell on any AppContext", "[forms-demo]") { + FakeAppContext ctx; + auto const app = lab::client::makeApplication(ctx, AppEnvironment{}); + REQUIRE(app != nullptr); + RecordingBackend backend; + Mounted const mounted{ctx.runtime(), backend, app->view()}; + + REQUIRE(pumpUntil(ctx.owner(), [&] { return shows(backend, "Oven-dry mass of the specimen"); })); + CHECK(backend.prop(menuOf(backend), "items") == "[Density,Measure,Intake,Samples]"); +} + +TEST_CASE("makeApplication: the typed entrance shows the typed reply line", "[forms-demo]") { + FakeAppContext ctx; + auto const app = lab::client::makeApplication(ctx, AppEnvironment{}, lab::client::Entrance::Typed); + RecordingBackend backend; + Mounted const mounted{ctx.runtime(), backend, app->view()}; + REQUIRE(pumpUntil(ctx.owner(), [&] { return backend.all("TextInput").size() == 2; })); // massDry, volume + + auto const inputs = backend.all("TextInput"); + backend.edit(inputs.at(0), "2650.5"); + backend.edit(inputs.at(1), "1"); + + auto const densityLine = [&] { + for (int const widgetId : backend.all("Text")) { + if (std::string const text = backend.prop(widgetId, "text"); text.starts_with("Dry density: ")) { + return text; + } + } + return std::string{}; + }; + REQUIRE(pumpUntil(ctx.owner(), [&] { return !densityLine().empty(); })); + CHECK(densityLine().starts_with("Dry density: 2650.5")); +} + +TEST_CASE("makeApplication: --server is refused, the model runs in this process", "[forms-demo]") { + FakeAppContext ctx; + AppEnvironment env; + env.server = "ws://localhost:8080"; + CHECK_THROWS_AS(lab::client::makeApplication(ctx, env), std::invalid_argument); +} +``` + +Create `examples/forms/tests/smoke/test_lab_frontend_smoke.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The application mounts and quits on every frontend this configure built: on the terminal through scripted +// input, and on Qt Quick offscreen. + +#include +#include +#include +#include + +#include "app/lab_application.hpp" + +namespace { + +[[maybe_unused]] morph::ui::ApplicationFactory factoryFor(lab::client::Entrance entrance) { + return [entrance](morph::ui::AppContext& ctx) { + return lab::client::makeApplication(ctx, morph::examples::AppEnvironment{}, entrance); + }; +} + +} // namespace + +#if MORPH_EXAMPLE_HAS_TUI +TEST_CASE("morph_forms_app: mounts and quits on the terminal UI", "[forms-demo][smoke][tui]") { + morph::examples::testing::runFrontendSmoke(factoryFor(lab::client::Entrance::Runtime), + morph::examples::testing::SmokeFrontend::Tui); +} + +TEST_CASE("morph_forms_app --typed: mounts and quits on the terminal UI", "[forms-demo][smoke][tui]") { + morph::examples::testing::runFrontendSmoke(factoryFor(lab::client::Entrance::Typed), + morph::examples::testing::SmokeFrontend::Tui); +} +#endif + +#if MORPH_EXAMPLE_HAS_QT_QUICK +TEST_CASE("morph_forms_app: mounts and quits on Qt Quick, offscreen", "[forms-demo][smoke][qt]") { + morph::examples::testing::runFrontendSmoke(factoryFor(lab::client::Entrance::Runtime), + morph::examples::testing::SmokeFrontend::QtQuick); +} +#endif +``` + +In `examples/forms/CMakeLists.txt`, add `tests/test_lab_application.cpp` to the `forms_app_tests` source list +(`FakeAppContext` and `pumpUntil` are header-only, on `morph_ladder_app_common`'s PUBLIC include path +`examples/common`, which `forms_app` passes on), and after `catch_discover_tests(forms_app_tests …)` add: + +```cmake + # ── forms_app_smoke_tests: the application on each built frontend ──── + # A binary of its own whose main (morph_test_main) owns no Qt application object, because the Qt Quick + # frontend constructs its own. morph_example_testkit (examples/common/app, named by its plain target name) + # brings the harness, every built frontend and MORPH_EXAMPLE_HAS_TUI / MORPH_EXAMPLE_HAS_QT_QUICK. It + # exists only where the binary does. + if(TARGET morph_forms_app) + add_executable(forms_app_smoke_tests tests/smoke/test_lab_frontend_smoke.cpp) + target_link_libraries(forms_app_smoke_tests PRIVATE forms_app morph_example_testkit morph_test_main) + target_compile_features(forms_app_smoke_tests PRIVATE cxx_std_23) + apply_warnings(forms_app_smoke_tests) + apply_bigobj(forms_app_smoke_tests) + if(DEFINED AF_SANITIZER) + apply_sanitizers(forms_app_smoke_tests ${AF_SANITIZER}) + endif() + catch_discover_tests(forms_app_smoke_tests + DISCOVERY_MODE PRE_TEST + PROPERTIES LABELS forms-demo) + endif() +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cmake --build build/forms --target forms_app_tests +``` + +Expected: FAIL to compile — `'app/lab_application.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/forms/app/lab_application.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// The forms demo as a `ui::Application`: an in-process LabModel and the shell over it, on whichever frontend +/// runs it. + +#include +#include +#include +#include +#include + +#include "app/lab_shell.hpp" + +namespace lab::client { + +/// @brief The demo application: a local connection (a worker pool and a bridge on the frontend's executor) and the +/// shell over it. +class LabApplication final : public morph::ui::Application { +public: + /// @brief Connects locally and builds the shell on @p ctx's runtime and executor. + /// @param ctx The frontend's context. + /// @param env The command line's environment; the demo runs LabModel in this process. + /// @param entrance How the form screens are built. + /// @throws std::invalid_argument when @p env names a server: the demo has none. + LabApplication(morph::ui::AppContext& ctx, morph::examples::AppEnvironment const& env, Entrance entrance); + + /// @brief The shell's view. + /// @return The root node. + [[nodiscard]] morph::ui::Node view() override; + +private: + std::unique_ptr _connection; // first: the shell's handler lives on its bridge + LabShell _shell; +}; + +/// @brief The demo's `ui::ApplicationFactory` body: the runtime entrance. +/// @param ctx The frontend's context. +/// @param env The command line's environment. +/// @return The application. +/// @throws std::invalid_argument when @p env names a server. +[[nodiscard]] std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env); + +/// @brief The demo's `ui::ApplicationFactory` body, with the entrance `--typed` selects. +/// @param ctx The frontend's context. +/// @param env The command line's environment. +/// @param entrance How the form screens are built. +/// @return The application. +/// @throws std::invalid_argument when @p env names a server. +[[nodiscard]] std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env, + Entrance entrance); + +} // namespace lab::client +``` + +Create `examples/forms/app/lab_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "app/lab_application.hpp" + +#include +#include + +namespace lab::client { + +namespace { + +/// @brief The local connection LabModel runs behind; there is no LabModel server to reach. +std::unique_ptr connectLocally(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env) { + if (env.server.has_value()) { + throw std::invalid_argument{"morph_forms_app runs LabModel in this process; --server is not supported"}; + } + return morph::examples::connect(ctx, env, morph::examples::LocalSetup{.setupDatabase = {}, .workers = 2}); +} + +} // namespace + +LabApplication::LabApplication(morph::ui::AppContext& ctx, morph::examples::AppEnvironment const& env, + Entrance entrance) + : _connection{connectLocally(ctx, env)}, + _shell{ctx.runtime(), _connection->bridge(), _connection->callbacks(), entrance} {} + +morph::ui::Node LabApplication::view() { return _shell.view(); } + +std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env) { + return makeApplication(ctx, env, Entrance::Runtime); +} + +std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env, + Entrance entrance) { + return std::make_unique(ctx, env, entrance); +} + +} // namespace lab::client +``` + +Create `examples/forms/ui/main.cpp` (spec 4 §3's composition root, as `examples/bank/ui/main.cpp` has it, plus the +entrance): + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// morph_forms_app — the forms demo on any frontend. +/// +/// morph_forms_app [--ui=tui|qt] [--typed] +/// +/// The frontend is `--ui=`, else `MORPH_UI`, else the first built frontend that can run here (Qt Quick, +/// then the terminal UI). `--typed` builds the form screens from `forms::Form` instead of the served schemas. + +#include +#include +#include +#include +#include + +#include "app/lab_application.hpp" + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#endif +#if MORPH_EXAMPLE_HAS_TUI +#include +#endif + +int main(int argc, char** argv) { + try { + auto const env = morph::examples::AppEnvironment::fromArgs(argc, argv); + auto const entrance = lab::client::entranceFromArgs(argc, argv); + std::vector built; +#if MORPH_EXAMPLE_HAS_QT_QUICK + built.push_back(morph::qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_EXAMPLE_HAS_TUI + built.push_back(morph::tui::frontendOption()); +#endif + auto const frontend = morph::ui::selectFrontend(built, argc, argv); + return frontend->run([&env, entrance](morph::ui::AppContext& ctx) { + return lab::client::makeApplication(ctx, env, entrance); + }); + } catch (std::exception const& error) { + std::cerr << "morph_forms_app: " << error.what() << '\n'; + return 1; + } +} +``` + +In `examples/forms/CMakeLists.txt`: + +1. Replace the header comment (the four lines after the SPDX line) with: + + ```cmake + # Schema-driven forms demo. morph_forms_demo is the console client: it prints the generated action schemas, + # emits a self-contained HTML/JS form renderer, and executes pasted action lines through the type-erased + # dispatcher. morph_forms_app is the same lab model's forms on the terminal UI or Qt Quick: forms_app (app/) + # is its toolkit-free app library, ui/main.cpp its composition root. + ``` + +2. Delete the block `if(MORPH_BUILD_FORMS_QML)` / `add_subdirectory(gui_qml)` / `endif()`. +3. In `forms_app`: add `app/lab_application.cpp` after `app/typed_lab_forms.cpp`; change its link line to + `target_link_libraries(forms_app PUBLIC morph::morph morph_ladder_app_common)` and its comment's "it links morph + and nothing else" to "it links morph and morph_ladder_app_common (examples/common/app, Qt-free, added by the root + after this directory and named by its plain target name) and nothing else". +4. After the `forms_app` library block, add: + + ```cmake + # ── morph_forms_app: the binary ────────────────────────────────────────────── + # Built when at least one frontend is (spec 4 §6); it links every frontend that was built and says which + # through MORPH_EXAMPLE_HAS_TUI / MORPH_EXAMPLE_HAS_QT_QUICK (cmake/morph_example_app.cmake). + morph_add_example_ui(TARGET morph_forms_app SOURCES ui/main.cpp LIBRARIES forms_app) + ``` + +The root `CMakeLists.txt`'s "Example applications on an injected frontend" block (Part 6) already adds +`examples/common/app` in every native configure with examples on, which is what `forms_app` needs; confirm with +`grep -n "NOT EMSCRIPTEN OR MORPH_BUILD_BANK_EXAMPLE" CMakeLists.txt`. + +Delete the QML demo: + +```bash +git rm -r examples/forms/gui_qml +``` + +In the root `CMakeLists.txt`, replace the whole comment block between the line +`# ── Qt/QML forms renderer setup (optional) ───…` and `if(MORPH_BUILD_FORMS_QML)` with: + +```cmake +# ── Qt/QML forms renderer setup (optional) ─────────────────────────────────── +# Ships the Qt/QML forms renderer (MorphForms, src/qt/forms) as a reusable component, independent of +# MORPH_BUILD_EXAMPLES. The find_package/qt_standard_project_setup() call and the morph::qt_forms interface target +# are created here, before the rest of the tree is configured, because a consumer links morph::qt_forms by its +# namespaced name. add_subdirectory(src/qt/forms) itself (the module, its plugin and a Catch2-linked test +# executable) is deferred to just after the "Tests" section, since Catch2 is only found or fetched there. +# Emscripten builds this too; the module's one host-only piece, the QuickTest suite, is guarded inside +# src/qt/forms/CMakeLists.txt. +``` + +and the comment block between `# ── Qt/QML forms renderer (optional) ───…` and the second +`if(MORPH_BUILD_FORMS_QML)` with: + +```cmake +# ── Qt/QML forms renderer (optional) ───────────────────────────────────────── +# The MorphForms module and plugin (src/qt/forms), deferred to here because its CMakeLists.txt links a Catch2 +# test executable against Catch2::Catch2 when MORPH_BUILD_TESTS is ON. Qt6 Quick/Qml was found and +# morph::qt_forms created earlier, in "Qt/QML forms renderer setup". +``` + +Both blocks are deleted outright in Task 9; this keeps commit 17's comments true. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake -S . -B build/forms # re-configure: new sources +cmake --build build/forms --target forms_app_tests forms_app_smoke_tests morph_forms_app morph_forms_demo +ctest --test-dir build/forms -L forms-demo --output-on-failure +ctest --test-dir build/forms -R 'forms_html_math|forms_repl_roundtrip' --output-on-failure +cmake -S . -B build/forms-nofe -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build/forms-nofe --target forms_app_tests && ctest --test-dir build/forms-nofe -L forms-demo +cmake --build build/forms-nofe --target morph_forms_app 2>&1 | grep -q "unknown target 'morph_forms_app'" \ + && echo "no frontend, no binary" +git ls-files examples/forms | grep -c gui_qml # expect 0 +``` + +Expected: on `build/forms`, 24 `forms_app_tests` cases and 3 `forms_app_smoke_tests` cases, all passing; on +`build/forms-nofe`, the 24 `forms_app_tests` cases pass and neither `morph_forms_app` nor `forms_app_smoke_tests` +exists ("no frontend, no binary"); the CLI's two tests pass; the last line prints `0`. + +Mutation check: in the three-argument `makeApplication`, pass `Entrance::Runtime` instead of `entrance`. Rebuild: +"makeApplication: the typed entrance shows the typed reply line" fails at its second `REQUIRE(pumpUntil(…))`. +Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/forms CMakeLists.txt +git commit -m "wip(forms-demo): morph_forms_app on the injected frontend; remove the QML demo + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 7: The demo's documentation + +**Files:** +- Modify: `examples/forms/README.md` — the file map, "Build", section 3, section 4, "Tests", "Notes" +- Modify: `README.md` — the `examples/forms` paragraph and code block in the forms section; the "Examples" list entry +- Modify: `docs/ARCHITECTURE.md` — the `examples/forms` sentence after the forms headers paragraph; the `Choice` + paragraph's last sentence + +**Interfaces:** none (documentation of Tasks 1–6). + +- [ ] **Step 1: Write the failing test** + +The check every doc task in this part uses — every backticked repository path in the file exists, and no retired +name is left — saved once as a scratch script: + +```bash +cat > /tmp/morph-doc-check.sh <<'SH' +#!/usr/bin/env bash +# Usage: morph-doc-check.sh PATTERN FILE... -- fails on a missing path or a PATTERN hit +set -u +pattern=$1; shift +status=0 +for file in "$@"; do + while IFS= read -r path; do + [ -e "$path" ] || { echo "$file: names a missing path: $path"; status=1; } + done < <(grep -oE '`(examples|include|src|scripts|cmake|tests|docs|\.github)/[^` ]+`' "$file" \ + | tr -d '`' | grep -v '[<*{]' | sed -E 's/[:#].*$//' | sort -u) + if grep -nE "$pattern" "$file"; then status=1; fi +done +exit $status +SH +chmod +x /tmp/morph-doc-check.sh +/tmp/morph-doc-check.sh 'gui_qml|morph_forms_qml|AppShell\.qml|Main\.qml|forms_qml_logic|QML renderer' \ + examples/forms/README.md +git grep -nE 'gui_qml|morph_forms_qml|QML client|Qt Quick GUI|QML renderers' -- README.md docs/ARCHITECTURE.md +``` + +(The path half runs on the demo's README only; `README.md` and `docs/ARCHITECTURE.md` get the name check, so a +path that was already missing there before this part cannot fail this task.) + +- [ ] **Step 2: Run it to verify it fails** + +Expected: the first command exits 1, printing `examples/forms/README.md` lines naming `gui_qml/`, +`morph_forms_qml`, `AppShell.qml`, `forms_qml_logic`; the second prints `README.md`'s +`./build/examples/forms/gui_qml/morph_forms_qml` line and `docs/ARCHITECTURE.md`'s "the QML client fetches them +live" line. + +- [ ] **Step 3: Implement** + +`examples/forms/README.md`: + +- Opening paragraph: replace "This example runs the whole loop locally with **two renderers driven by the same + generated schemas** — nothing about the forms is hardcoded in either client." with "This example runs the whole + loop locally with **two clients driven by the same generated schemas** — a browser page and an app on the + terminal UI or Qt Quick — and nothing about the forms is hardcoded in either." +- File map: add `lab_views.hpp SamplesView (a list/master-detail view over ListSamples)` after `lab_wizard.hpp`'s + two lines; change `lab_schemas.hpp`'s lines to `lab_schemas.hpp the served documents: {actionType: schema}, + {wizardId: schema}, the app-* document, {viewId: schema}`; replace the `gui_qml/` line with + `app/ forms_app: the app library (screens over the forms engine)` and + `ui/main.cpp morph_forms_app: frontend selection and the app factory`; add + `tests/ the app's tests (RecordingBackend, both entrances, frontend smoke)`. +- Reading order: `lab_units.hpp` → `lab_model.hpp` → `lab_views.hpp` → `lab_wizard.hpp` → `lab_schemas.hpp` → + `app/`. +- "## Build": replace the code block with + + ```sh + cmake -B build -G Ninja -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON + ninja -C build morph_forms_demo morph_forms_app + ``` + + and add below it: "`morph_forms_app` is built when at least one frontend is (`MORPH_BUILD_TUI` or + `MORPH_BUILD_QT_QUICK`); the console demo needs neither." +- Section 2's sentence "(its options were resolved at emit time by executing `ListSamples`; the QML client fetches + them live instead)" → "(its options were resolved at emit time by executing `ListSamples`; `morph_forms_app` + fetches them live instead)". +- Replace section "## 3. QML renderer" and section "## 4. Views: the SamplesView list/master-detail screen" (up to + "## Tests") with: + + ````markdown + ## 3. The app: one binary, any frontend + + ```sh + ./build/examples/forms/morph_forms_app # Qt Quick if it can open a window, else the terminal UI + ./build/examples/forms/morph_forms_app --ui=tui # the terminal UI + ./build/examples/forms/morph_forms_app --ui=qt # Qt Quick + ./build/examples/forms/morph_forms_app --typed # the same screens, form screens built from Form + ``` + + The frontend is `--ui=`, else the `MORPH_UI` environment variable, else the first built frontend that can + run here. Nothing below depends on which one it is: the app library (`app/`, target `forms_app`) links morph + and nothing else. + + The app renders `LabApp` (`lab_wizard.hpp`) through `morph::forms::appShellView`: a menu beside the selected + screen. + + | Menu | Screen kind | What it is | + |---|---|---| + | Density | form | `ComputeDryDensity` — two `Quantity` fields with unit alternatives | + | Measure | form | `RecordMeasurement` — a `Choice` served by `ListSamples`, a date-time, sections and an accordion | + | Intake | wizard | `IntakeWizard` — `RegisterSample`, then `RecordMeasurement` with `sampleId` prefilled from step one's reply (`Bind<"sampleId", "RegisterSample.id">`); Next is enabled once step one's submission succeeded, and entering step two re-fetches its sample options, so the new sample is among them | + | Samples | view | `SamplesView` — the `ListSamples` table, New, a row's Delete behind a confirmation, and the `EditSample` editor a row opens prefilled with its id | + + **Two entrances, one engine.** By default the app is a runtime client: it takes the four documents a server + would serve — the `app-*` document, the action schemas, the wizard and the view, all from `lab_schemas.hpp` — + as JSON text and builds every screen from them: one `forms::AppShellSession` reads each screen's document + through the lookups in `app/lab_documents.cpp`, which name no lab action type, and makes the screen's session + the first time it is shown. `--typed` builds the two form screens from `forms::Form` and + `forms::Form` instead: the engine reads the same schema, so the form behaves the + same and submits the same bytes, and the screen adds the reply decoded into its C++ type (`Density`, + `MeasurementAck`), which a runtime client can only show as JSON. The engine has no typed facade for wizards or + collections, so both entrances build those from the documents; the typed entrance draws the same menu and + screens with a shell view of its own (`LabShell::typedScreen`). + + Every screen submits through `forms::handlerSubmitter` over one `BridgeHandler` (`app/lab_link.hpp`) + — one model instance — so a sample the wizard registers is the one Measure's sample picker lists and the Samples table shows. Forms submit + automatically when they become ready and on every edit that leaves them ready with a changed body; an action + that wants a Submit button declares `explicitSubmit` (`docs/spec/forms/forms.md`, "Explicit submit mode"). + Leaving a screen and coming back finds it as it was: each screen's session lives as long as the app, so the + wizard keeps its step and a half-filled form keeps its drafts. + + See `docs/spec/forms/workflows_navigation.md` for the `w-*`/`app-*` documents, `docs/spec/forms/views.md` for + `v-*`, and `docs/spec/forms/engine.md` for how the engine reads them. + ```` + +- "## Tests": replace the `forms_qml_logic` bullet with "- `forms_app_tests` (label `forms-demo`) — the app on + `ui::testing::RecordingBackend` over an in-process `LabModel`: the shell's menu and screen switching, the wizard's + gate and prefill, the collection's list, create, confirm and row editor, byte-identical bodies from both + entrances, and the application on Part 6's `FakeAppContext`; and `forms_app_smoke_tests` (label `forms-demo`, + built with the binary) — the application mounting and quitting on each built frontend." +- "## Notes", the `measuredAt` bullet: replace "Both clients provide a picker: the browser's native datetime-local + control (with a *now* button) and a QML calendar/time popup." with "Both clients provide a picker: the browser's + native datetime-local control (with a *now* button) and the app's date-time input, drawn by each frontend." + +`README.md`, forms section: replace "[`examples/forms`](examples/forms) shows the whole loop with two renderers +driven purely by the generated schemas — a self-contained HTML page and a runtime-built Qt Quick GUI:" with +"[`examples/forms`](examples/forms) shows the whole loop with two clients driven purely by the generated schemas — +a self-contained HTML page and an app on the terminal UI or Qt Quick:"; in the code block below it replace the +first line with `cmake -B build -G Ninja -DMORPH_BUILD_TUI=ON # add -DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON for Qt Quick` +and the last line with `./build/examples/forms/morph_forms_app --typed # same forms, on the terminal UI or Qt Quick`. +"## Examples": the `examples/forms` entry becomes "the schema-driven forms loop (an HTML page, and an app on the +terminal UI or Qt Quick) over an exact-value lab model." + +`docs/ARCHITECTURE.md`: replace "`examples/forms` demonstrates the whole loop with two renderers: a self-contained +HTML page and a Qt Quick client (`MORPH_BUILD_FORMS_QML=ON`), both driven purely by the generated schemas." with +"`examples/forms` demonstrates the whole loop with two clients: a self-contained HTML page and `morph_forms_app` on +the terminal UI or Qt Quick, both driven purely by the generated schemas."; and "the QML client fetches them live +over the same in-process wire it submits on." with "`morph_forms_app` fetches them live over the same in-process +wire it submits on." + +- [ ] **Step 4: Run the check to verify it passes** + +```bash +/tmp/morph-doc-check.sh 'gui_qml|morph_forms_qml|AppShell\.qml|Main\.qml|forms_qml_logic|QML renderer' \ + examples/forms/README.md && echo clean +git grep -nE 'gui_qml|morph_forms_qml|QML client|Qt Quick GUI|QML renderers' -- README.md docs/ARCHITECTURE.md \ + || echo "no stale name" +pre-commit run --files examples/forms/README.md README.md docs/ARCHITECTURE.md +``` + +Expected: `clean`, `no stale name`; pre-commit's hooks pass (codespell, whitespace). + +Mutation check: add the line ``See `examples/forms/gui_qml/qml/Main.qml`.`` to `examples/forms/README.md`; the check +exits 1 naming the missing path and the `gui_qml` hit. Remove it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/forms/README.md README.md docs/ARCHITECTURE.md +git commit -m "wip(forms-demo): document morph_forms_app + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 8: Group verification, then squash `forms-demo` + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Strict build and the suites** + +```bash +cmake --build build/forms && ctest --test-dir build/forms --output-on-failure +cmake --build build/forms-nofe && ctest --test-dir build/forms-nofe --output-on-failure +``` + +Expected: every test passes on both trees, not only `forms-demo`. + +- [ ] **Step 2: Sanitizers** (Linux; on macOS an ASan configure of `build/forms`) + +```bash +cmake --preset clang-asan -DMORPH_BUILD_TUI=ON +cmake --build --preset clang-asan --target forms_app_tests forms_app_smoke_tests morph_tests +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-asan/examples/forms/forms_app_tests asan +./build/clang-asan/examples/forms/forms_app_tests "[forms-demo]" +./build/clang-asan/examples/forms/forms_app_smoke_tests +./build/clang-asan/tests/morph_tests "[forms-engine][wizard]" +``` + +Expected: clean. The shell and wizard tests destroy screens' views while sessions live on, and the wizard's +re-fetch on step entry replaces a query's in-flight request; ASan is their observer. + +- [ ] **Step 3: clang-tidy over the changed lines** — CONTRIBUTING's "Running the `clang-tidy-diff` gate locally", + configured with `-DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON` added, diff base + `origin/master...HEAD`, file count asserted non-zero. + +Expected: no findings in `examples/forms/`. + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A examples/forms include/morph/forms tests +git commit -m "wip(forms-demo): fixes from the sanitizer and tidy gates + +Signed-off-by: Christian Parpart " +``` + +Skip the commit if there was nothing to fix, and say so in the hand-off. + +- [ ] **Step 5: Squash the group into its one commit** + +Follow the master plan's "Squashing a part" procedure with `key=forms-demo` and this message (add a paragraph +before the trailer if Task 2 Step 0 found a Part 5 header that differs from Part 5's plan — what the plan said, what +the header has, what was done): + +```text +examples/forms: one app, any frontend, runtime and typed forms + +morph_forms_app replaces the QML demo: one binary that renders LabApp +through forms::appShellView on the terminal UI or Qt Quick, chosen at +run time. By default every screen is built from the served JSON +documents; --typed builds the form screens from forms::Form and shows +the typed reply. SamplesView is now an app-shell view screen, and every +screen submits through forms::handlerSubmitter over one BridgeHandler, +so they share one LabModel. The console demo (--schemas, the REPL, +--emit-html) is unchanged. + +Deviations: ui/main.cpp passes the --typed entrance to a second +makeApplication overload, the one line by which it differs from the +composition root every other example has. + +Signed-off-by: Christian Parpart +``` + +Expected last line of the procedure: `git log --oneline master..HEAD` lists commits 1–17 of the master plan's +history, ending with `examples/forms: one app, any frontend, runtime and typed forms`. + +--- +## Group `retire` — `forms, examples: retire the QML renderer and the Qt client stack` + +Every task in this group deletes or rewrites; none adds behaviour. Its "failing test" is a check that names what +is still there, written before the deletion and passing after it, and each check is mutated to prove it detects +what it claims to. + +### Task 9: Remove the QML forms renderer and its Qt components + +**Files:** +- Delete: `src/qt/forms/` (the `MorphForms` module: `qml/` with `DynamicForm.qml`, `CollectionView.qml`, + `WizardView.qml`, `SlotRegistry.qml`, `DateTimePicker.qml`, `JsonExact.js`; `I18nCatalog.{hpp,cpp}`; `tests/` + with the QuickTest suite, `test_forms_controller_core.cpp`, `test_multi_model_bridge_core.cpp`; `CMakeLists.txt`) +- Delete: `include/morph/qt/forms/` (`forms_controller_core.hpp`, `multi_model_forms_controller_core.hpp`), + `include/morph/qt/bridge/` (`generic_model_bridge_core.hpp`, `multi_model_bridge_core.hpp`, + `detail/owned_local_bridge.hpp`) +- Delete: `scripts/check_forms_qml_install.sh` +- Modify: `CMakeLists.txt` — the option; the Windows Qt detection; the public-header guard; the offline-sqlite + comment; the "Qt/QML forms renderer setup" block; the "Application ladder" comment; the "Qt/QML forms renderer" + block; the install components; the MorphForms install block; `configure_package_config_file` +- Modify: `cmake/morphConfig.cmake.in` — the `qt_forms` and `forms_qml` blocks +- Modify: `CMakePresets.json` — `windows-everything`, `linux-everything` +- Modify: `scripts/test_check_install_export.sh` — the `foreach` pattern of the vacuity-guard case +- Modify: `scripts/check_coverage_objects.sh` — the `morph_forms_qml_tests|morph_forms_controller_core_tests` arm +- Modify: `.github/workflows/ci.yml`, `.github/workflows/nightly-slow-checks.yml`, `.github/workflows/wasm-ladder.yml` +- Modify: `README.md` — prerequisites, build options, install components; `CONTRIBUTING.md` — the options list +- Modify: `CHANGELOG.md` — `[Unreleased]` → `### Removed` + +**Interfaces:** +- Consumes: nothing new. +- Produces: an install whose components are `offline_sqlite`, `qt`, `net`, `tui`, `qt_quick` (as configured); + no `MORPH_BUILD_FORMS_QML`. + +- [ ] **Step 1: Write the failing check** + +Every removed path is gone, no build or CI file names the renderer, and a fresh configure that passes the old +option reports it unused (CMake's own proof that nothing reads it): + +```bash +cat > /tmp/morph-retire-renderer.sh <<'SH' +#!/usr/bin/env bash +set -u +status=0 +for path in src/qt/forms include/morph/qt/forms include/morph/qt/bridge scripts/check_forms_qml_install.sh; do + [ -e "$path" ] && { echo "still present: $path"; status=1; } +done +if git grep -nE 'FORMS_QML|qt_forms|forms_qml|MorphForms|morph_forms_module|check_forms_qml_install|morph_QML_IMPORT_PATH|qt/bridge/|qt/forms/' \ + -- CMakeLists.txt cmake CMakePresets.json .github scripts README.md CONTRIBUTING.md \ + ':!cmake/compiler_options.cmake'; then + status=1 +fi +rm -rf build/retire-check +cmake -S . -B build/retire-check -G Ninja -DMORPH_BUILD_EXAMPLES=OFF -DMORPH_BUILD_TESTS=OFF \ + -DMORPH_BUILD_FORMS_QML=ON > build/retire-check.log 2>&1 +grep -A3 'Manually-specified variables were not used by the project' build/retire-check.log \ + | grep -q MORPH_BUILD_FORMS_QML || { echo "MORPH_BUILD_FORMS_QML is still read by the configure"; status=1; } +exit $status +SH +chmod +x /tmp/morph-retire-renderer.sh +``` + +(`cmake/compiler_options.cmake` is excluded: its one `FORMS_QML` is the recorded configuration of a past +measurement, which Task 14 lists as an allowed hit.) + +Pre-condition — nothing outside the files this task deletes or edits still uses the renderer's C++ side: + +```bash +git grep -lE 'morph/qt/forms/|morph/qt/bridge/|FormsControllerCore|GenericModelBridgeCore|MultiModelBridgeCore' \ + -- examples include src tests ':!include/morph/qt/forms' ':!include/morph/qt/bridge' ':!src/qt/forms' +``` + +Expected: no output. A hit under `examples//` is code Parts 7–9 should have removed with that app's QML: +stop and report it — it belongs in that app's commit (master plan, "Every commit builds"). + +- [ ] **Step 2: Run it to verify it fails** + +```bash +/tmp/morph-retire-renderer.sh; echo "exit $?" +``` + +Expected: `exit 1`, listing the four paths as still present, the `MORPH_BUILD_FORMS_QML`/`qt_forms`/`forms_qml` +lines of `CMakeLists.txt`, `cmake/morphConfig.cmake.in`, `CMakePresets.json`, the workflows, the scripts and +`README.md`, and "MORPH_BUILD_FORMS_QML is still read by the configure". + +- [ ] **Step 3: Implement** + +Delete the sources: + +```bash +git rm -r src/qt/forms include/morph/qt/forms include/morph/qt/bridge scripts/check_forms_qml_install.sh +``` + +`CMakeLists.txt`: + +1. Delete the line `option(MORPH_BUILD_FORMS_QML "Build the shipped Qt/QML forms renderer module (MorphForms) and its demo" OFF)`. +2. Replace the comment `# Both Qt consumers (the WebSocket backend and the forms QML renderer) need` / + `# Qt6 auto-detected on Windows before their find_package(Qt6 ...) calls run.` and the condition below it with: + + ```cmake + # Every Qt consumer (the WebSocket backend, the Qt Quick frontend, which requires it) needs Qt6 + # auto-detected on Windows before its find_package(Qt6 ...) call runs. + if(WIN32 AND MORPH_BUILD_QT) + ``` + +3. Public-header guard: delete the list entry `"include/morph/qt/forms/:morph_qt_forms"`; delete `morph_qt_forms` + from the `foreach(_morph_target IN ITEMS …)` list; in the comment above, delete `morph::qt_forms, ` from + "(morph::net, morph::qt, morph::qt_forms, …)". +4. The offline-sqlite block's comment: delete " -- see the identical note on morph::qt_forms vs the plain + morph_forms_moduleplugin just below" (the sentence ends at "the point it is named)."). +5. Delete the whole "Qt/QML forms renderer setup (optional)" block: its comment and the `if(MORPH_BUILD_FORMS_QML)` + … `endif()` that creates `morph_qt_forms`. +6. "Application ladder (optional)" comment: replace "Deferred to here (after the Tests section above), the same way + MORPH_BUILD_FORMS_QML's src/qt/forms subdirectory is deferred further below:" with "Deferred to here (after the + Tests section above):". +7. Delete the whole "Qt/QML forms renderer (optional)" block (`add_subdirectory(src/qt/forms)`). +8. Install: delete the word `qt_forms` from `foreach(_morph_component IN ITEMS offline_sqlite qt_forms qt net)`. + Parts 3 and 4 left that line byte-identical and gave `tui` and `qt_quick` install blocks of their own, so the + result is `foreach(_morph_component IN ITEMS offline_sqlite qt net)` — the line the + `scripts/test_check_install_export.sh` edit below matches byte for byte. +9. Delete the MorphForms install block: from its comment `# The MorphForms QML module (MORPH_BUILD_FORMS_QML): …` + through the `endif()` closing `if(TARGET morph_forms_module)`, the `set(MORPH_INSTALL_QMLDIR …)` cache variable + included. Then `git grep -n MORPH_INSTALL_QMLDIR` prints only `PATH_VARS MORPH_INSTALL_QMLDIR` in + `configure_package_config_file(…)` — Part 4 installs its private `MorphUi` module inside its own `qt_quick` block + and names no such variable — so delete that line too. Any other hit is a consumer no plan declared: stop and + report it. + +`cmake/morphConfig.cmake.in`: delete + +```cmake +if("qt_forms" IN_LIST morph_KNOWN_COMPONENTS) + find_dependency(Qt6 6.5 COMPONENTS Core) +endif() +``` + +and the comment block beginning `# The MorphForms QML module: morph::forms_qml (backing library) and` through the +`endif()` of `if("forms_qml" IN_LIST morph_KNOWN_COMPONENTS)`. + +`CMakePresets.json`: in `windows-everything` and `linux-everything`, delete `"MORPH_BUILD_FORMS_QML": "ON",` (Parts 3 +and 4 added `"MORPH_BUILD_TUI": "ON"` and `"MORPH_BUILD_QT_QUICK": "ON"` to both). Validate: +`python3 -m json.tool CMakePresets.json > /dev/null`. + +`scripts/test_check_install_export.sh`, the case "no optional component installed, leaving the export-name check +with nothing to read": change the sed expression's left-hand side to the `foreach(_morph_component IN ITEMS …)` +line from item 8, exactly: + +```bash + "edit CMakeLists.txt -e 's@foreach(_morph_component IN ITEMS offline_sqlite qt net)@foreach(_morph_component IN ITEMS \"\")@'" \ +``` + +`scripts/check_coverage_objects.sh`: delete the three-line arm + +```bash + morph_forms_qml_tests|morph_forms_controller_core_tests) + echo "GAP: built by -DMORPH_BUILD_FORMS_QML=ON, …" + ;; +``` + +`.github/workflows/ci.yml`: + +- `env:` — replace `# MORPH_BUILD_FORMS_QML needs Qt 6.5+; ubuntu-24.04 apt still ships 6.4.2.` with + `# examples/common and the Qt Quick frontend need Qt 6.5+; ubuntu-24.04 apt still ships 6.4.2.` +- The two "Install Qt" comments in the ladder jobs (the one beginning "Not the distro's Qt: examples/common/ + CMakeLists.txt requires 6.5+ unconditionally (not gated on MORPH_BUILD_FORMS_QML)" and the one beginning + "(QQmlApplicationEngine::loadFromModule, used by MORPH_BUILD_FORMS_QML rungs)"): replace each with + + ```yaml + # Not the distro's Qt: examples/common/CMakeLists.txt requires Qt 6.5+ + # (qt_standard_project_setup(REQUIRES 6.5)) and Ubuntu 24.04 still ships + # 6.4.2 -- the gap the "all optional features" job's identical step + # documents. + ``` + + keeping each step's own remaining sentences that do not mention `MORPH_BUILD_FORMS_QML`. +- `linux-all-features`: the "Install Qt" comment becomes `# Not the distro's Qt: the ladder and the Qt Quick + frontend need 6.5+ and Ubuntu 24.04 still ships 6.4.2.`; in "Configure (every optional feature ON)" delete + `-DMORPH_BUILD_FORMS_QML=ON \` and replace the comment paragraph beginning "MORPH_BUILD_LADDER belongs in this job + by its own charter" through "…and the smoke test runs, on every push." with + + ```yaml + # MORPH_BUILD_LADDER belongs in this job by its own charter ("every + # MORPH_BUILD_* option … enabling them together also proves they + # compose"): every rung's binary and its frontend smoke tests are + # built here against both frontends. + ``` + +- `clang-tidy`: delete `-DMORPH_BUILD_FORMS_QML=ON \` from "Configure (generates compile_commands.json …)". +- `clang-tidy`, the step "Generate the AUTOMOC headers two sources include by name": both sources it names + (`examples/common/testkit/test_qml_surface.cpp`, removed in Task 11, and `src/qt/forms/tests/tst_main.cpp`) are + gone after this group. Run + + ```bash + git grep -lE '^[[:space:]]*#[[:space:]]*include[[:space:]]+"[^"]+\.moc"' -- '*.cpp' '*.cc' '*.cxx' \ + ':!examples/common/testkit/test_qml_surface.cpp' ':!src/qt/forms' + ``` + + If it prints nothing, delete the step together with its preceding comment block (from "# Two sources end with + `#include \".moc\"`" to the end of its `run:`), and in "Run clang-tidy-diff on changed lines"'s + comment delete the paragraph beginning "It is not sufficient for a generated header that a source includes + *by name*." through "…why this job builds two targets.". If it prints files, keep the step: replace its target + list with each printed source's `_autogen` target, and its comment's two file names with the printed + ones. +- Delete the whole `install-export-forms-qml:` job. + +`.github/workflows/nightly-slow-checks.yml`: in the header comment replace "including MORPH_BUILD_LADDER + +MORPH_BUILD_FORMS_QML" with "including MORPH_BUILD_LADDER with both frontends"; delete `-DMORPH_BUILD_FORMS_QML=ON \`. + +`.github/workflows/wasm-ladder.yml`: delete `-DMORPH_BUILD_FORMS_QML=ON \` from "Configure"; in the comment above +the build loop, delete the clause listing reasons a target is skipped if it still names +`MORPH_BUILD_FORMS_QML off` (the remaining reasons stand). + +`README.md`: the prerequisites line "Optional: Qt 6 for the WebSocket transport and QML example." → "Optional: +Qt 6 for the WebSocket transport and the Qt Quick frontend."; the build-options list ("Relevant CMake options: …", +where Part 3 added `` `MORPH_BUILD_TUI`, `` after `` `MORPH_BUILD_QT`, ``): replace `` `MORPH_BUILD_FORMS_QML`, `` +with `` `MORPH_BUILD_QT_QUICK`, ``; the components sentence (Part 3 added `tui` after `net`, Part 4 `qt_quick` after +`qt`): replace "`qt_quick` (`MORPH_BUILD_QT_QUICK`, which needs `qt`), and `qt_forms` and `forms_qml` (both +`MORPH_BUILD_FORMS_QML`)" with "and `qt_quick` (`MORPH_BUILD_QT_QUICK`, which needs `qt`)" — and delete the paragraph +beginning "`forms_qml` is the `MorphForms` QML module", its `cmake` block, and the paragraph after it ending "builds +and runs such an application against an install." + +`CONTRIBUTING.md`: in the options list "(`MORPH_BUILD_TESTS`, `MORPH_BUILD_EXAMPLES`, `MORPH_BUILD_QT`, +`MORPH_BUILD_FORMS_QML`, …)" replace `` `MORPH_BUILD_FORMS_QML` `` with `` `MORPH_BUILD_TUI`, `MORPH_BUILD_QT_QUICK` ``. + +`CHANGELOG.md`, under `## [Unreleased]` → `### Removed`, as the first entry: + +```markdown +- **The Qt/QML forms renderer and the Qt components behind it.** `src/qt/forms` — the `MorphForms` QML + module (`DynamicForm`, `CollectionView`, `WizardView`, `SlotRegistry`, `DateTimePicker`, `JsonExact.js`) + and `I18nCatalog` — with its QuickTest suite; `include/morph/qt/forms/` (`FormsControllerCore`, + `MultiModelFormsControllerCore`) and `include/morph/qt/bridge/` (`GenericModelBridgeCore`, + `MultiModelBridgeCore`); the `qt_forms`, `forms_qml` and `forms_qmlplugin` install components, their + package-config blocks and `morph_QML_IMPORT_PATH`; and the `MORPH_BUILD_FORMS_QML` option. Forms render + through the C++ forms engine instead — `forms::formView`, `collectionView`, `wizardView` and `appShellView` + over `FormSession` and its siblings (`include/morph/forms/engine/`, in the base `morph` target) — on any + `morph::ui` frontend, `morph::tui` or `morph::qt_quick`. `forms::Overrides` replaces `SlotRegistry`; a + `render::TranslationProvider` in `FormSessionOptions` replaces `I18nCatalog`; `forms::bridgeSubmitter` and + `forms::bridgeChoiceFetcher` replace the controller cores. `morph::qt` (the WebSocket transport and + `QtExecutor`) is unchanged. Pre-1.0, per `docs/spec/VERSIONING.md`. +``` + +- [ ] **Step 4: Run the checks to verify they pass** + +```bash +/tmp/morph-retire-renderer.sh && echo "renderer retired" +cmake -S . -B build/all && cmake --build build/all && QT_QPA_PLATFORM=offscreen ctest --test-dir build/all --output-on-failure +cmake -S . -B build/qt -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_EXAMPLES=OFF -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_QT_QUICK=ON +cmake --build build/qt && QT_QPA_PLATFORM=offscreen ctest --test-dir build/qt --output-on-failure +bash scripts/test_check_install_export.sh +bash scripts/check_install_export.sh +``` + +Expected: `renderer retired`; both trees build and every test passes (`build/qt` proves `morph::qt` keeps +`qt_executor.hpp` in its own header set, verified standalone by `VERIFY_INTERFACE_HEADER_SETS`); the install +checker's self-test ends "detects every install/export defect it claims to", and the checker passes. + +Mutation checks: +- Re-add `-DMORPH_BUILD_FORMS_QML=ON \` to `nightly-slow-checks.yml`'s configure: `/tmp/morph-retire-renderer.sh` + exits 1 naming that line. Restore. +- Restore the old `foreach(_morph_component IN ITEMS offline_sqlite qt_forms qt net)` text in + `scripts/test_check_install_export.sh`'s sed expression: `bash scripts/test_check_install_export.sh` fails on + "no optional component installed, leaving the export-name check with nothing to read" (the edit matches nothing, + so the defect it injects is never injected). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add -A src/qt include/morph/qt scripts CMakeLists.txt cmake/morphConfig.cmake.in CMakePresets.json \ + .github/workflows README.md CONTRIBUTING.md CHANGELOG.md +git commit -m "wip(retire): remove the QML forms renderer and its Qt components + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 10: `morph_add_rung` loses the old client conventions + +**Files:** +- Modify: `cmake/morph_add_rung.cmake` — the header's directory table; the `EMSCRIPTEN` guard; delete the + `gui_lib/`, `gui/qml/`, `gui/` and `gui_wasm/` blocks; the tests block's `gui_lib` link and `_qml_plugin` block; + delete the `src/headless/` block; add the refusal of a leftover old client directory +- Modify: `.github/workflows/wasm-ladder.yml` — the build loop's `gui_wasm` fallback and the comment naming it + +**Interfaces:** +- Consumes: Part 6's `app/` → `ladder__app`, `ui/*.cpp` → `` and `tests/smoke/*.cpp` → + `ladder__smoke_tests` blocks (unchanged), its Qt-free target `morph_ladder_app_common`; Part 8's + `src/server/app/` → `ladder__server_app` block (unchanged); Part 9's kanban, which builds + `ladder_kanban_headless` from `examples/kanban/headless/main.cpp` in its own `CMakeLists.txt`. +- Produces: `morph_add_rung()` creates `ladder__{lib,app,server_app,server,tests,smoke_tests}` and ``; + a rung with a top-level `gui/`, `gui_lib/` or `gui_wasm/` directory fails the configure. + +- [ ] **Step 1: Write the failing check** + +A stray old-convention directory must fail the configure loudly, and no old target may be generated: + +```bash +cat > /tmp/morph-retire-rung.sh <<'SH' +#!/usr/bin/env bash +set -u +status=0 +mkdir -p examples/pastebin/gui_lib +printf '// SPDX-License-Identifier: Apache-2.0\n' > examples/pastebin/gui_lib/stray.cpp +cmake -S . -B build/all > build/retire-rung.log 2>&1 +grep -q "examples/pastebin/gui_lib/ is not a ladder directory" build/retire-rung.log \ + || { echo "a stray gui_lib/ was not refused"; status=1; } +rm -rf examples/pastebin/gui_lib +cmake -S . -B build/all > build/retire-rung.log 2>&1 || { echo "configure failed without the stray"; status=1; } +if cmake --build build/all --target help | grep -E '_gui_lib|_qml\b|_qmlplugin|_gui_wasm|ladder_[a-z]+_gui:'; then + status=1 +fi +if git grep -nE 'gui_lib|_qml_plugin|_qml_uri|MORPH_LADDER_QML_URI|MORPH_LADDER_TESTKIT_GUI_APP|morph::ladder_app([^_]|$)|morph_ladder_app([^_]|$)|morph::ladder_gui|morph_qt_forms|FORMS_QML|src/headless' \ + -- cmake/morph_add_rung.cmake; then + status=1 +fi +if git grep -nE 'gui_wasm|gui_lib' -- .github/workflows/wasm-ladder.yml; then + status=1 +fi +exit $status +SH +chmod +x /tmp/morph-retire-rung.sh +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +/tmp/morph-retire-rung.sh; echo "exit $?" +``` + +Expected: `exit 1` — "a stray gui_lib/ was not refused" (the configure creates `ladder_pastebin_gui_lib` instead), +the first `git grep` lists the `gui_lib`, `_qml_plugin`, `MORPH_LADDER_QML_URI`, `MORPH_LADDER_TESTKIT_GUI_APP`, +`morph::ladder_app` and `src/headless` lines of `cmake/morph_add_rung.cmake`, and the second the `gui_wasm` branch +Part 8 left in `wasm-ladder.yml`'s build loop. + +- [ ] **Step 3: Implement** + +In `cmake/morph_add_rung.cmake`: + +1. Header comment, "Directory -> target convention:" table: delete the rows for `gui_lib/*.cpp`, `gui/qml/*.qml`, + `gui/*.cpp`, `gui_wasm/*.cpp` and `src/headless/*.cpp`. The rows Part 6 added (`app/*.cpp`, `ui/*.cpp`, + `tests/smoke/*.cpp`) and Part 8 added (`src/server/app/*.cpp + src/server/include/`) stay as they are; in Part 8's + note below its row, delete "(the declarative client layout)" — every rung's client is that layout now. +2. The `if(EMSCRIPTEN)` guard at the top of the function: replace `morph_ladder_app` with `morph_ladder_app_common` + in both the `if(NOT TARGET …)` condition and its message. +3. Delete the `gui_lib/*.cpp -> ladder__gui_lib` block (from its leading comment through the `endif()` closing + `if(_gui_lib_sources)`), the `gui/qml/*.qml` block (`set(_qml_plugin "")` through the `endif()` closing + `if(_qml_files AND TARGET morph_qt_forms)`, with the comment above it), the desktop `gui/*.cpp` block (the + `if(NOT EMSCRIPTEN)` holding `_gui_sources`) and the `gui_wasm/*.cpp` block (the `if(EMSCRIPTEN)` holding + `_gui_wasm_sources`), each with its leading comment. +4. Tests block: delete + + ```cmake + if(TARGET ladder_${_rung}_gui_lib) + target_link_libraries(ladder_${_rung}_tests PRIVATE morph::ladder_${_rung}_gui_lib) + endif() + ``` + + and the `if(_qml_plugin)` … `endif()` block (with its comment) that links the QML plugin and defines + `MORPH_LADDER_QML_URI` and `MORPH_LADDER_TESTKIT_GUI_APP`. +5. Delete the `# ── ladder__headless: QProcess test-client binary (rung 4+) ────` block (its `file(GLOB_RECURSE + _headless_sources … "${_dir}/src/headless/*.cpp")` through the `endif()` closing + `if(NOT EMSCRIPTEN AND _headless_sources AND TARGET ladder_${_rung}_gui_lib)`). Its condition can no longer hold + (no `gui_lib` target exists), and kanban, the one rung with a headless client, builds `ladder_kanban_headless` + from `examples/kanban/headless/main.cpp` in its own `CMakeLists.txt` (Part 9). Confirm first: + `ls -d examples/*/src/headless` prints nothing. +6. After the `set(_rung "${RUNG_NAME}")` line, add: + + ```cmake + # A rung's client is app/ (the toolkit-free app library, ladder__app) and ui/main.cpp (the binary, + # ). A top-level directory under another client name holds code no target builds, so it is refused + # here rather than skipped in silence. + foreach(_old_client_dir IN ITEMS gui gui_lib gui_wasm) + if(IS_DIRECTORY "${_dir}/${_old_client_dir}") + message(FATAL_ERROR + "morph_add_rung(${_rung}): examples/${_rung}/${_old_client_dir}/ is not a ladder directory, so " + "nothing would build it. Client code lives in examples/${_rung}/app/ (the toolkit-free app " + "library, ladder_${_rung}_app) and examples/${_rung}/ui/main.cpp (the binary, ${_rung}); see " + "examples/TESTING.md, \"App architecture\".") + endif() + endforeach() + unset(_old_client_dir) + ``` + +`.github/workflows/wasm-ladder.yml`, the step "Build the WASM-remote spike and every rung's WASM client": every rung +has `ui/main.cpp` now, so replace the loop body Part 8 wrote with + +```bash + if [ -f "examples/$rung/ui/main.cpp" ]; then + cmake --build build-wasm-ladder --target "$rung" + built=$((built + 1)) + else + echo "::notice::examples/$rung has no ui/main.cpp -- no browser client to build (see wasm-ladder.yml's comment)" + fi +``` + +and in the comment above the loop replace each mention of a rung's `gui_wasm` target or directory with its +`ui/main.cpp` binary, keeping every other reason a target is skipped. + +- [ ] **Step 4: Run the check to verify it passes** + +```bash +/tmp/morph-retire-rung.sh && echo "conventions retired" +cmake --build build/all && QT_QPA_PLATFORM=offscreen ctest --test-dir build/all -L ladder --output-on-failure +``` + +Expected: `conventions retired`; every ladder test passes, including every `.smoke.` case and kanban's +process-separation tests, which spawn `ladder_kanban_headless` (built by kanban's own `CMakeLists.txt`). + +Mutation check: remove `gui_lib` from the `foreach(_old_client_dir IN ITEMS …)` list; `/tmp/morph-retire-rung.sh` +exits 1 with "a stray gui_lib/ was not refused". Restore. + +- [ ] **Step 5: Commit** + +```bash +git add cmake/morph_add_rung.cmake .github/workflows/wasm-ladder.yml +git commit -m "wip(retire): morph_add_rung loses the gui/, gui_lib/, gui/qml/ and gui_wasm/ conventions + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 11: Remove `examples/common`'s Qt client stack + +**Files:** +- Delete: `examples/common/gui/` (`presenter.{hpp,cpp}`, `event_poller.{hpp,cpp}`, `error_text.{hpp,cpp}`, + `id_qml.hpp`, `app_context.{hpp,cpp}`) +- Delete: `examples/common/testkit/qml_surface.hpp`, `examples/common/testkit_src/qml_surface.cpp` +- Delete: `examples/common/testkit/test_presenter.cpp`, `test_error_text.cpp`, `test_id_qml.cpp`, + `test_event_poller.cpp`, `test_qml_surface.cpp` +- Modify: `examples/common/CMakeLists.txt` — header comment; the `MORPH_BUILD_QT` refusal's message; delete + `morph_ladder_gui` and `morph_ladder_app`; the WebAssembly comment; `morph_ladder_testkit`'s sources, comment and + links; `ladder_common_tests`' sources +- Modify: `examples/common/testkit/testkit_main.cpp` — the `QGuiApplication` branch +- Modify: `examples/common/testkit/pump.hpp` — delete `settle` +- Modify: `examples/common/testkit/client_pool.hpp`, `test_client_pool.cpp`, `backend_rig.hpp`, + `test_backend_rig.cpp`, `journey.hpp` — "presenter" becomes "client"/"controller" +- Modify: `examples/common/testkit/.clang-tidy` — the comment naming `qml_surface.cpp` + +**Interfaces:** +- Consumes: Part 6's `examples/common/app/` (`morph_ladder_app_common`, `examples_common_app_tests`) and + `morph_example_testkit` (`testkit/wait.hpp`, `testkit/fake_app_context.hpp`, `testkit/frontend_smoke.hpp`, + `testkit_src/frontend_smoke.cpp`), unchanged. +- Produces: `examples/common` targets `morph_ladder_app_common`, `morph_example_testkit`, `morph_ladder_testkit`, + `examples_common_app_tests`, `ladder_common_tests` (plus the WASM spike under Emscripten); + `morph::ladder::testkit::ClientPool`. + +- [ ] **Step 1: Write the failing check** + +```bash +cat > /tmp/morph-retire-common.sh <<'SH' +#!/usr/bin/env bash +set -u +status=0 +for path in examples/common/gui examples/common/testkit/qml_surface.hpp examples/common/testkit_src/qml_surface.cpp \ + examples/common/testkit/test_presenter.cpp examples/common/testkit/test_error_text.cpp \ + examples/common/testkit/test_id_qml.cpp examples/common/testkit/test_event_poller.cpp \ + examples/common/testkit/test_qml_surface.cpp; do + [ -e "$path" ] && { echo "still present: $path"; status=1; } +done +if git grep -niE 'presenter|qml_surface|QmlSurface|EventPoller|event_poller|id_qml|error_text|(^|[^_])app_context|AppContext::Mode|morph_ladder_gui|ladder_gui|morph_ladder_app([^_]|$)|ladder_app([^_]|$)|MORPH_LADDER_TESTKIT_GUI_APP' \ + -- examples/common; then + status=1 +fi +exit $status +SH +chmod +x /tmp/morph-retire-common.sh +``` + +Pre-condition — no rung or app still uses the stack: + +```bash +git grep -lE 'gui/(presenter|event_poller|error_text|id_qml|app_context)\.hpp|testkit/qml_surface\.hpp|morph::ladder_gui|morph::ladder_app([^_]|$)|ClientPool<.*Presenter' \ + -- examples ':!examples/common' +``` + +Expected: no output; a hit is a Part 7–9 leftover — stop and report it. + +- [ ] **Step 2: Run it to verify it fails** + +```bash +/tmp/morph-retire-common.sh; echo "exit $?" +``` + +Expected: `exit 1`, listing the eight paths and the matching lines of `examples/common/CMakeLists.txt`, +`testkit_main.cpp`, `pump.hpp`, `client_pool.hpp`, `backend_rig.hpp`, `journey.hpp`, `test_client_pool.cpp`, +`test_backend_rig.cpp` and `testkit/.clang-tidy`. + +- [ ] **Step 3: Implement** + +```bash +git rm -r examples/common/gui examples/common/testkit/qml_surface.hpp examples/common/testkit_src/qml_surface.cpp \ + examples/common/testkit/test_presenter.cpp examples/common/testkit/test_error_text.cpp \ + examples/common/testkit/test_id_qml.cpp examples/common/testkit/test_event_poller.cpp \ + examples/common/testkit/test_qml_surface.cpp +``` + +`examples/common/CMakeLists.txt`: + +1. Header: `# Shared ladder infrastructure: the presenter architecture (gui/) and the` / `# testkit (testkit/). See + examples/TESTING.md.` → `# Shared ladder infrastructure: what every rung's client shares (app/, target` / + `# morph_ladder_app_common) and the testkit (testkit/). See examples/TESTING.md.` +2. The `if(NOT MORPH_BUILD_QT)` refusal: replace its comment with + + ```cmake + # MORPH_BUILD_QT is required in every configure: the rungs' servers are Qt (QCoreApplication + + # QtWebSocketServer), the testkit's BackendRig Socket mode and the fault-injection proxy are Qt, and a + # rung's binary uses QtWebSocketBackend for --server on Qt Quick (and in the browser, where it is the only + # transport). + ``` + + and its message with `"MORPH_BUILD_LADDER requires MORPH_BUILD_QT=ON: the ladder's servers, the testkit's + BackendRig Socket mode and the fault-injection proxy all need morph::qt (Qt6::WebSockets)."` +3. Delete the `# ── morph_ladder_gui: …` block and the `# ── morph_ladder_app: AppContext …` block, comments + included. +4. The `# ── WebAssembly build ──` comment: replace its first paragraph with + + ```cmake + # Everything above this line builds under Emscripten and is what a rung's browser binary needs: the shared + # app layer (morph_ladder_app_common), whose remote transport there is QtWebSocketBackend. Everything below + # does not: morph_ladder_testkit and ladder_common_tests need Catch2 (MORPH_BUILD_TESTS is never part of a + # WASM configure) and the Lightweight ORM speaks ODBC, which does not exist in a browser + # (examples/IMPLEMENTATION.md rule 4's WASM clause). + ``` + + and delete its second paragraph ("Rung 0 returned *before* the two targets above as well, …"). +5. `morph_ladder_testkit`: delete `testkit_src/qml_surface.cpp` and `testkit/qml_surface.hpp` from its sources; + in `target_link_libraries(morph_ladder_testkit PUBLIC …)` delete `morph::ladder_gui morph::ladder_app` and, if + not already there, add `morph::ladder_app_common`. In the comment above it: "the library already links non-empty + TUs (fault_proxy.cpp, qml_surface.cpp)" → "the library already links a non-empty TU (fault_proxy.cpp)"; delete + the sentence beginning "qml_surface.cpp is a .cpp for the opposite reason"; "Those two TUs live in + testkit_src/" → "That TU lives in testkit_src/"; "Placed in testkit/ these two would inherit" → "Placed in + testkit/ it would inherit"; "Both headers are listed rather than only the one carrying a Q_OBJECT today, so the + move is moc-neutral by construction instead of by which header happens to need moc." → "The header is listed so + the move stays moc-neutral by construction."; "Which is why the two headers are listed as sources below" → + "Which is why the header is listed as a source below". +6. `ladder_common_tests`: delete the source lines `testkit/test_presenter.cpp`, `testkit/test_error_text.cpp`, + `testkit/test_id_qml.cpp`, `testkit/test_event_poller.cpp` and `testkit/test_qml_surface.cpp`. + +`examples/common/testkit/testkit_main.cpp`, whole file: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// Qt-owning Catch2 main for the ladder's test binaries. The rig's Socket mode, the fault proxy and the rungs' +// servers are QObjects, so a QCoreApplication must outlive every QObject Catch2 constructs during the run and be +// destroyed before static teardown, or Qt's cleanup runs against a torn-down application. Frontend smoke tests +// live in binaries of their own whose main owns no Qt application object (ladder__smoke_tests, over +// morph_test_main), because the Qt Quick frontend constructs its own; so this main never needs a GUI application. + +#include +#include +#include +#include + +int main(int argc, char* argv[]) { + QCoreApplication app{argc, argv}; + Catch::Session session; + int const result = morph::testkit::runSession(session, argc, argv); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + QCoreApplication::processEvents(QEventLoop::AllEvents); + return result; +} +``` + +Before writing it, confirm the claim in its last sentence: `git grep -ln 'runFrontendSmoke' -- examples` lists only +files under a `tests/smoke/` directory (compiled into `ladder__smoke_tests`, `bank_smoke_tests` or +`forms_app_smoke_tests`) and `examples/common/app/tests/test_frontend_smoke.cpp` (`examples_common_app_tests`, also +over `morph_test_main`). A hit compiled into a `ladder__tests` binary is a smoke case running under this Qt +`main`, which Part 6's helper refuses: stop and report it — it belongs in that rung's commit. + +`examples/common/testkit/pump.hpp`: run `git grep -n 'testkit::settle' examples`; with no caller left, delete the +`settle` template and its doc comment (the block beginning `/// @brief \`pumpUntil(!presenter.busy())\``). + +`examples/common/testkit/client_pool.hpp`: rename the template parameter `Presenter` → `Client` and the member +`_presenters` → `_clients`; the class comment becomes "`ClientPool` -- N client objects (a rung's +controller set) over one `BackendRig`'s N clients, each constructed from that client's `(Bridge&, IExecutor*)` +pair."; the constructor's `@brief` "Constructs one `Client` per client in @p rig, forwarding each client's +`(Bridge&, IExecutor*)` pair to its constructor."; `@param rig` "…to build the clients over"; `@param nClients` +"How many clients to construct -- …"; `at`'s `@return` "The client object for client @p index."; `size`'s +"How many clients this pool holds.". `examples/common/testkit/test_client_pool.cpp`: `FakePresenter` → +`FakeClient`, its comment "Minimal stand-in for a rung's controller set: takes the `(Bridge&, IExecutor*)` pair +`ClientPool` forwards, and builds its own `BridgeHandler` over it. Exercises `ClientPool` without depending +on any rung's concrete type, which the shared testkit must not"; the test name "ClientPool constructs one +presenter per client, each over its own bridge" → "ClientPool constructs one client object per client, each over +its own bridge"; the comments' "presenter(s)" → "client object(s)". + +`examples/common/testkit/backend_rig.hpp`: "the test's own presenters/handlers go first" → "the test's own +controllers/handlers go first"; "any Presenter built over this rig" → "any controller built over this rig"; +"a `Presenter` subclass" → "a controller"; "presenter tests need the raw bridge" → "controller tests need the raw +bridge"; "The second half of a presenter's `(Bridge&, IExecutor*)` pair" → "The second half of a controller's +`(Bridge&, IExecutor*)` pair". `test_backend_rig.cpp`: the test name "BackendRig exposes bridge/executor/url so +presenters compose over it" → "…so controllers compose over it"; its comment "The pair a Presenter subclass is +constructed from" → "The pair a controller is constructed from", "which a presenter that builds its own" → "which a +controller that builds its own". `journey.hpp`: "a presenter test proves one call wires through" → "a controller +test proves one call wires through". + +`examples/common/testkit/.clang-tidy`: replace the sentence "The two translation units of `morph_ladder_testkit` -- +`fault_proxy.cpp` and `qml_surface.cpp`, neither containing a single REQUIRE -- are therefore kept in +`examples/common/testkit_src/`, one directory up and outside this file's reach." with "The library translation units +-- `morph_ladder_testkit`'s `fault_proxy.cpp` and `morph_example_testkit`'s `frontend_smoke.cpp` -- are therefore +kept in `examples/common/testkit_src/`, one directory up and outside this file's reach." (if Part 6 already named +`frontend_smoke.cpp` there, only the `qml_surface.cpp` clause goes). + +- [ ] **Step 4: Run the checks to verify they pass** + +```bash +/tmp/morph-retire-common.sh && echo "client stack retired" +cmake -S . -B build/all && cmake --build build/all +QT_QPA_PLATFORM=offscreen ctest --test-dir build/all -L 'ladder|examples-common' --output-on-failure +``` + +Expected: `client stack retired`; `ladder_common_tests`, `examples_common_app_tests`, every rung's tests and every +`ladder__smoke_tests` binary pass. + +Mutation check: put back, in `testkit_main.cpp`, the `#ifdef MORPH_LADDER_TESTKIT_GUI_APP` / `#include +` branch; `/tmp/morph-retire-common.sh` exits 1 naming the `#ifdef` line. Restore. + +- [ ] **Step 5: Commit** + +```bash +git add -A examples/common +git commit -m "wip(retire): remove examples/common's presenter, EventPoller, AppContext and QML-surface audit + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 12: `TESTING.md`, `IMPLEMENTATION.md`, `LADDER.md` — the app on an injected frontend + +**Files:** +- Modify: `examples/TESTING.md` — title and introduction; delete "Current state (verified, 2026-08)"; "Presenter + architecture" → "App architecture"; delete "The QML-surface drift guard"; "The dual-mode fixture"; "Pumping + discipline"; "Multi-client stress harness" (`client_pool`, convergence, test naming); "WASM reality"; "Build system + and CI"; "Framework gaps" item 2 +- Modify: `examples/IMPLEMENTATION.md` — rule 1's two presenter clauses; rule 2 → "One app, any frontend"; rule 5's + last bullet; rule 6's checklist item 2 +- Modify: `examples/LADDER.md` — the opening, the companion-documents paragraph, the extension-bag spike's result, + the rung-0 scope paragraph, the explicit-submit paragraph + +**Interfaces:** documentation of spec 4 §7 and of Tasks 9–11. + +- [ ] **Step 1: Write the failing check** + +```bash +/tmp/morph-doc-check.sh '[Pp]resenter|QML-surface|qml_surface\.hpp|testkit_src/qml_surface|QmlSurface|gui_lib|gui_wasm|morph_ladder_gui|ladder_gui|morph_ladder_app([^_]|$)|AppContext::Mode|EventPoller|MorphForms|DynamicForm|FormsControllerCore|FORMS_QML|Qt Quick Test|engine-load smoke' \ + examples/TESTING.md examples/IMPLEMENTATION.md examples/LADDER.md +``` + +(`/tmp/morph-doc-check.sh` is Task 7's.) The bare `qml_surface.cpp` is not in the pattern on purpose: the section +"What `examples/common`'s coverage number measures (audit, 2026-09)" is a dated measurement of the directory as it +stood that day, its rows name the file, and `examples/crm/README.md` cites the section — it stays as written. + +- [ ] **Step 2: Run it to verify it fails** + +Expected: exit 1 — `examples/TESTING.md` names missing paths (`examples/common/testkit/qml_surface.hpp`, +`examples/common/testkit/test_qml_surface.cpp`, `examples/common/gui/…`) and dozens of pattern hits across the +three files. + +- [ ] **Step 3: Implement** + +**`examples/TESTING.md`.** + +Title and introduction, up to the first `##`, become: + +```markdown +# Ladder testing strategy — controllers, views, dual deployment modes, multi-client stress + +Every rung of the [application ladder](LADDER.md) ships a client whose controllers are unit tested in **both +deployment modes** — in-process (`LocalBackend`) and client/server (`QtWebSocketBackend` against a +`RemoteServer`), including **N clients against one server** for stress tests — and whose views are tested on +`ui::testing::RecordingBackend`. This document is the binding convention; rung READMEs reference it instead of +restating it. + +**What this machinery is:** [`IMPLEMENTATION.md`](IMPLEMENTATION.md) rule 2 keeps views to bindings and +controllers to declarative state over morph's `Query`/`Mutation`, so the BackendRig / client-pool / convergence +stack is **a conformance harness for morph's client-side stack** — `Bridge`, backends, `morph::reactive`, +completions, attach/reconnect under a real event loop. It is owned by the testkit as framework coverage: the full +matrix runs once per framework surface it conforms, and each rung runs a *thin instantiation* (its controllers +through the rig, one suite per model — not a per-screen × 3-mode combinatorial matrix), which keeps the CI cost +curve flat. +``` + +Delete the section "## Current state (verified, 2026-08)". + +Replace the section "## Presenter architecture (every rung)" (through the end of its rule 7) with: + +```markdown +## App architecture (every rung) + +A rung's client is a toolkit-free **app library** — `examples//app/`, target `ladder__app`: +`controllers/`, `views/` and the rung's `ui::Application` — plus one binary, `examples//ui/main.cpp`, +target ``, which picks a frontend at run time (`ui::selectFrontend`: `--ui=`, else `MORPH_UI`, else the +first built frontend that can run) and hands it the application's factory. The same library and binary build +natively (terminal UI and Qt Quick) and for the browser (Qt Quick). + +1. **The app library is toolkit-free**, and the build enforces it: `ladder__app` links `morph`, + `morph_ladder_app_common` and the rung's domain library — nothing else — so a Qt, terminal or frontend include + in `app/` fails to compile. +2. **Views are bindings only.** `app/views/` builds `ui::Node` trees whose props bind to controller state; every + conditional, format and validation lives in a controller or a `Computed`. Schema-driven parts are + `forms::formView`, `collectionView` and `wizardView` over the controller's sessions. +3. **Controllers are tested headless**: a `reactive::Runtime` on a `MainThreadExecutor` or `StepExecutor`, the + multi-mode rig below (`Local`, `LocalSingleThread`, `Socket`), and waits through `testkit/wait.hpp` — never a + sleep. A controller owns, in order, its handlers, a `Store` if it has user-intent state, its `Query`s, + `Mutation`s and `Subscription`s, and its `Computed` projections, so the `CallbackScope`s inside them are + destroyed first. +4. **One view test per screen** on `ui::testing::RecordingBackend`: the screen's tree, and that its controls drive + the controller — a click, an edit or a choice reaches the controller, and the bound state comes back. +5. **One smoke test per frontend per app** (`testkit/frontend_smoke.hpp`): the application mounts and quits on the + terminal UI (scripted input) and on Qt Quick (offscreen). +6. **Fingerprints, journeys, convergence and stress harnesses** drive controllers. Polling is a `Query` with + `refreshEvery` on the frontend's `Scheduler` (`reactive::testing::ManualScheduler` in tests), so a test advances + time instead of waiting for it. +``` + +Delete the section "## The QML-surface drift guard" entirely. + +"## The dual-mode fixture": "N "clients" are N presenter sets over the shared bridge" → "N "clients" are N +controller sets over the shared bridge"; "each rung shipping a small headless-client binary that drives its +*presenters*, not raw handlers" → "…that drives its *controllers*, not raw handlers"; "Teardown order (encoded in +`~BackendRig`): presenters → client bridges →" → "Teardown order (encoded in `~BackendRig`): controllers → client +bridges →". + +"## Pumping discipline — no sleeps": retitle "## Waiting discipline — no sleeps" and replace its first two +paragraphs (through "…copied from `tests/qt/test_qt_websocket.cpp`.") with: + +```markdown +Controller tests wait through `examples/common/testkit/wait.hpp`: `pumpUntil(owner, done, budget)` runs the +owner executor (`MainThreadExecutor` or `StepExecutor`) until `done()` holds or the budget — scaled by +`MORPH_LADDER_DEADLINE_MS` through `deadline.hpp` — runs out. `testkit/pump.hpp` is the Qt event-loop pump +(`pumpUntil(pred, deadline)`, `awaitQt(Completion)`) and serves only what is Qt: the rig's `Socket` mode, +whose server and client transport are QObjects, and the fault proxy. A `sleep_for` outside these two headers is a +review-rejectable defect. Test binaries use the Qt-owning `main()` in `testkit/testkit_main.cpp` +(`QCoreApplication`, `Catch::Session`, a DeferredDelete drain). +``` + +In the next paragraph "routing its callbacks through a `QtExecutor` produces 165 TSan warnings" stays; in the one +after, "`pump.hpp` covers waiting on the *Qt loop*." → "`wait.hpp` and `pump.hpp` cover waiting on an owner +loop." + +"## Multi-client stress harness": the component table's first row becomes `` `testkit_main.cpp`, `wait.hpp`, +`pump.hpp`, `deadline.hpp`, `backend_rig.hpp`, `db_fixture.hpp`, `db_fault_fixture.hpp`, **fault proxy + strand +interleaver** `` | rung 0/1, and add a row `` `frontend_smoke.hpp` `` | every app. The `client_pool.hpp` bullet: +"typed pool constructing each client's controller set against `rig.client(i)`; test bodies are mode-blind." The +`convergence.hpp` bullet: "…rungs 0–2 use `settle()` + fingerprint equality…" → "…rungs 0–2 wait for every client's +controllers to go idle and compare fingerprints, without event cursors." The per-rung test naming line becomes: +"Per-rung test naming: `test_model_.cpp` (full mode matrix), the controller tests (full matrix), one view +test per screen, one frontend smoke test, `test_multiclient.cpp` `[stress]`, `test_offline.cpp` (rungs 4/6/7)." — +and if every rung of Parts 7–9 names its controller, view and smoke files one way (`ls examples/*/tests`), write +that pattern in place of the three descriptive phrases. + +Replace the section "## WASM reality" up to its "Open framework facts every rung must respect (verified):" line +with: + +```markdown +## WASM reality + +A rung's browser client is its ordinary binary: `ui/main.cpp` built under Emscripten with Qt Quick as the only +frontend and `-DMORPH_CLIENT_ONLY=ON`, so the browser runs the same app library as the desktop. It cannot be +unit-tested in a browser in CI today; three layers stand in: + +1. **`LocalSingleThread` mode natively** — the same controllers, WASM-shaped wiring, every test run. +2. **Compile gate** — `.github/workflows/wasm-ladder.yml` (and `wasm-demo.yml` for bank) builds each rung's binary + for wasm32-emscripten, so app code cannot drift away from the browser build. +3. **One scripted browser smoke** (emrun + Playwright against the built client) as an optional stage in the same + CI run. +``` + +and its first "Open framework facts" bullet with: "A WASM client over `QtWebSocketBackend` is compiled on every +qualifying PR — `wasm-ladder.yml` builds the rung-0 spike and every rung's binary under +`-DMORPH_LADDER_RUNGS=all` — but has never been *run* in CI: "does it build" is answered, "does it work in a +browser" is not." The remaining bullets stand. + +"## Build system and CI": + +- The `examples/common/` bullet ("declares exactly three consumable targets") becomes: + + ```markdown + - `examples/common/` declares three consumable targets: `morph_ladder_app_common` (STATIC, Qt-free: + what every example application's library and binary share — `AppEnvironment`, `connect`, `Poller`, + `Wiring`, `mapCompletion`, `newUuid`, the id helpers; the Qt and `morph::net` transports are compiled + into it only when their options are on), `morph_example_testkit` (morph + Catch2 + every built frontend: + `wait.hpp`, `fake_app_context.hpp` and the frontend smoke harness) and `morph_ladder_testkit` (morph + + Catch2 + Qt + Lightweight: the rig, the fixtures, the fault proxy, `pump.hpp`). A rung's + `ladder__app` links `morph_ladder_app_common`; its tests link `morph_ladder_testkit` and + `morph_example_testkit`; its `ladder__smoke_tests` link `morph_example_testkit` over + `morph_test_main`. Rungs link targets, never paths; the testkits never grow per-rung options. + ``` + + Check each clause against `examples/common/CMakeLists.txt` as Task 11 left it and + `examples/common/app/CMakeLists.txt` as Part 6 wrote it (what each target compiles and links) and correct the + clause, not the CMake, where they differ. +- The `morph_add_rung()` bullet: "creates `ladder__{lib,gui_lib,gui, gui_wasm,tests,headless}`" → "creates + `ladder__{lib,app,server_app,server,tests,smoke_tests}` and the binary `` (built when at least one + frontend is), refuses a rung that still has a top-level `gui/`, `gui_lib/` or `gui_wasm/` directory". Its sentence "every rung's + `src/`, `include/` and `gui_lib/`" (the `.clang-tidy` bullet) → "every rung's `src/`, `include/` and `app/`". +- The bullet beginning "Do **not** copy bank's `wasm/` shadow-header pattern" (Part 7's wording) becomes: + + ```markdown + - Do **not** copy bank's `wasm/` shadow-header pattern — a rung's `app/` library is toolkit-free, and copying + the pattern makes the WASM and native builds different programs, silently falsifying the "same client code" + DoD. One WASM configure builds every rung's binary (`.github/workflows/wasm-ladder.yml`, which also builds + rung 0's spike; it caches emsdk but has no compiler cache yet): the same `app/` library and the same + `ui/main.cpp`, with Qt Quick the only frontend. + + **What rung 1 learned doing this for real**: a client's controllers hold `BridgeHandler`s, so a WASM + client still *names* its rung's model type and therefore still includes its model header. … + ``` + + keeping the rest of the existing "What rung 1 learned" paragraph from "Every rung's models acquire their + `Lightweight::DataMapper` connection…" to its end, unchanged. +- The coverage-wiring bullet: "Every ladder CMake target (`morph_ladder_gui`, `morph_ladder_app`, + `morph_ladder_testkit`, and each rung's own targets)" → "Every ladder CMake target (`morph_ladder_app_common`, + `morph_ladder_testkit`, and each rung's own targets)". +- CI tiers, item 1's paragraph "Two pieces of this live outside that job as shipped…": replace its "**The GUI + half**" sentences (through "…so the omission is never silent.") with "**The frontends** — each rung's binary and + its smoke tests — need `MORPH_BUILD_TUI=ON` and `MORPH_BUILD_QT_QUICK=ON`, which the ladder jobs configure; with + neither, `morph_add_rung()` builds the app library and every test but the smoke tests, and no binary." Keep the + "**The WASM compile gate**" sentences. + +"## Framework gaps this strategy exposed": item 2 "→ `include/morph/core/bridge.hpp`, so `settle()` can be exact +rather than substituting presenter-level counters." → "→ `include/morph/core/bridge.hpp`, so a test's wait can be +exact rather than counting calls by hand." + +**`examples/IMPLEMENTATION.md`.** + +- Rule 1: "nothing domain-shaped may live in presenters, QML, `main()`, or free functions" → "nothing + domain-shaped may live in controllers, views, `main()`, or free functions"; in the offline carve-out, whose + kanban sentence Part 9 already rewrote to name `examples/kanban/app/controllers/offline_moves.cpp`, replace + "**not** a presenter:" with "**not** a screen's controller:". +- Replace "## 2. GUI minimalism" with: + + ```markdown + ## 2. One app, any frontend + + The UI is deliberately the *least* interesting part of every rung. We are not building UIs; we are proving morph + can drive them — on every frontend, from one description. + + - **One app library, one binary.** A rung's client is `app/` — controllers and views, toolkit-free — and + `ui/main.cpp`, which picks the terminal UI or Qt Quick at run time and runs the app's factory on it + ([`TESTING.md`](TESTING.md), "App architecture"). Nothing in `app/` names a toolkit; the build refuses it. + - **Schema-driven first, always.** Every form is a `forms::FormSession` built from the schema the rung emits, or a + typed `forms::Form`, rendered by `forms::formView`; every list over an action is a `forms::collectionView` or + a `ui::table` bound to a `Query`; multi-step flows are `forms::wizardView`. Hand-built input rows, hand-built + tables and hand-rolled layouts are **forbidden by default**. + - **A custom view requires a written justification** in the rung README, and the only two acceptable + justifications are: (a) the generated UI *cannot* express the interaction — a forms-subsystem finding, so file + it on the gap ledger (this is how the ladder found the missing explicit-submit mode, the child-table renderer + gap and the sum-type gap — see [`LADDER.md`](LADDER.md)); or (b) pure glue with no domain logic (an app shell + frame, a connection-status line). + - **Controllers decide nothing the model owns.** They hold view state, issue `Query`s and `Mutation`s and project + results through `Computed`s; views bind. Validation, authorization and invariants stay in the model (rule 1). + - **Zero styling effort.** Each frontend's default look; no theming, no animations, no custom drawing. A rung that + looks pretty has spent effort in the wrong place. + ``` + +- Rule 5's last bullet: "GUI/presenter testing follows `TESTING.md`; there is no separate GUI logic to test if rule + 2 was followed — presenter tests verify routing, error surfacing, and quiescence, not business behavior." → + "Controller and view testing follows `TESTING.md`; there is no separate UI logic to test if rule 2 was followed — + controller tests verify routing, error surfacing and quiescence, view tests verify bindings, and neither tests + business behaviour." +- Rule 6, item 2: "Custom GUI elements present, each with its written justification…" → "Custom views present, each + with its written justification…". + +**`examples/LADDER.md`.** + +- Opening paragraph: "clients are Qt (desktop + WASM), as in [`bank`](bank)." → "clients are toolkit-free apps on + an injected frontend — the terminal UI or Qt Quick, chosen at run time, and Qt Quick in the browser — as in + [`bank`](bank)." +- The extension-bag spike's "**Complete:**" sentence: "the journal and the QML renderer needed nothing new" → "the + journal and the schema-driven renderer needed nothing new". +- The companion-documents paragraph: "(models are the application; minimal schema-driven GUIs; …)" → "(models are + the application; one app, any frontend, schema-driven first; …)"; and "how they are tested: every rung's GUI is + presenter-shaped and unit tested in **both deployment modes** (in-process `LocalBackend`, and + `QtWebSocketBackend` against an in-test `RemoteServer` with N clients) plus a WASM-shaped single-thread mode, via + the shared `examples/common/testkit`." → "how they are tested: every rung's controllers are unit tested in **both + deployment modes** (in-process `LocalBackend`, and `QtWebSocketBackend` against an in-test `RemoteServer` with N + clients) plus a WASM-shaped single-thread mode, its views on the `RecordingBackend`, and its binary by one smoke + test per frontend, via the shared `examples/common/testkit`." +- Bank's paragraph: Part 7 already rewrote it (no presenter, no QML surface audit); leave it. +- "## Rung 0, scope, and sequencing": "the shared presenter architecture (`examples/common/gui`)" → "the shared + client layer (`examples/common/app`)". +- The explicit-submit paragraph: "…and the shipped `DynamicForm.qml` renders its own Submit button — gated on the + form's `ready` state — instead of auto-firing on validity (`docs/spec/forms/forms.md`, "Explicit submit mode"). + Adoption is per rung and only `polls` has migrated so far, on all three of its schema-driven actions; + `bookmarks`, `lims` and `pastebin` still pair `controller: null` with a hand-written Button at thirteen call + sites between them." → "…and `forms::formView` renders a Submit button bound to the session's readiness instead + of submitting on every ready change (`docs/spec/forms/forms.md`, "Explicit submit mode"). Adoption is per + action." + +- [ ] **Step 4: Run the check to verify it passes** + +```bash +/tmp/morph-doc-check.sh '[Pp]resenter|QML-surface|qml_surface\.hpp|testkit_src/qml_surface|QmlSurface|gui_lib|gui_wasm|morph_ladder_gui|ladder_gui|morph_ladder_app([^_]|$)|AppContext::Mode|EventPoller|MorphForms|DynamicForm|FormsControllerCore|FORMS_QML|Qt Quick Test|engine-load smoke' \ + examples/TESTING.md examples/IMPLEMENTATION.md examples/LADDER.md && echo clean +pre-commit run --files examples/TESTING.md examples/IMPLEMENTATION.md examples/LADDER.md +grep -n '](#' examples/TESTING.md examples/IMPLEMENTATION.md examples/LADDER.md # every in-page anchor still names a heading +``` + +Expected: `clean`; the hooks pass; each `](#…)` anchor printed matches a heading in its file (read them — the +renamed "App architecture" and "Waiting discipline" sections are the ones to look for). + +Mutation check: add "See `examples/common/testkit/qml_surface.hpp`." to `examples/TESTING.md`; the check exits 1 +naming the missing path and the `qml_surface` hit. Remove it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/TESTING.md examples/IMPLEMENTATION.md examples/LADDER.md +git commit -m "wip(retire): TESTING, IMPLEMENTATION and LADDER describe the app on an injected frontend + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 13: Every other reference — specs, guides, library comments, tests, tooling + +**Files:** +- Modify: `docs/spec/forms/forms.md`, `views.md`, `workflows_navigation.md`, `choice.md`, `instance_constraints.md`, + `sections.md` — the QML-renderer sections and sentences that remain after Part 5 +- Modify: `docs/GETTING-STARTED.md` — only what the check below still prints (Part 8 rewrote §3–§12 for + pastebin's new client) +- Modify: `docs/todo.md`, `docs/spec/core/callback_scope.md`, `docs/spec/offline/offline.md` +- Modify: comments in `include/morph/forms/forms.hpp`, `include/morph/forms/flows.hpp`, + `include/morph/forms/views.hpp`, `include/morph/render/i18n.hpp`, `include/morph/render/locale_format.hpp`, + `include/morph/core/callback_scope.hpp` +- Modify: comments in `tests/` (`CMakeLists.txt`, `forms_rule_corpus.hpp`, `test_forms_*.cpp`, + `test_render_locale_format.cpp`, `test_quantity_forms.cpp`, `test_extension_bag_spike.cpp`, `test_completion.cpp`, + `testkit/catch_main.cpp`) — each line `git grep` names +- Modify: `examples/*/tests/.clang-tidy`, `examples/*/include/.clang-tidy`, `examples/crm/tests/.clang-tidy`, + `examples/crm/include/crm/gui/crm_schemas.hpp`, `examples/crm/EXTENSION-BAG-SPIKE.md` +- Modify: `scripts/coverage.sh`, `codecov.yml`, `scripts/check_automoc_includes.sh`, `cmake/compiler_options.cmake`, + `cmake/morph_demote_interface_includes.cmake` + +**Interfaces:** none. + +- [ ] **Step 1: Write the failing check** + +```bash +cat > /tmp/morph-retire-refs.sh <<'SH' +#!/usr/bin/env bash +set -u +pattern='DynamicForm|CollectionView\.qml|WizardView|AppShell\.qml|SlotRegistry|MorphForms|I18nCatalog|JsonExact|FormsControllerCore|GenericModelBridgeCore|MultiModelBridgeCore|src/qt/forms|qt/bridge/|tst_[A-Za-z_]+\.qml|QuickTest|Qt/QML|QML renderer|forms_qml|qt_forms|FORMS_QML|[Pp]resenter|gui_lib|gui_wasm|qml_surface|EventPoller|AppContext::Mode|examples/common/gui' +git grep -nE "$pattern" -- docs/spec docs/GETTING-STARTED.md docs/todo.md include tests scripts cmake codecov.yml \ + 'examples/*/tests/.clang-tidy' 'examples/*/include/.clang-tidy' examples/common examples/crm \ + ':!tests/lint/automoc_includes' \ + | grep -vE '^(cmake/compiler_options\.cmake|scripts/check_automoc_includes\.sh):[0-9]+:.*budget_presenter\.hpp' \ + | grep -vE '^cmake/compiler_options\.cmake:[0-9]+:.*NET/QT/FORMS_QML/OFFLINE_SQLITE/' \ + && exit 1 +for file in docs/spec/forms/*.md docs/GETTING-STARTED.md; do + /tmp/morph-doc-check.sh 'x^' "$file" || exit 1 # 'x^' matches nothing: paths only +done +exit 0 +SH +chmod +x /tmp/morph-retire-refs.sh +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +/tmp/morph-retire-refs.sh; echo "exit $?" +``` + +Run the path half once on the tree as it was before this part (`git stash -u; for f in docs/spec/forms/*.md +docs/GETTING-STARTED.md; do /tmp/morph-doc-check.sh 'x^' "$f"; done; git stash pop`): a path it already reports is +pre-existing — fix it if it names a file that moved, and say so in the hand-off either way. + +Expected: `exit 1`, printing the hits this task's Files list names (the counts per file before Parts 5–9 ran were +forms.md 98, workflows_navigation.md 25, views.md 17, GETTING-STARTED.md 8, the `tests/` comments about 25; Part 5 +added engine sections beside the spec ones without removing them, and Part 8 took GETTING-STARTED's). + +- [ ] **Step 3: Implement** + +Rewrite each printed line by this table — a sentence that only described the QML mirror is deleted, not +translated: + +| Retired name | Write instead | +|---|---| +| `DynamicForm`, `DynamicForm.qml` | `forms::formView` over a `FormSession` (the forms engine) | +| `CollectionView.qml` | `forms::collectionView` over a `CollectionSession` | +| `WizardView.qml` | `forms::wizardView` over a `WizardSession` | +| the demo's `AppShell.qml` | `forms::appShellView` over an `AppShellSession` | +| `SlotRegistry` (any tier, chrome slots) | `forms::Overrides` (`byField`, `byWidget`, `byUnit`, `byKind`) | +| `I18nCatalog` | a `render::TranslationProvider` in `FormSessionOptions::translations` | +| `JsonExact.js`, `JsonExact.parse` | the engine's JSON reading (glaze's `generic_u64`, which keeps integer digits exact) | +| `FormsControllerCore`, `MultiModelFormsControllerCore`, `submitIfValid`, `fetchOptions` | `forms::bridgeSubmitter` / `forms::bridgeChoiceFetcher` | +| `src/qt/forms/tests/tst_.qml` | the forms-engine test that re-expresses it (Part 5's plan maps every QuickTest file to its replacement, spec 2 §10) | +| `MorphForms`, `forms_qml`, `qt_forms`, `MORPH_BUILD_FORMS_QML` | delete the clause: the engine is in the base `morph` target | +| "the shipped (Qt/QML) renderer", "the QML mirror" | "the forms engine" | +| presenter (examples) | controller | +| `gui_lib/` (a rung directory) | `app/` | + +and these sections whole: + +`docs/spec/forms/forms.md`: + +- Replace "## Shipped Qt/QML reference renderer" — with its subsections "### Prefill — loading a stored payload for + editing" and "### What `ready` claims", up to "## Renderer conformance kit" — by: + + ```markdown + ## Rendering — the forms engine + + The schema contract above is renderer-agnostic. morph renders it with the forms engine + (`include/morph/forms/engine/`, specified in [engine.md](engine.md)): `FormModel::fromSchema` reads a schema into + fields, `FormSession` holds the drafts, encodes them exactly, evaluates the rules and submits, and + `forms::formView(session)` returns a `ui::Node` that every `morph::ui` frontend — the terminal UI and Qt Quick — + draws. A typed `forms::Form` builds the same model from `schemaJson()`, so a typed and a runtime form for + the same action behave identically. + + - **Prefill** is `FormSession::prefill(bodyJson)`: the reverse of every encoder, applied as one programmatic + change that never submits by itself. `reset()` clears every draft the same way. + - **`ready`** is `FormSession::ready()`: every required field encodes, no field has an error, and every gating + rule is `True`. It is a client-side convenience over the schema; the model's own `validate()` and the + dispatcher's rule check stay the authority (see "Field metadata is not a security control"). + ``` + +- "## Renderer conformance kit": replace the "**QML functional assertions**" bullet (and any further bullet naming a + `.qml` file) with: "- **Engine assertions** run the same five fixtures through `FormModel::fromSchema` and + `FormSession` ([engine.md](engine.md), "Parity and tests"): fields come out in `x-order`; readiness waits for every + `required` field; a `Quantity` body is `{num,den,dp}` exact and a unit switch converts it exactly; a `Choice` + carries its declared options action; a date-time encodes as ISO-8601 UTC." +- Replace "## Theming / component-override registry" with its "### Chrome slots" by: + + ```markdown + ## Overrides — replacing a field's control + + A field's control is the engine's default for its kind ([engine.md](engine.md), "Rendering"). An app that wants a + different control for one field, one `x-widget` hint, one unit or one kind registers it in `forms::Overrides`, + handed to `formView` through `FormViewOptions`: + + - `byField(path, render)`, `byWidget(hint, render)`, `byUnit(unit, render)` and `byKind(kind, render)`, resolved + in that order, then the default. `render` is `std::function`; `FieldView` exposes the + field's spec and its session state, so an override binds to the same draft and error the default control does. + - **`x-widget`** (optional, property-level) names a control variant when the type alone is ambiguous — + `"textarea"`, `"slider"`, `"radio"`, which the engine's defaults dispatch on (see "Widget hints"), or an + app-defined id an override recognises. Absent, it matches no `byWidget` override. + ``` + +- "### The catalog seam": replace the paragraph beginning "The `examples/forms/gui_qml` reference renderer hosts a + concrete, minimal realization" through the end of that subsection with: "The forms engine is the seam's + production caller: `FormSessionOptions::translations` is the provider and `FormSessionOptions::bcp47` the locale, + and every label, help text and placeholder a session shows goes through `resolveText`." +- "### Collections of objects — a host slot draws them" and "### Nested objects — a host slot draws them": retitle + "### Collections of objects — rows with Add and Remove" and "### Nested objects — a sub-panel"; keep every + sentence about the schema's shape; replace the sentences about a slot drawing them with "The engine draws an + object array as a `ForEach` of child panels, each with Remove, and an Add button, and a nested object as a + `Panel` holding its child fields; both encode and prefill like top-level fields. Nothing is unrepresentable." + (fix the two in-page links that name the old anchors). +- "### Two evaluators, one corpus": the evaluators are the typed rule nodes (`allRulesSatisfied`, the + dispatcher's check) and the engine's runtime evaluator (`include/morph/forms/engine/rules.hpp`), both C++, + compared row by row over `tests/data/rule_corpus.json` in one test binary — rewrite the section to say exactly + that. +- "#### What `DynamicForm` does with a nested aggregate" → "#### What the engine does with a nested aggregate", body: + "It draws it — a nested object as a sub-panel, an object array as rows with Add and Remove — and encodes and + prefills it like any top-level field." + +`docs/spec/forms/views.md`: replace "## The Qt/QML reference renderer" and "### Chrome slots and the embedded +editors" by: + +```markdown +## Rendering — `collectionView` + +`forms::collectionView(CollectionSession&)` renders a view document ([engine.md](engine.md), "Collections"): the +list is a `Query` on `v-query`, shown as a `Table` whose columns come from `v-columns`, its cells formatted +exactly; row and collection actions are `Mutation`s whose bodies are built from `bind` with exact ids; an action +with `confirm` opens a `Dialog` first and runs only on confirmation; a success refetches the list. Activating a +row opens the `v-rowAction` editor — a `FormSession` cleared and then prefilled from the row as one programmatic +change, so opening a row never submits by itself — as a dialog for `v-kind: "collection"` and a side form for +`"master-detail"`. + +`examples/forms`' `SamplesView` (`examples/forms/lab_views.hpp`, over `ListSamples`, `EditSample`, `DeleteSample` +and `CreateSample`) is the worked example; `morph_forms_app` shows it as an app-shell `view` screen. +``` + +and in "## Limitations" delete each bullet that describes the QML renderer's behaviour (lossy cell formatting +through a double, prefill reaching only plain text fields, the auto-fire on row open); keep the bullets about the +`v-*` vocabulary. + +`docs/spec/forms/workflows_navigation.md`: replace "## The Qt/QML reference renderer" and "### Chrome slots" by: + +```markdown +## Rendering — `wizardView` and `appShellView` + +`forms::wizardView(WizardSession&)` renders a wizard document ([engine.md](engine.md), "Wizards"): one +`FormSession` per step, kept alive for the session's lifetime, so a step's drafts survive Back and Next; a step is +done when its submission succeeded, and Next is enabled only then; entering a step applies its `prefill` from the +resolved values — the draft first, then the reply, the reply winning on a name collision, the rule +`FlowSession::captureResult` follows — as JSON values, after re-fetching the step's `Choice` options so a value +naming a row an earlier step created is among them. + +`forms::appShellView(AppShellSession&)` renders an `app-*` document: a title, and a `Menu` from `app-menu` beside the +current screen of `app-screens`, for the kinds `form`, `wizard` and `view` (`ViewScreen`). `AppShellSession` makes each +screen's session from `AppShellSources` the first time the screen is shown and keeps it, so a session outlives its +view: leaving a screen and coming back finds it as it was. +`examples/forms`' `LabApp` is the worked example. +``` + +In "## Limitations" delete the bullets "**`kind: "view"` is not implemented.**", "**No cross-screen state store in +the reference renderer.**" and "**Prefill does not resync a widget's displayed value.**" (if Part 5 has not); in +"## Testing" replace the bullet about `WizardView.qml`/`tst_wizardview.qml` with one naming the forms engine's +wizard tests (from Part 5's plan) and `examples/forms/tests/test_lab_wizard.cpp`; in "## Cross-references" drop the +clauses about `src/qt/forms` vs `examples/forms/gui_qml` and `CollectionView.qml`'s placement. + +`docs/spec/forms/instance_constraints.md`: "### What the shipped renderer does with them" → "### What the forms +engine does with them"; "`DynamicForm.qml` honours all three:" → "The engine honours all three:"; the table's +column "Effect in the renderer" → "Effect in the engine"; replace the boundary bullet "**The client compares a +`double`** …" with "**The engine compares exactly**: a bound's `{num,den}` is a `Rational`, compared with the +draft's `Rational`; no double is involved. The model's `checkValue` stays the floor." + +`docs/spec/forms/choice.md`: "and `DynamicForm.qml` resolves the `$ref`, reads `type`, and draws a checkbox for +`"boolean"`" → "and a renderer that resolves the `$ref` and reads `type` draws a checkbox for `"boolean"`"; "The +renderer parses an `OptionsAction` reply with `JsonExact.parse` (`src/qt/forms/qml/JsonExact.js`), which keeps an +integer literal a double cannot represent as its exact digits, and emits those digits verbatim into the submitted +body." → "The forms engine reads an `OptionsAction` reply with glaze's `generic_u64`, which keeps an integer +literal a double cannot represent as its exact digits, and writes those digits verbatim into the submitted body." + +`docs/spec/forms/sections.md`, "## Limitations": "No renderer ships for `s-*` yet. `WizardView.qml` has no +section-group counterpart in `src/qt/forms`; a host consuming the document builds its own layout for now." → "No +renderer ships for `s-*` yet: the forms engine has no section-group counterpart to `wizardView`, so a host +consuming the document builds its own layout." + +`docs/GETTING-STARTED.md`: Part 8 (its Task 7) rewrote §3, §4 and §9–§12 for pastebin's app library and binary. +Fix only the lines the check still prints, by the table above; a passage that only described the QML client is +deleted, not translated. + +`docs/todo.md`: in the "Removed the reactive-draft mechanism" entry drop ", and `WizardView.qml` are unchanged"; +E-G7: "and the `src/qt/forms` `CollectionView.qml` reference renderer" → "and `forms::collectionView`"; E-G8: "and +the `src/qt/forms` `WizardView.qml` reference renderer plus the demo's `AppShell.qml`" → "and `forms::wizardView` / +`forms::appShellView` (`ViewScreen` included)"; delete the "F3 reworks this" note under E-G8 if `FlowSession` no +longer uses the removed mechanism (`git grep -n 'set<&' include/morph/forms/flows.hpp` shows its own `set`). + +`docs/spec/core/callback_scope.md` and `include/morph/core/callback_scope.hpp`: the example class `BoardPresenter` → +`BoardController`. `docs/spec/offline/offline.md` (Part 9 already rewrote the kanban bullet to name +`examples/kanban/app/controllers/offline_moves.cpp`): the quotation of rule 1, "nothing domain-shaped may live in +presenters, QML, `main()`, or free functions", → "nothing domain-shaped may live in controllers, views, `main()`, or +free functions" (Task 12's rule 1, verbatim); "never in a presenter, a QML bridge, or `main()`" → "never in a +screen's controller, a view, or `main()`". + +Library comments: + +- `include/morph/forms/forms.hpp`, `annotateBasicMemberProperty`'s comment: "The shipped Qt/QML `DynamicForm` then + fires two mutually exclusive kind flags on it (`isBoolean` and `isArray` both true) and draws a checkbox whose + payload is a JSON array of the string "false", reporting the form `ready` for a value nobody chose." → "A + renderer reading that type set sees both a boolean and an array, and has no closed set to offer — it can only + guess a control, and any value it then submits is one the user did not choose."; the `static_assert` message's + "…and the shipped DynamicForm renders that wildcard as a checkbox reporting the form ready for a value the user + never chose." → "…which no renderer can turn into a choice of the declared values." +- `include/morph/forms/flows.hpp`: "(the shipped renderer's `DynamicForm` calls `submitIfValid` on every change that + leaves the form ready, with no in-flight suppression either)" → "(the forms engine's automatic submission sends + on every change that leaves a form ready with a changed body, with no in-flight suppression either)". +- `include/morph/forms/views.hpp`: "the same two shapes Choice's optionRows reads (DynamicForm.qml)" → "the same + two shapes the forms engine reads a `Choice`'s options from". +- `include/morph/render/i18n.hpp`: the `@file` paragraph's "The per-field widget-override registry that pairs with + it is *not* C++ at all: it is `SlotRegistry`, a QML type in module `MorphForms` (`src/qt/forms/qml/ + SlotRegistry.qml`, documented in docs/spec/forms/forms.md), so do not look for it under this namespace." → "The + per-field control override that pairs with it is `forms::Overrides` (`morph/forms/engine/overrides.hpp`)."; the + comment above `resolveText`: delete "The QML renderer carries a mirror of this function with the same parameters + in the same order (src/qt/forms/qml/DynamicForm.qml, `resolveText(explicitKey, derivedKey, literal)`), and the two + are meant to be read against each other. Reordering to break the adjacency here would desynchronise that pair + and" and continue the sentence "…leave the signature the only place…" as "Reordering it would leave the signature + the only place…". +- `include/morph/render/locale_format.hpp`: "-- matching the QML mirror in `src/qt/forms/qml/DynamicForm.qml`, which + rejects it too." → "." (end the sentence after "rejected"). + +Tests, scripts, tooling (comments only — no behaviour changes): + +- `tests/CMakeLists.txt`: the comment naming `src/qt/forms/tests/tst_DynamicFormRuleCorpus.qml` names the engine's + rule-corpus test instead. +- `tests/forms_rule_corpus.hpp`, `tests/test_forms_rule_corpus.cpp`, `tests/test_forms_rule_agreement.cpp`: "a + JavaScript reimplementation in `src/qt/forms/qml/DynamicForm.qml`" → "the forms engine's runtime evaluator + (`morph/forms/engine/rules.hpp`)"; the pin's "one file, two readers" names this suite and the engine's corpus + test. +- `tests/test_forms_{blank_as,boolean_anyof_wire,display_unit,exact_bounds,field_bounds}.cpp`, + `test_forms_instance_constraints.cpp`, `test_forms_conformance_corpus.cpp`, `test_forms_layout.cpp`, + `test_quantity_forms.cpp`, `test_render_locale_format.cpp`, `test_extension_bag_spike.cpp`: each "…`tst_X.qml` + pins the renderer half" → "…the forms engine's `` pins the renderer half" (the file Part 5 maps `tst_X` + to); each "the QML mirror …" sentence → "the forms engine …" or deleted where it only described the mirror. +- `tests/test_completion.cpp`: "This is the mechanism behind Presenter::track()'s onErr parameter + (examples/common/gui/presenter.hpp), documented here at its source." → "A `Mutation` relies on it: its error + handler is attached alongside the caller's." +- `tests/testkit/catch_main.cpp`: drop `src/qt/forms/tests` from the list of suites that own their `main`. +- Every `examples/*/tests/.clang-tidy`, `examples/*/include/.clang-tidy` and `examples/common/testkit/.clang-tidy`: + `perl -pi -e 's@src/, include/ and gui_lib/@src/, include/ and app/@' `; `examples/crm/include/.clang-tidy` + line 78's "(core/, dto/, gui/, …)" names `crm`'s own `include/crm/gui/` directory and stays; + `examples/crm/tests/.clang-tidy`'s measurement "(… examples/common/testkit_src/qml_surface.cpp, 132 -> 128)" is a + dated count: delete that parenthesis's file. +- `examples/crm/include/crm/gui/crm_schemas.hpp` (Part 9 rewrote the `@file` comment's first sentences): "Lives + under `include/crm/gui` rather than `gui_lib/`: it has no Qt dependency … a later step's real Qt presenters move it + there (or link against it) once `gui_lib/*.cpp` files exist to host them." → "Lives under `include/crm/gui`: it has + no Qt dependency, and crm has no client yet; one would include it from its `app/`." (keep the rest of the + comment). +- `examples/crm/EXTENSION-BAG-SPIKE.md`: the table row "**Forms client renderer** (`DynamicForm.qml`)" → "**Forms + client renderer** (the forms engine)"; its evidence cell's `DynamicForm.qml:227-330` citation is the record of the + measurement — reword to "verified at the time against the then-shipped QML renderer, which iterated + `schema.properties` generically; the forms engine does the same (`FormModel::fromSchema`)". +- `scripts/coverage.sh`: Part 8 made the rung loop `for _sub in include src gui_lib app; do`; make it + `for _sub in include src app; do`, and replace the comment Part 8 wrote above it (from "# include/ + src/ are each + rung's DTOs and models" through "# the frontend smoke tests and by hand.") with + + ```bash + # include/ + src/ are each rung's DTOs and models (rule 5's own 100% bar); + # app/ is its controllers and views, held to the same bar: it is real + # coverage of morph's client stack (morph::reactive, morph::ui, the forms + # engine), not app-specific domain logic. ui/ is deliberately absent: it is + # the main() shell (argv, frontend selection), with no unit-testable seam, + # exercised by the frontend smoke tests and by hand. + ``` + + and in the file's other comments (the ones `git grep -n -i 'presenter\|QML adapter\|test_event_poller' + scripts/coverage.sh` prints) write "controllers and views" for "presenters and QML adapters" and drop + `test_event_poller.cpp`, `test_presenter.cpp` from the list of Catch2 files named as examples. +- `codecov.yml`: Parts 8 and 9 rewrote each migrated rung's component comment and replaced its `gui/**` and + `gui_wasm/**` ignore entries with `ui/**`. What remains is every line `git grep -nE + 'gui_lib|gui_wasm|/gui/|[Pp]resenter|QML adapter' codecov.yml` prints — the header's "models/app/presenter code" + (→ "models and app code"), the older components' "presenters and QML adapters" (→ "controllers and views"), any + rung's leftover `gui/**`/`gui_wasm/**` ignore pair (→ one `examples//ui/**`), and crm's + "examples/crm/{include,src,gui_lib}" (→ "examples/crm/{include,src,app}"). A component's `target:` was set against the old directories: leave every + target as it is, and if a rung's component fails on the pull request, re-measure it per + `IMPLEMENTATION.md` rule 5's coverage-artifact guidance rather than lowering it blind. +- `scripts/check_automoc_includes.sh` and `cmake/compiler_options.cmake`: keep the quoted moc output (the + `budget_presenter.hpp` lines are what moc wrote, and `tests/lint/automoc_includes/` pins them); replace "every + ladder__gui_lib -- and every ladder__tests that links one -- stopped building at all." with "every + AUTOMOC target -- the ladder testkit, and every rung's server and test binary -- stopped building at all." and + "into a build failure -- ladder__gui_lib, and with it every ladder__tests binary that links one." with + "into a build failure -- morph_ladder_testkit, and with it every ladder__tests binary that links it." +- `cmake/morph_demote_interface_includes.cmake`: "ladder__gui_lib and ladder__tests" → + "ladder__app and ladder__tests". + +- [ ] **Step 4: Run the checks to verify they pass** + +```bash +/tmp/morph-retire-refs.sh && echo "references retired" +cmake -S . -B build/docs -G Ninja -DMORPH_BUILD_DOCUMENTATION=ON -DMORPH_BUILD_TESTS=OFF -DMORPH_BUILD_EXAMPLES=OFF +cmake --build build/docs --target doc +cmake --build build/all && QT_QPA_PLATFORM=offscreen ctest --test-dir build/all --output-on-failure +pre-commit run --all-files +``` + +Expected: `references retired`; the Docs build exits 0 (the edited library comments and the deleted +`include/morph/qt/{forms,bridge}` headers leave no dangling Doxygen reference); `build/all` passes (comment-only +changes, and `tests/` still compiles); the hooks pass. + +Mutation check: append `// DynamicForm` to `include/morph/forms/views.hpp`; `/tmp/morph-retire-refs.sh` exits 1 +printing that line. `git checkout -- include/morph/forms/views.hpp`. + +- [ ] **Step 5: Commit** + +```bash +git add -A docs include tests scripts cmake codecov.yml examples +git commit -m "wip(retire): every spec, guide and comment names the forms engine and the app layer + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 14: The whole-repository gate — only intended hits remain + +**Files:** none new; a hit is fixed in the file it names. + +The check spans every tracked file. These are the **allowed** hits, and the only ones: + +| Allowed | Why | +|---|---| +| `CHANGELOG.md` | Release history: its entries describe the renderer and the stack as they were when they shipped, and Task 9's "Removed" entry names them. | +| `docs/superpowers/**` | The program's design record (specs and plans) — it names what it retires. | +| `docs/analysis/qml-bridge-boilerplate.md` | A measurement taken at a stated revision; it records that revision's code. | +| `tests/lint/automoc_includes/**` | Synthetic fixtures for the AUTOMOC include lint; their file names are arbitrary. | +| `cmake/compiler_options.cmake`, `scripts/check_automoc_includes.sh` — the lines quoting `…/gui_lib/budget_presenter.hpp` and `"budget_presenter.hpp"` | moc's recorded output, pinned by the lint fixture above. | +| `cmake/compiler_options.cmake` — the line `configured (clang 22.1.8, clang-debug + NET/QT/FORMS_QML/OFFLINE_SQLITE/` | The configuration a recorded measurement was taken on. | +| `examples/crm/include/.clang-tidy` — `(core/, dto/, gui/, …)` | Names crm's own `include/crm/gui/` directory, which is not a rung client directory. | +| `examples/TESTING.md` — the lines naming `` `qml_surface.cpp` `` | Rows of the dated coverage audit (2026-09), which `examples/crm/README.md` cites; a measurement of the directory as it stood. | + +- [ ] **Step 1: Write the failing check** + +```bash +cat > /tmp/morph-retire-gate.sh <<'SH' +#!/usr/bin/env bash +set -u +pattern='DynamicForm|CollectionView\.qml|WizardView|AppShell\.qml|SlotRegistry|MorphForms|FormsControllerCore|MultiModelFormsControllerCore|GenericModelBridgeCore|MultiModelBridgeCore|OwnedLocalBridge|owned_local_bridge|I18nCatalog|JsonExact|qt_forms|forms_qml|MORPH_BUILD_FORMS_QML|MORPH_INSTALL_QMLDIR|morph_QML_IMPORT_PATH|check_forms_qml_install|src/qt/forms|qt/bridge/|tst_[A-Za-z_]+\.qml|[Pp]resenter|EventPoller|event_poller|qml_surface|QmlSurface|AppContext::Mode|examples/common/gui|id_qml|morph_ladder_gui|ladder_gui|morph_ladder_app([^_]|$)|ladder_app([^_]|$)|gui_lib|gui_wasm|MORPH_LADDER_TESTKIT_GUI_APP|MORPH_LADDER_QML_URI|morph_forms_qml|gui_qml' +git grep -nE "$pattern" -- . ':!CHANGELOG.md' ':!docs/superpowers' ':!docs/analysis/qml-bridge-boilerplate.md' \ + ':!tests/lint/automoc_includes' \ + | grep -vE '^(cmake/compiler_options\.cmake|scripts/check_automoc_includes\.sh):[0-9]+:.*budget_presenter\.hpp' \ + | grep -vE '^cmake/compiler_options\.cmake:[0-9]+:.*NET/QT/FORMS_QML/OFFLINE_SQLITE/' \ + | grep -vE '^examples/crm/include/\.clang-tidy:[0-9]+:.*\(core/, dto/, gui/' \ + | grep -vE '^examples/TESTING\.md:[0-9]+:.*`qml_surface\.cpp`' \ + && exit 1 +exit 0 +SH +chmod +x /tmp/morph-retire-gate.sh +``` + +The pattern covers every name the task lists — `DynamicForm`, `CollectionView.qml`, `WizardView`, `SlotRegistry`, +`MorphForms`, `FormsControllerCore`, `MultiModelFormsControllerCore`, `GenericModelBridgeCore`, `qt_forms`, +`forms_qml`, `MORPH_BUILD_FORMS_QML`, `Presenter`, `EventPoller`, `qml_surface`, `AppContext` (the old +`AppContext::Mode` and `examples/common/gui` forms; `ui::AppContext` is the new frontend seam and is not matched), +`gui_lib`, `morph_ladder_gui` — and the related names a reader would also grep for. + +- [ ] **Step 2: Run it** + +```bash +/tmp/morph-retire-gate.sh; echo "exit $?" +``` + +Expected after Tasks 9–13: `exit 0` with no output. Any printed line is a reference those tasks did not reach — +most likely in an app's README or code left by Parts 7–9 (`examples//README.md` describing its old QML +screens, a `.clang-tidy` comment, a CI comment). Fix each in place by Task 13's table (a README's "visible +differences from the old QML screens" paragraph, spec 4 §9, is written in the present tense: what the screen shows +now and what it does not draw — no "was"), then re-run until `exit 0`. + +- [ ] **Step 3: (no implementation beyond Step 2's fixes)** + +- [ ] **Step 4: Prove the gate detects what it claims** + +Mutation checks — each must make the gate exit 1 printing exactly the injected line, then be undone: + +```bash +echo '// DynamicForm' >> include/morph/forms/forms.hpp; /tmp/morph-retire-gate.sh; git checkout -- include/morph/forms/forms.hpp +echo '# gui_lib/' >> examples/pastebin/CMakeLists.txt; /tmp/morph-retire-gate.sh; git checkout -- examples/pastebin/CMakeLists.txt +echo 'morph::ladder_app' >> examples/common/CMakeLists.txt; /tmp/morph-retire-gate.sh; git checkout -- examples/common/CMakeLists.txt +echo 'ui::AppContext and morph_ladder_app_common' >> README.md; /tmp/morph-retire-gate.sh; git checkout -- README.md +``` + +Expected: the first three print their line and exit 1; the fourth prints nothing and exits 0 (the new names are not +matched). Then `/tmp/morph-retire-gate.sh && echo "only intended hits"`. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "wip(retire): the last references the repository-wide gate found + +Signed-off-by: Christian Parpart " +``` + +Skip the commit if Step 2 found nothing, and say so in the hand-off. + +--- + +### Task 15: Group verification, squash `retire`, and hand over to "Finishing the branch" + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Strict build and the full suite on `build/all`** + +```bash +cmake -S . -B build/all && cmake --build build/all +QT_QPA_PLATFORM=offscreen ctest --test-dir build/all --output-on-failure +``` + +Expected: every test passes; the configure printed `strict=ON`. + +- [ ] **Step 2: No frontend at all** (spec 4 §6: both frontends are optional) + +```bash +cmake -S . -B build/nofe -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_QT=ON -DMORPH_BUILD_LADDER=ON \ + -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_TUI=OFF -DMORPH_BUILD_QT_QUICK=OFF +cmake --build build/nofe && QT_QPA_PLATFORM=offscreen ctest --test-dir build/nofe --output-on-failure +``` + +Expected: builds and passes; no app binary exists (`morph_forms_app`, `bank`, the rung binaries), every app library +and its tests do. + +- [ ] **Step 3: Sanitizers, tidy, docs, install** + +```bash +cmake --preset clang-asan -DMORPH_BUILD_QT=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all +cmake --build --preset clang-asan +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-asan/examples/common/ladder_common_tests asan +QT_QPA_PLATFORM=offscreen ASAN_OPTIONS=detect_leaks=0 ctest --test-dir build/clang-asan -L ladder -LE stress +``` + +then CONTRIBUTING's `clang-tidy-diff` recipe over `origin/master...HEAD` (file count asserted non-zero; the +configure as `ci.yml`'s `clang-tidy` job now has it, without `MORPH_BUILD_FORMS_QML`), the Docs build (Task 13 +Step 4's two commands), and `bash scripts/test_check_install_export.sh && bash scripts/check_install_export.sh`. + +Expected: all clean. `ladder_common_tests` lost five self-test files; the instrumentation check is what makes its +clean run mean something. + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A +git commit -m "wip(retire): fixes from the whole-group gates + +Signed-off-by: Christian Parpart " +``` + +Skip the commit if there was nothing to fix, and say so in the hand-off. + +- [ ] **Step 5: Squash the group into its one commit** + +Follow the master plan's "Squashing a part" procedure with `key=retire` and this message (add a paragraph before the +trailer only for a deviation from this plan — what it said, what was done, why — e.g. a hit one of the +pre-condition greps of Tasks 9, 11 or 14 found and how it was resolved): + +```text +forms, examples: retire the QML renderer and the Qt client stack + +Removes the MorphForms QML module (src/qt/forms, its QuickTest suite), +the qt/forms and qt/bridge controller cores, the qt_forms, forms_qml and +forms_qmlplugin components and MORPH_BUILD_FORMS_QML: forms render +through the C++ forms engine on morph::tui or morph::qt_quick. Removes +examples/common's presenter, EventPoller, AppContext and QML-surface +audit, and morph_add_rung's gui/, gui_lib/, gui/qml/ and gui_wasm/ +conventions, which now fail the configure when a rung still has one. + +Signed-off-by: Christian Parpart +``` + +Expected last line of the procedure: `git log --oneline master..HEAD` shows the master plan's 18 commits, in order, +ending with `forms, examples: retire the QML renderer and the Qt client stack`. + +- [ ] **Step 6: Hand over** + +This was the program's last part. Continue with the master plan's "Finishing the branch" +(`docs/superpowers/plans/2026-10-04-declarative-ui-tui.md`): the whole-branch gates on `build/all` and Linux CI's +configurations, the no-frontend build, the WebAssembly configurations of `wasm-ladder.yml` and `wasm-demo.yml`, +`git rebase --exec "cmake --build build/all && ctest --test-dir build/all" master` so every commit stands alone, the +history check, and — each confirmed with the user first — pushing the branch, opening the pull request, and +deleting core-cpp's abandoned `feature/reactive-ui`. diff --git a/docs/superpowers/plans/2026-10-04-declarative-ui-tui-2-ui.md b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-2-ui.md new file mode 100644 index 000000000..bcd167092 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-2-ui.md @@ -0,0 +1,6607 @@ +# Declarative UI, Part 2 — `morph::ui` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Ship `morph::ui` — a toolkit-agnostic, immutable view tree whose props are constants or bindings, a +`Mounted` that builds retained widgets through an `IViewBackend` once and keeps them current through reactive +bindings, the frontend seam (`AppContext`, `Application`, `Frontend`, `selectFrontend`), and +`ui::testing::RecordingBackend` plus the backend-conformance cases that fix the contract the TUI and Qt Quick +backends implement. + +**Architecture:** Nodes are aggregates (`ui::Text`, `ui::Column`, …) wrapped by builder functions into +`ui::Node = std::shared_ptr`. Mounting walks the tree once: each node gets a widget from a typed +factory, each bound `Prop` becomes an equality-gated `Computed` plus an `Effect` calling one widget setter, and each +constant is set once; there is no diffing. Switch, Dialog, every Tabs page and every ForEach/Table row mount into a +`reactive::Scope` of their own, adopted widget-first, so a teardown destroys bindings before widgets and children +before parents. Every widget callback runs inside `Runtime::widgetEvent`. + +**Tech Stack:** C++23, header-only in the base `morph` target; Part 1's `morph::reactive`; `morph/util/datetime.hpp` +(`time::Timestamp`); Catch2 v3. + +**Spec:** `docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md` §1 (goals), §5 (view IR and mount), §5b +(frontend seam), §7 (the `ui`, frontend-seam and backend-conformance tests), §9 (docs). + +This is **Part 2 of 11** of the declarative-UI program. Read the master plan +`docs/superpowers/plans/2026-10-04-declarative-ui-tui.md` first: it fixes the branch, the commit layout and the +`wip(...)` convention every task below follows, and this part ends by squashing its `wip(ui)` commits into one. +The public names are fixed by `docs/superpowers/plans/2026-10-04-declarative-ui-tui-interfaces.md`, "Part 2"; Part 1 +(`morph::reactive`) has landed. + +## Global Constraints + +- Everything in Part 1's "Global Constraints" applies unchanged: SPDX line first, `#pragma once`, clang-tidy + naming, no history or issue numbers in comments, full Doxygen including `detail`, `-Weverything -Werror`, + clang-tidy clean, the sign-off trailer. +- `morph::ui` depends on `morph::reactive` and `morph/util/datetime.hpp` only. No header under `include/morph/ui/` + includes a toolkit, a terminal library, `morph/core/bridge.hpp` or anything under `morph/net`. +- Strings crossing the backend contract are UTF-8 (`std::string_view` into a setter, `std::string` out of a + callback). +- `Key = std::variant` — never a floating-point key. +- Parameter names are at least three characters: the contract's `int id` is spelled `widgetId` in code. +- Headers index containers with `.at()`, never `operator[]` + (`cppcoreguidelines-pro-bounds-avoid-unchecked-container-access` is on for `include/morph/**`). +- Misuse in this layer is reported through `reactive::detail::RuntimeCore::report` under the site names in + `ui::detail::site`, then refused. A test that triggers a report installs `morph::testing::OwnerProbeRecorder` + (a debug build asserts on a report nobody observes). +- Tests are tagged `[ui]`. + +## Review Focus + +1. **A row view that reads a signal directly while it builds its nodes** (instead of in a binding) must not + subscribe the ForEach to that signal — row construction is untracked (Task 5 test "building a row reads + nothing on the ForEach's behalf"). +2. **A Switch whose selector yields a key with no case and no fallback** mounts nothing and stays usable for the + next key (Task 4 test "a key with no case and no fallback mounts nothing"). +3. **A handler that pumps the owner while its widget would be unmounted** — a Dialog's button whose click runs a + nested event loop while a flush that closes the Dialog is pending — must not destroy the button under its own + handler (Task 4 test "a Dialog's button survives a nested loop in its own handler"). +4. **A ForEach emptied and then refilled** removes every row and leaves the container usable for the next insert + (Task 5 test "emptied then refilled"). +5. **Unmounting destroys children before parents and leaves no binding behind**: a write after the `Mounted` is + gone posts no flush and touches no widget (Task 3 test "unmounting destroys children first and leaves no + binding"). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `include/morph/ui/view.hpp` | `Action`, `Key`, `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::kDuplicateKey`, `detail::Mounter` and its row helpers, `Mounted` | +| `include/morph/ui/frontend.hpp` | `Scheduler`/`TimerHandle` aliases, `AppContext`, `Application`, `ApplicationFactory`, `Frontend`, `FrontendOption`, `FrontendSelectionError`, `EnvironmentReader`, `processEnvironment`, `selectFrontend` | +| `include/morph/ui/testing/recording_backend.hpp` | `testing::RecordingBackend`, its record store, fake widgets, operation log, golden dump and interaction helpers | +| `include/morph/ui/testing/backend_conformance.hpp` | `testing::ConformanceProbe`, `ConformanceCase`, `conformanceCases()` | +| `tests/test_ui_view.cpp` | `Prop`, `Sizing`, `Common`, builders | +| `tests/test_ui_recording_backend.cpp` | The recording backend on its own: log, dump, lookups, helpers, drag | +| `tests/test_ui_mount.cpp` | Mounting leaf and container kinds, `Common`, bindings, callbacks, teardown | +| `tests/test_ui_structure.cpp` | Switch, `switchOn`, Tabs, Dialog | +| `tests/test_ui_foreach.cpp` | ForEach: identity, minimal operations, keys, duplicates | +| `tests/test_ui_table.cpp` | Table: columns, row keys, selection, activation | +| `tests/test_ui_frontend.cpp` | `selectFrontend` precedence and errors, `processEnvironment` | +| `tests/test_ui_conformance.cpp` | Every conformance case against a `RecordingProbe` | +| `docs/spec/ui/view_tree.md`, `backend_contract.md`, `frontend.md` | Authoritative specs | +| `CMakeLists.txt`, `tests/CMakeLists.txt` | Header and test registration | +| `docs/spec/README.md`, `docs/ARCHITECTURE.md`, `CHANGELOG.md` | Maps and changelog | + +## Build and test commands (used by every task) + +```bash +cmake -S . -B build/reactive -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_EXAMPLES=OFF # once (Part 1 made it) +cmake --build build/reactive --target morph_tests +./build/reactive/tests/morph_tests "[ui]" +``` + +Configuring prints `morph: warnings: ... strict=ON`; if it does not, stop and say so (CONTRIBUTING, "Warnings +are errors"). + +--- + +### Task 1: The view tree — `Prop`, `Common`, the leaf and container nodes, builders + +Switch, Tabs and Dialog (Task 4), ForEach (Task 5) and Table (Task 6) join `NodeData` in the tasks that mount +them, so every task builds and every node kind lands with its mount. + +**Files:** +- Create: `include/morph/ui/view.hpp` +- Modify: `CMakeLists.txt` — the `FILE_SET HEADERS` block of `target_sources(morph …)`: add + `include/morph/ui/view.hpp` after `include/morph/reactive/testing/manual_scheduler.hpp` +- Modify: `tests/CMakeLists.txt` — the `add_executable(morph_tests …)` list: add `test_ui_view.cpp` after + `test_reactive_scheduler.cpp` +- Test: `tests/test_ui_view.cpp` + +**Interfaces:** +- Consumes: `morph::time::Timestamp` (`include/morph/util/datetime.hpp:376`). +- Produces (later tasks and parts use these exact names): + - `ui::Action = std::function`; `ui::Key = std::variant`. + - `ui::Prop`: implicit from a non-callable value convertible to `T` (constant) or a callable returning one + (binding); `isBound()`, `constant()`, `binding()`, `evaluate()`. + - Enums `TextRole{Normal, Muted, Heading, Error, Success}`, `TextInputMode{SingleLine, Multiline, Password}`, + `SelectStyle{Dropdown, Radio}`, `Axis{Vertical, Horizontal}`, `DateMode{Date, DateTime}`, + `FilePickerMode{Open, Save}`. + - `Sizing{kind, amount}` with `content()`, `fixed(int)`, `stretch(int = 1)`; `LayoutHints{width, height}`; + `Common{visible, enabled, layout, dragKey, accepts, onDrop}`. + - Aggregates `Text`, `Button`, `TextInput`, `Checkbox`, `SelectOption`, `Select`, `MenuItem`, `Menu`, `Column`, + `Row`, `GridCell`, `Grid`, `Spacer`, `Panel`, `Scroll`, `Busy`, `DateTimeInput`, `Slider`, `FilePicker`, fields + exactly as the contract lists them. + - `NodeData{kind}`, `Node`, `detail::makeNode(Kind)`, and the builders `text`, `button`, `textInput`, + `checkbox`, `select`, `menu`, `column`, `row`, `grid`, `spacer`, `panel`, `scroll`, `busy`, `dateTimeInput`, + `slider`, `filePicker`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_ui_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#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()); +} +``` + +Register it: in `tests/CMakeLists.txt`, add `test_ui_view.cpp` to the `add_executable(morph_tests …)` list directly +after `test_reactive_scheduler.cpp`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `fatal error: 'morph/ui/view.hpp' file not found`. + +- [ ] **Step 3: Implement the view tree** + +Create `include/morph/ui/view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; + +/// @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. +/// @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`. + /// @tparam U A non-callable type convertible to `T`. + /// @param value The constant. + template + requires(!std::invocable) && std::convertible_to + Prop(U&& value) // NOLINT(bugprone-forwarding-reference-overload) + : _value{std::in_place_index<0>, static_cast(std::forward(value))} {} + + /// @brief A binding. + /// @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. + template + requires std::invocable && std::convertible_to, T> + Prop(F binding) : _value{std::in_place_index<1>, std::function{std::move(binding)}} {} + + /// @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 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 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{}; +}; + +/// @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 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)); } + +} // namespace morph::ui +``` + +Register the header: in `CMakeLists.txt`, in the `FILE_SET HEADERS` block of `target_sources(morph …)`, add after +`include/morph/reactive/testing/manual_scheduler.hpp`: + +```cmake + include/morph/ui/view.hpp +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[ui]"` +Expected: PASS, `All tests passed (… assertions in 7 test cases)`. + +Mutation check: in `Prop`'s constant constructor, delete `(!std::invocable) &&` from the constraint and rebuild. +Expected: FAIL — "a captureless lambda is a binding" no longer compiles (`Prop` from a captureless lambda is +ambiguous between the constant and the binding constructor). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/ui/view.hpp tests/test_ui_view.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(ui): the view tree -- Prop, Common, leaf and container nodes, builders + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 2: The backend contract and `RecordingBackend` + +The widget interfaces and the headless backend land together: the interfaces are only testable through an +implementation, and `RecordingBackend` is the reference implementation every later task asserts through. + +**Files:** +- Create: `include/morph/ui/backend.hpp` +- Create: `include/morph/ui/testing/recording_backend.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/ui/view.hpp`: `include/morph/ui/backend.hpp` + and `include/morph/ui/testing/recording_backend.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_ui_recording_backend.cpp` after `test_ui_view.cpp` +- Test: `tests/test_ui_recording_backend.cpp` + +**Interfaces:** +- Consumes: Task 1's `Key`, `Action`, `LayoutHints`, `Sizing`, `SelectOption`, the enums; `time::Timestamp`, + `time::DateTime::toIso8601()` (`include/morph/util/datetime.hpp`). +- Produces: + - `ui::Widget` (`setVisible`, `setEnabled`, `setLayout`, `setDragKey`, `setDropHandler`), + `ui::ContainerWidget::moveChild`, and `TextWidget`, `ButtonWidget`, `TextInputWidget`, `CheckboxWidget`, + `SelectWidget`, `MenuWidget`, `StackWidget`, `GridWidget`, `SpacerWidget`, `PanelWidget`, `ScrollWidget`, + `BusyWidget`, `DateTimeInputWidget`, `SliderWidget`, `FilePickerWidget` exactly as the contract declares them. + - `ui::IViewBackend` with the factories for those kinds (Task 4 adds `createSlot`, `createTabs`, `createDialog`; + Task 6 adds `createTable`). + - `ui::testing::RecordingBackend`: the contract's `dump`, `log`, `clearLog`, `find`, `all`, `prop`, `exists`, + `click`, `edit`, `submit`, `toggle`, `choose`, `chooseIndex`, `collapse`, `setDateTime`, `slide`, `pick`, + `drag` (Task 4 adds `dismiss`, Task 6 `selectRows` and `activateRow`), **plus four additions** the + conformance probe and the mount tests need — `idOf(Widget const&) -> int`, `kindOf(int) -> std::string`, + `children(int) -> std::vector`, `widget(int) -> Widget*`. + - `ui::testing::detail::{PropList, Callbacks, Record, RecordStore, FakeLeaf, FakeContainer, Fake*}` and the + formatters `formatBool`, `formatKey`, `formatOptionalKey`, `formatSizing`, `formatLayout`, `formatTimestamp`, + `formatList`, `enumName(…)`. + - Log lines, exactly: `create Kind#id in Parent#id` (`in root` for a root), `set Kind#id name=value`, + `move Kind#id to n`, `destroy Kind#id`. Dump: one line per widget, `Kind#id name=value …` with the + properties in name order, children indented two spaces per depth, every line ending in `\n`. A stack's kind + is `Column` or `Row` by its axis. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_ui_recording_backend.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ui = morph::ui; + +namespace { + +using ui::testing::RecordingBackend; + +ui::Key intKey(std::int64_t value) { return ui::Key{value}; } + +} // 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) {}); + 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)); }); + 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); }); + 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() == 9); +} + +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)}); +} +``` + +Register it after `test_ui_view.cpp` in `tests/CMakeLists.txt`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `fatal error: 'morph/ui/backend.hpp' file not found`. + +- [ ] **Step 3: Write the widget interfaces** + +Create `include/morph/ui/backend.hpp`: + +```cpp +// 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. 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. + /// @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. +class SelectWidget : public Widget { +public: + /// @brief Replaces the options. + /// @param options The options, in order. + virtual void setOptions(std::vector const& options) = 0; + + /// @brief Marks the option with @p key. A key outside the options marks none. 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. 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 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 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. + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- the contract's signature; a range reads in this order + 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 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 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 +``` + +- [ ] **Step 4: Write `RecordingBackend`** + +Create `include/morph/ui/testing/recording_backend.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#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". + +// Every interaction helper takes a widget id and then what the user entered; for integer and boolean input the +// two convert into each other, and the signatures are the contract's. +// NOLINTBEGIN(bugprone-easily-swappable-parameters) + +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 Formats a key: an integer in decimal, a string in double quotes, so `7` and `"7"` differ. +/// @param key The key. +/// @return Its text. +[[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 '"' + std::get(key) + '"'; +} + +/// @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 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 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 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. + 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; + _log.push_back("destroy " + label(record)); + std::erase(siblingsOf(record.parent), 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); + } + + /// @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; } + + // NOLINTNEXTLINE(misc-no-recursion) -- a widget tree is dumped by descending it + 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: + /// @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))} {} + + ~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; + + void setVisible(bool visible) override { set("visible", formatBool(visible)); } + void setEnabled(bool enabled) override { set("enabled", formatBool(enabled)); } + void setLayout(LayoutHints const& hints) override { set("layout", formatLayout(hints)); } + + void setDragKey(std::optional const& key) override { + _store->at(_id).dragKey = key; + set("dragKey", formatOptionalKey(key)); + } + + 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; + + 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", std::string{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", std::string{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", std::string{text}); } + void setPlaceholder(std::string_view placeholder) override { set("placeholder", std::string{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", std::string{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`. +class FakeSelect final : public FakeLeaf { +public: + using FakeLeaf::FakeLeaf; + void setOptions(std::vector const& options) override { + set("options", formatList(options, [](SelectOption const& option) { + return formatKey(option.key) + ":" + option.label; + })); + } + void setSelected(std::optional const& key) override { set("selected", formatOptionalKey(key)); } + void setOnSelect(std::function onSelect) override { callbacks().select = std::move(onSelect); } +}; + +/// @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 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)); } + void setSpan(Widget& child, int span) override { store().set(store().idOf(child), "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", std::string{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", std::string{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", std::string{path}); } + void setOnPicked(std::function onPicked) override { callbacks().picked = std::move(onPicked); } +}; + +} // 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 hidden or disabled widget ignores every interaction helper, as a real +/// one would. A helper copies the handler before calling it, so a handler may destroy its own widget. +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 createBusy(ContainerWidget* parent) override { + return std::make_unique(_store, "Busy", 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`. + [[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. + /// @param widgetId The widget. + /// @param name The property. + /// @return The value, or an empty string when it was never set. + /// @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 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. + void click(int widgetId) { + if (detail::Record const* const record = actionable(widgetId); 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. + void edit(int widgetId, std::string text) { + if (detail::Record const* const record = actionable(widgetId); record != nullptr) { + _store->setByUser(widgetId, "text", 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. + void submit(int widgetId, std::string text) { + if (detail::Record const* const record = actionable(widgetId); record != nullptr) { + _store->setByUser(widgetId, "text", 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. + void toggle(int widgetId) { + if (detail::Record const* const record = actionable(widgetId); 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. + /// @param widgetId The select. + /// @param key The option's key. + void choose(int widgetId, Key key) { + if (detail::Record const* const record = actionable(widgetId); record != nullptr) { + _store->setByUser(widgetId, "selected", detail::formatKey(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. + void chooseIndex(int widgetId, std::size_t index) { + if (detail::Record const* const record = actionable(widgetId); 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. + /// @param widgetId The panel. + /// @param collapsed The requested state. + void collapse(int widgetId, bool collapsed) { + if (detail::Record const* const record = actionable(widgetId); record != nullptr) { + _store->setByUser(widgetId, "collapsed", detail::formatBool(collapsed)); + invoke(record->callbacks.toggle, collapsed); + } + } + + /// @brief Enters a date-time, or clears the field with `nullopt`. + /// @param widgetId The field. + /// @param value The instant entered. + void setDateTime(int widgetId, std::optional value) { + if (detail::Record const* const record = actionable(widgetId); 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. + void slide(int widgetId, std::int64_t value) { + if (detail::Record const* const record = actionable(widgetId); 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. + void pick(int widgetId, std::string path) { + if (detail::Record const* const record = actionable(widgetId); record != nullptr) { + _store->setByUser(widgetId, "path", path); + invoke(record->callbacks.picked, std::move(path)); + } + } + + /// @brief Drags one widget onto another. + /// + /// 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. + bool drag(int sourceId, int targetId) { + detail::Record const* const source = actionable(sourceId); + detail::Record const* const target = actionable(targetId); + if (source == nullptr || target == nullptr) { + 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: + [[nodiscard]] detail::Record* actionable(int widgetId) { + detail::Record& record = _store->at(widgetId); + auto const isFalse = [&record](std::string_view name) { + auto const found = record.props.find(name); + return found != record.props.end() && found->second == "false"; + }; + return isFalse("visible") || isFalse("enabled") ? nullptr : &record; + } + + // 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 + +// NOLINTEND(bugprone-easily-swappable-parameters) +``` + +Register both headers: in `CMakeLists.txt`'s `FILE_SET HEADERS` block, after `include/morph/ui/view.hpp`: + +```cmake + include/morph/ui/backend.hpp + include/morph/ui/testing/recording_backend.hpp +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[ui]"` +Expected: PASS — the seven Task 1 cases plus the ten here. + +Mutation checks, one at a time, each restored afterwards: + +| Change | Test that must fail | +|---|---| +| In `FakeTextInput::setText`, add `if (callbacks().change) { callbacks().change(std::string{text}); }` | "setText never calls onChange" | +| In `RecordingBackend::actionable`, return `&record` unconditionally | "a hidden or disabled widget ignores the helpers" | +| In `RecordingBackend::invoke`, call `handler(...)` instead of `copy(...)` | "a helper survives a handler that destroys its widget" (ASan reports a use after free; without ASan, run the case under the `clang-asan` preset) | + +- [ ] **Step 6: Commit** + +```bash +git add include/morph/ui/backend.hpp include/morph/ui/testing/recording_backend.hpp \ + tests/test_ui_recording_backend.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(ui): the backend contract and RecordingBackend + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 3: `Mounted` — leaves, containers, `Common`, bindings and callbacks + +**Files:** +- Create: `include/morph/ui/mount.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/ui/backend.hpp`: `include/morph/ui/mount.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_ui_mount.cpp` after `test_ui_recording_backend.cpp` +- Test: `tests/test_ui_mount.cpp` + +**Interfaces:** +- Consumes: `reactive::Runtime` (`batch`, `widgetEvent`, `untracked`, `core()`), `reactive::Scope` (`make`, + `adopt`, `effect`), `reactive::Computed`, `reactive::Signal`, `reactive::detail::site::kFlushInWidgetEvent` + (Part 1); Tasks 1–2. +- Produces: + - `ui::Mounted(reactive::Runtime&, IViewBackend&, Node root, ContainerWidget* parent = nullptr)`, + `root() -> Widget&`; throws `std::invalid_argument` for a null root. + - `ui::detail::Mounter(reactive::Runtime&, IViewBackend&)`, `mount(reactive::Scope&, Node const&, + ContainerWidget*) -> Widget*` — Tasks 4–6 add `mountKind` overloads to it. + - Mount order per node, which the logs below pin: create the widget (adopted into the scope first); `Common` + (`visible` and `enabled` only when bound or `false`, `layout` only when not content-sized, `dragKey` only when + bound or engaged, the drop handler only when `onDrop` is set); the kind's props in field order (every + kind-specific prop is set once even when it holds its default; a Grid child's span only when not 1; a Panel's + `collapsed` and `onToggle` only when `collapsible`); then the children. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_ui_mount.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "owner_probe_recorder.hpp" +#include "test_support.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; + +} // 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"); + CHECK(probe.count(morph::reactive::detail::site::kFlushInWidgetEvent) == 1); + 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}}}); +} + +// Review Focus 5. +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() == 2); // the text binding: one Computed, one 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() == 0); + 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); +} +``` + +Register it after `test_ui_recording_backend.cpp`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `fatal error: 'morph/ui/mount.hpp' file not found`. + +- [ ] **Step 3: Implement the mount** + +Create `include/morph/ui/mount.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#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 { + +// A view tree is mounted by descending it: a container's mount mounts its children. +// NOLINTBEGIN(misc-no-recursion) + +/// @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 or ForEach 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 &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. + template + void bind(reactive::Scope& scope, Prop const& prop, Apply apply) { + if (!prop.isBound()) { + apply(prop.constant()); + return; + } + auto& value = scope.make>(*_rt, prop.binding()); + scope.effect([&value, apply = std::move(apply)] { apply(value.get()); }); + } + + // Every widget callback runs 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. + template + [[nodiscard]] std::function event(std::function const& handler) const { + if (!handler) { + return {}; + } + return [runtime = _rt, handler](Args... args) { + runtime->widgetEvent([&] { 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) { + std::function accepts = common.accepts; + if (!accepts) { + accepts = [](Key const&) { return true; }; + } + 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] { 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; + } + + 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; +}; + +// NOLINTEND(misc-no-recursion) + +} // 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. +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 +``` + +Register it: in `CMakeLists.txt`'s `FILE_SET HEADERS`, after `include/morph/ui/backend.hpp`: + +```cmake + include/morph/ui/mount.hpp +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[ui]"` +Expected: PASS. + +Mutation checks, one at a time, each restored afterwards: + +| Change | Test that must fail | +|---|---| +| In `Mounter::bind`, replace the Computed and the Effect with `scope.effect([binding = prop.binding(), apply = std::move(apply)] { apply(binding()); });` | "a binding calls its setter once per real change" (`set Text#2 text=odd` appears) | +| In `Mounter::event`, return `handler` unwrapped | "a pending flush does not run inside a click handler" | +| In Part 1's `reactive::Scope::clear`, destroy front-first (`_owned.erase(_owned.begin())`) | "unmounting destroys children first and leaves no binding" (`destroy Column#1` comes first) | +| In `Mounter::bind`, make the Computed with `new reactive::Computed(*_rt, prop.binding())` instead of `scope.make` (leaked) | "unmounting destroys children first and leaves no binding" (`liveNodes()` stays 1) | +| In `applyCommon`, delete the `if (!accepts) { … }` default | "a drag key reaches a drop target that accepts it" (`drag(2, 5)`: `RecordingBackend` refuses every key for an empty predicate, so only the mount's default makes that drop land) | + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/ui/mount.hpp tests/test_ui_mount.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(ui): Mounted -- leaves, containers, Common, bindings and callbacks + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 4: Structure — Switch, `switchOn`, Tabs and Dialog + +Each of the three mounts its content into a child `reactive::Scope`, so swapping content tears the old content down +child-first before the new content is built. + +**Files:** +- Modify: `include/morph/ui/view.hpp` — the node structs, the builders, `switchOn`, and `NodeData`'s variant +- Modify: `include/morph/ui/backend.hpp` — `SlotWidget`, `TabsWidget`, `DialogWidget` and their factories +- Modify: `include/morph/ui/testing/recording_backend.hpp` — `Callbacks::dismiss`, `FakeTabs`, `FakeDialog`, the + three factories, `dismiss()` +- Modify: `include/morph/ui/mount.hpp` — `detail::Pages`, three `mountKind` overloads, `showPage` +- Modify: `tests/CMakeLists.txt` — add `test_ui_structure.cpp` after `test_ui_mount.cpp` +- Test: `tests/test_ui_structure.cpp` + +**Interfaces:** +- Consumes: Tasks 1–3; `reactive::detail::site::kFlushInWidgetEvent`. +- Produces: `ui::SwitchCase`, `ui::Switch`, `ui::Tab`, `ui::Tabs`, `ui::Dialog`; `switchOf(Switch)`, + `tabs(Tabs)`, `dialog(Dialog)`, `switchOn(std::function, std::vector>, Node = {})`; + `ui::SlotWidget`, `ui::TabsWidget`, `ui::DialogWidget`; `IViewBackend::createSlot`, `createTabs`, `createDialog`; + `RecordingBackend::dismiss(int)`. A Switch is a `Slot` holding its current case; a Tabs holds one `Slot` per + page shown so far, in the order they were first shown; a Dialog holds its content only while open. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_ui_structure.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "owner_probe_recorder.hpp" +#include "test_support.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; + +enum class Page : std::uint8_t { Home, Settings }; + +ui::Key intKey(std::int64_t value) { return ui::Key{value}; } + +} // 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()); +} + +// Review Focus 2. +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::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::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(); + CHECK(backend.log() == Lines{"set Slot#2 visible=false", "create Slot#4 in Tabs#1", "create Text#5 in Slot#4", + "set Text#5 text=stats", "set Text#5 role=Normal", "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::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"}); +} + +// Review Focus 3. +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); + CHECK(probe.count(morph::reactive::detail::site::kFlushInWidgetEvent) == 1); + owner.runAll(); + CHECK_FALSE(backend.exists(2)); + CHECK(backend.prop(1, "open") == "false"); +} +``` + +Register it after `test_ui_mount.cpp`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `no member named 'switchOf' in namespace 'morph::ui'`. + +- [ ] **Step 3: Add the nodes** + +In `include/morph/ui/view.hpp`, insert after `struct Scroll { … };`: + +```cpp +/// @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. + 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{}; +}; +``` + +Replace `NodeData`'s member with: + +```cpp + /// @brief The node. + std::variant + kind; +``` + +Insert after the `scroll` builder: + +```cpp +/// @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)); } +``` + +and after the `filePicker` builder, before the closing `} // namespace morph::ui`: + +```cpp +/// @brief A Switch over an enumeration: each enumerator becomes the `int64` key of its case. +/// @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. +template + requires std::is_enum_v +[[nodiscard]] Node switchOn(std::function selector, std::vector> cases, Node fallback = {}) { + 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)}); + } + return switchOf(Switch{ + .selector = [current = std::move(selector)] { return Key{static_cast(current())}; }, + .cases = std::move(keyed), + .fallback = std::move(fallback), + }); +} +``` + +- [ ] **Step 4: Add the widget interfaces** + +In `include/morph/ui/backend.hpp`, insert after `class ScrollWidget : public ContainerWidget {};`: + +```cpp +/// @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. +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) calls. + /// @param onDismiss The handler; empty does nothing. + virtual void setOnDismiss(Action onDismiss) = 0; +}; +``` + +and in `IViewBackend`, after `createScroll`: + +```cpp + /// @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; +``` + +- [ ] **Step 5: Teach `RecordingBackend` the three kinds** + +In `include/morph/ui/testing/recording_backend.hpp`: + +In `struct Callbacks`, after `picked`: + +```cpp + /// @brief From `DialogWidget::setOnDismiss`. + Action dismiss; +``` + +After `class FakeFilePicker { … };`: + +```cpp +/// @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 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", std::string{title}); } + void setOnDismiss(Action onDismiss) override { callbacks().dismiss = std::move(onDismiss); } +}; +``` + +In `RecordingBackend`, after `createScroll`: + +```cpp + [[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); + } +``` + +and after `collapse`: + +```cpp + /// @brief Dismisses a dialog, as Esc does on the TUI. + /// @param widgetId The dialog. + void dismiss(int widgetId) { + if (detail::Record const* const record = actionable(widgetId); record != nullptr) { + invoke(record->callbacks.dismiss); + } + } +``` + +- [ ] **Step 6: Mount the three kinds** + +In `include/morph/ui/mount.hpp`, insert inside `namespace detail`, before the `// A view tree is mounted by +descending it` comment: + +```cpp +/// @brief The pages of one Tabs: a slot per tab, made the first time the tab is selected and kept, hidden, after. +struct Pages { + /// @brief Each page's scope, owning its slot and its content; null until the tab is first selected. + std::vector> scopes; + /// @brief Each page's slot; null until the tab is first selected. + std::vector slots; + /// @brief The page shown now, if any. + std::optional shown; +}; +``` + +and inside `class Mounter`, after `mountKind(…, Scroll const& …)`: + +```cpp + // The case lives in a scope of its own: 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. + 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 = std::make_unique(*_rt); + _rt->untracked([&] { static_cast(mount(*content, node, &widget)); }); + } + }); + return widget; + } + + 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& pages = scope.make(); + pages.scopes.resize(spec.tabs.size()); + pages.slots.resize(spec.tabs.size(), nullptr); + bind(scope, spec.selected, [this, &widget, &pages, pageSpecs = spec.tabs](std::size_t index) { + _rt->untracked([&] { showPage(widget, pages, pageSpecs, index); }); + }); + 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. + void showPage(TabsWidget& widget, Pages& pages, std::vector const& pageSpecs, std::size_t 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); + } + } + pages.shown = index; + if (index < pageSpecs.size()) { + if (SlotWidget* const existing = pages.slots.at(index); existing != nullptr) { + existing->setVisible(true); + } else { + auto page = std::make_unique(*_rt); + SlotWidget& slot = page->adopt(_backend->createSlot(&widget)); + static_cast(mount(*page, pageSpecs.at(index).node, &slot)); + pages.slots.at(index) = &slot; + pages.scopes.at(index) = std::move(page); + } + } + widget.setSelected(index); + } + + // The content is built before the overlay opens and torn down before it closes. + 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); }); + widget.setOnDismiss(event(spec.onDismiss)); + auto& content = scope.make>(); + bind(scope, spec.open, [this, &widget, &content, child = spec.child](bool open) { + if (!open) { + content.reset(); + } else if (content == nullptr) { + content = std::make_unique(*_rt); + _rt->untracked([&] { static_cast(mount(*content, child, &widget)); }); + } + widget.setOpen(open); + }); + return widget; + } +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[ui]"` +Expected: PASS. + +Mutation checks, one at a time, each restored afterwards: + +| Change | Test that must fail | +|---|---| +| In the Switch binding, build the new case into a fresh scope first and assign it to `content` afterwards (so the old case is destroyed after the new one is created) | "mounts the selected case and remounts child-first" | +| In `showPage`, delete the `existing != nullptr` branch (always make a new page) | "pages mount on first selection and are kept" | +| In the Dialog binding, delete `content.reset();` | "the content exists only while open" | +| In `Mounter::event`, return `handler` unwrapped | "a Dialog's button survives a nested loop in its own handler" | + +- [ ] **Step 8: Commit** + +```bash +git add include/morph/ui tests/test_ui_structure.cpp tests/CMakeLists.txt +git commit -m "wip(ui): Switch, switchOn, Tabs and Dialog mount into child scopes + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 5: Keyed rows — `forEach` + +A ForEach keeps one row `Signal` and one `reactive::Scope` per key. On a new snapshot, kept keys are updated in +place (widget identity, focus and selection survive), gone keys are unmounted, new keys are mounted, and the +container is brought into order by moving only the rows outside a longest run already in order. The row type is +erased behind `detail::ForEachModel`, which Table (Task 6) reuses. + +**Files:** +- Modify: `include/morph/ui/view.hpp` — includes, `detail::RowSlot`, `ForEachSession`, `ForEachModel`, + `TypedForEach`, `struct ForEach`, `NodeData`'s variant, both `forEach` overloads +- Modify: `include/morph/ui/mount.hpp` — includes, `detail::site::kDuplicateKey`, `detail::KeyedRow`, + `detail::RowHook`, `detail::stableTargets`, `detail::reorderChildren`, and the ForEach mount in `Mounter` +- Modify: `tests/CMakeLists.txt` — add `test_ui_foreach.cpp` after `test_ui_structure.cpp` +- Test: `tests/test_ui_foreach.cpp` + +**Interfaces:** +- Consumes: `reactive::Signal` (`set`, `get`, `peek`), `reactive::Runtime::untracked`, `core()->report(...)` + (Part 1); Tasks 1–4. +- Produces: + - `ui::detail::RowSlot{assign(std::size_t), view() -> Node}`, `ui::detail::ForEachSession{pull() -> + std::vector, makeRow(std::size_t) -> std::unique_ptr}`, `ui::detail::ForEachModel{open( + reactive::Runtime&) -> std::unique_ptr}`, `ui::detail::TypedForEach` (Task 6's `table` + builds on it). + - `ui::ForEach{model, axis, gap, common}`; `forEach(std::function()>, …)` and + `forEach(reactive::Signal> const&, …)`. + - `ui::detail::site::kDuplicateKey`; `detail::RowHook = std::function`, and + `Mounter::mountRows(scope, model, container, RowHook)` (Task 6 passes `setRowKey` through it). + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_ui_foreach.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "owner_probe_recorder.hpp" +#include "test_support.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; + +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; +}; + +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"}}; } + +} // 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: mounts one row per key, in order", "[ui]") { + Owner owner; + Runtime runtime{owner}; + RecordingBackend backend; + Signal> 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 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: 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 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::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> 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"); +} + +// Review Focus 4. +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"); +} + +// Review Focus 1. +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"); +} +``` + +Register it after `test_ui_structure.cpp`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `no member named 'forEach' in namespace 'morph::ui'`. + +- [ ] **Step 3: Add the type-erased row model and the node** + +In `include/morph/ui/view.hpp`, add to the includes, after `#include "../util/datetime.hpp"`: + +```cpp +#include "../reactive/runtime.hpp" +#include "../reactive/signal.hpp" +``` + +Insert after `struct Busy { … };`: + +```cpp +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; equal rows notify 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. +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. + TypedForEach(std::function()> rows, std::function keyOf, + std::function const&)> rowView) + : _rows{std::move(rows)}, _keyOf{std::move(keyOf)}, _rowView{std::move(rowView)} {} + + [[nodiscard]] std::unique_ptr open(reactive::Runtime& runtime) const override { + return std::make_unique(runtime, *this); + } + +private: + 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{}; +}; +``` + +Replace `NodeData`'s member with: + +```cpp + /// @brief The node. + std::variant + kind; +``` + +Append after `switchOn`, before the closing `} // namespace morph::ui`: + +```cpp +/// @brief One widget per row of a keyed collection read through a binding (a `Query`'s `value()` included). +/// @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 refused at mount. +/// @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. +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 refused at mount. +/// @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. +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); +} +``` + +- [ ] **Step 4: Mount it** + +In `include/morph/ui/mount.hpp`, add `#include ` and `#include ` to the includes. +Insert at the top of `namespace detail`, before `struct Pages`: + +```cpp +/// @brief The site names a misuse in this layer is reported under, through the runtime's owner probe. +namespace site { +/// @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 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 std::size_t 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. +/// @param target The same children, in the order wanted. +// NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- the before and after orders of one container +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); + } +} +``` + +Inside `class Mounter`, after the Dialog `mountKind`, add: + +```cpp + 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; + } + + // 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, 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; + } + + [[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; + } + + // `rows` mirrors the container's order on entry and on exit. + 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; update the kept ones in place, which keeps their widgets. + std::vector current; + current.reserve(order.size()); + for (KeyedRow& row : rows) { + if (auto const found = wanted.find(row.key); found != wanted.end()) { + row.slot->assign(found->second); + current.push_back(std::move(row)); + } else { + row.scope.reset(); + } + } + + // Mount the new keys; each widget is appended to the container. + std::unordered_map position; // key -> index in `current` + for (std::size_t i = 0; i < current.size(); ++i) { + position.emplace(current.at(i).key, i); + } + for (std::size_t const index : order) { + Key const& key = keys.at(index); + if (!position.contains(key)) { + position.emplace(key, current.size()); + current.push_back(mountRow(session, container, key, index, onRow)); + } + } + + // Move the widgets into snapshot order, and keep `rows` in that order too. + std::vector present; + for (KeyedRow const& row : current) { + if (row.widget != nullptr) { + present.push_back(row.widget); + } + } + std::vector target; + std::vector ordered; + ordered.reserve(current.size()); + for (std::size_t const index : order) { + KeyedRow& row = current.at(position.at(keys.at(index))); + if (row.widget != nullptr) { + target.push_back(row.widget); + } + ordered.push_back(std::move(row)); + } + reorderChildren(container, std::move(present), target); + rows = std::move(ordered); + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[ui]"` +Expected: PASS. + +Mutation checks, one at a time, each restored afterwards: + +| Change | Test that must fail | +|---|---| +| In `mountRows`' Effect, call `reconcile(…)` directly instead of inside `_rt->untracked` | "building a row reads nothing on the ForEach's behalf" (`owner.pending()` is 1 after `theme.set`) | +| In `reconcile`, unmount every kept row too (`row.scope.reset()` in both branches, and drop the `current.push_back`) | "an update of a kept key keeps its widget" | +| In `reorderChildren`, treat every child as unstable (`std::vector const stable(target.size(), false);`) | "an insert at the front moves only the new row" and "the last row to the front is one move" | +| In `firstOccurrences`, drop the `seen` check (push every index) | "a duplicate key is reported and refused" | + +- [ ] **Step 6: Commit** + +```bash +git add include/morph/ui tests/test_ui_foreach.cpp tests/CMakeLists.txt +git commit -m "wip(ui): keyed forEach rows, updated in place and reordered with the fewest moves + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 6: Tables — `table` + +A Table is a ForEach whose row view is a `Row` of cells, under column headers, with selection and activation by +key. It reuses Task 5's keyed machinery and tells the widget each new row's key. + +**Files:** +- Modify: `include/morph/ui/view.hpp` — `SelectionMode`, `TableColumn`, `Table`, `NodeData`'s variant, + `TableOptions`, `table` +- Modify: `include/morph/ui/backend.hpp` — `TableWidget`, `IViewBackend::createTable` +- Modify: `include/morph/ui/testing/recording_backend.hpp` — `Callbacks::selection`/`activate`, + `enumName(SelectionMode)`, `FakeTable`, `createTable`, `selectRows`, `activateRow` +- Modify: `include/morph/ui/mount.hpp` — the Table `mountKind` +- Modify: `tests/CMakeLists.txt` — add `test_ui_table.cpp` after `test_ui_foreach.cpp` +- Test: `tests/test_ui_table.cpp` + +**Interfaces:** +- Consumes: Task 5's `detail::TypedForEach`, `Mounter::mountRows`, `detail::RowHook`. +- Produces: `ui::SelectionMode{None, Single, Multiple}`, `ui::TableColumn{label, width}`, `ui::Table`, + `ui::TableOptions`, `ui::table(columns, rows, keyOf, cells, options)`; `ui::TableWidget`, + `IViewBackend::createTable`; `RecordingBackend::selectRows(int, std::vector)`, + `activateRow(int, Key)`. Mount order: columns, selection mode, the two callbacks, the rows (each new row's + `setRowKey` right after it is built), then `selection` — so a selection names rows that already exist. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_ui_table.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace ui = morph::ui; + +namespace { + +using morph::reactive::Runtime; +using morph::reactive::Signal; +using ui::testing::RecordingBackend; +using Owner = morph::testing::StepExecutor; +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 intKey(std::int64_t value) { return ui::Key{value}; } + +} // 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"}); +} +``` + +Register it after `test_ui_foreach.cpp`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `no member named 'table' in namespace 'morph::ui'`. + +- [ ] **Step 3: Add the node and its builder** + +In `include/morph/ui/view.hpp`, insert after `enum class FilePickerMode { … };`: + +```cpp +/// @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. +}; +``` + +Insert after `struct ForEach { … };`: + +```cpp +/// @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{}; +}; +``` + +Replace `NodeData`'s member with the final palette: + +```cpp + /// @brief The node. + std::variant + kind; +``` + +Append after the two `forEach` overloads: + +```cpp +/// @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. +/// @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 refused at mount. +/// @param cells Builds a row's cells once, one per column, from a signal the mount keeps equal to the row. +/// @param options Selection, activation and `Common`. +/// @return The node. +template +[[nodiscard]] Node table(std::vector columns, std::function()> rows, + std::function keyOf, + std::function(reactive::Signal const&)> cells, + TableOptions options = {}) { + std::function const&)> rowView = + [cellsOf = std::move(cells)](reactive::Signal const& entry) { + return row(Row{.children = cellsOf(entry)}); + }; + return detail::makeNode(Table{ + .columns = std::move(columns), + .rows = std::make_shared const>(std::move(rows), std::move(keyOf), + std::move(rowView)), + .selectionMode = options.selectionMode, + .selection = std::move(options.selection), + .onSelectionChange = std::move(options.onSelectionChange), + .onActivate = std::move(options.onActivate), + .common = std::move(options.common), + }); +} +``` + +- [ ] **Step 4: Add the widget interface** + +In `include/morph/ui/backend.hpp`, insert after `class BusyWidget { … };`: + +```cpp +/// @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. Selection is +/// kept by key: it survives a reorder and applies to a row that appears later. +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. + /// @param row One of this table's rows. + /// @param key The row's key. + virtual void setRowKey(Widget& row, Key const& key) = 0; + + /// @brief Marks the rows with these keys selected. 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 selected keys. + /// @param onSelectionChange The handler; empty does nothing. + virtual void setOnSelectionChange(std::function)> onSelectionChange) = 0; + + /// @brief Sets what activating a row calls, with its key. + /// @param onActivate The handler; empty does nothing. + virtual void setOnActivate(std::function onActivate) = 0; +}; +``` + +and in `IViewBackend`, after `createBusy`: + +```cpp + /// @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; +``` + +- [ ] **Step 5: Teach `RecordingBackend` the table** + +In `include/morph/ui/testing/recording_backend.hpp`: + +After the `enumName(FilePickerMode)` overload: + +```cpp +/// @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"; + } +} +``` + +In `struct Callbacks`, after `dismiss`: + +```cpp + /// @brief From `TableWidget::setOnSelectionChange`. + std::function)> selection; + /// @brief From `TableWidget::setOnActivate`. + std::function activate; +``` + +After `class FakeDialog { … };`: + +```cpp +/// @brief A fake `TableWidget`: properties `columns`, `selectionMode`, `selection`, and `rowKey` on a row. +class FakeTable final : public FakeContainer { +public: + using FakeContainer::FakeContainer; + void setColumns(std::vector const& columns) override { + set("columns", formatList(columns, [](TableColumn const& column) { + return column.label + ":" + formatSizing(column.width); + })); + } + void setSelectionMode(SelectionMode mode) override { set("selectionMode", enumName(mode)); } + void setRowKey(Widget& row, Key const& key) override { store().set(store().idOf(row), "rowKey", formatKey(key)); } + void setSelection(std::vector const& keys) override { + set("selection", formatList(keys, [](Key const& key) { return formatKey(key); })); + } + void setOnSelectionChange(std::function)> onSelectionChange) override { + callbacks().selection = std::move(onSelectionChange); + } + void setOnActivate(std::function onActivate) override { callbacks().activate = std::move(onActivate); } +}; +``` + +In `RecordingBackend`, after `createBusy`: + +```cpp + [[nodiscard]] std::unique_ptr createTable(ContainerWidget* parent) override { + return std::make_unique(_store, "Table", parent); + } +``` + +and after `dismiss`: + +```cpp + /// @brief Selects rows of a table by key. + /// @param widgetId The table. + /// @param keys The keys the user selected. + void selectRows(int widgetId, std::vector keys) { + if (detail::Record const* const record = actionable(widgetId); record != nullptr) { + _store->setByUser(widgetId, "selection", + detail::formatList(keys, [](Key const& key) { return detail::formatKey(key); })); + 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. + void activateRow(int widgetId, Key key) { + if (detail::Record const* const record = actionable(widgetId); record != nullptr) { + invoke(record->callbacks.activate, std::move(key)); + } + } +``` + +- [ ] **Step 6: Mount it** + +In `include/morph/ui/mount.hpp`, inside `class Mounter`, after the ForEach `mountKind`: + +```cpp + // The rows are mounted before the selection is applied, so the first setSelection names rows that exist. + 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; + } +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[ui]"` +Expected: PASS. + +Mutation checks, one at a time, each restored afterwards: + +| Change | Test that must fail | +|---|---| +| In the Table `mountKind`, pass `{}` instead of the `setRowKey` hook | "columns, keyed rows of cells, selection after the rows" | +| Move the `selection` `bind` above `mountRows` | "columns, keyed rows of cells, selection after the rows" (the last log line is `set Row#5 rowKey=2`) | +| In `FakeTable::setRowKey`, record nothing | "a new row is told its key once; a reorder only moves" | + +- [ ] **Step 8: Commit** + +```bash +git add include/morph/ui tests/test_ui_table.cpp tests/CMakeLists.txt +git commit -m "wip(ui): table of keyed rows with selection and activation by key + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 7: The frontend seam — `AppContext`, `Application`, `Frontend`, `selectFrontend` + +An application is a factory the frontend calls with an `AppContext`; `main` is the composition root that lists the +frontends it was built with and lets `selectFrontend` pick one. The order `Frontend::run` guarantees (runtime +first, application destroyed before it) is a property of each frontend, so Parts 3 and 4 pin it in their own +frontend tests — Part 3's "tui::Frontend: the application is destroyed before the runtime" and Part 4's "qt_quick +Frontend: the mount dies before the application, the application before the runtime, Qt last" — and Task 9's +`frontend.md` names both; this task pins the selection. + +**Files:** +- Create: `include/morph/ui/frontend.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/ui/mount.hpp`: `include/morph/ui/frontend.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_ui_frontend.cpp` after `test_ui_table.cpp` +- Test: `tests/test_ui_frontend.cpp` + +**Interfaces:** +- Consumes: `reactive::Runtime`, `reactive::Scheduler`, `reactive::TimerHandle` (Part 1, `scheduler.hpp`); + `exec::IExecutor` (`include/morph/core/executor.hpp:68`, a `struct`); `exec::IoLoop` + (`include/morph/core/io_loop.hpp:54`, a `class`, forward-declared here so `ui` does not pull in core-cpp's + event loop). +- Produces, exactly as the contract declares them: `ui::Scheduler`, `ui::TimerHandle`, `ui::AppContext`, + `ui::Application`, `ui::ApplicationFactory`, `ui::Frontend`, `ui::FrontendOption{name, usable, make}`, + `ui::FrontendSelectionError`, `ui::EnvironmentReader`, `ui::processEnvironment()`, `ui::selectFrontend(…)`. + Precedence: the last `--ui=` or `--ui ` on the command line; else a non-empty `MORPH_UI`; else the + first option, in the order given, whose `usable()` holds (an empty `usable` counts as usable). A named frontend + is used even when its `usable()` is false. Errors, exactly: + `morph::ui: no frontend named '' is built (built: )`, + `morph::ui: no built frontend is usable here (built: )`, + `morph::ui: --ui needs a frontend name (built: )`, + `morph::ui: frontend '' could not be made` — `` is `none` when nothing is built. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_ui_frontend.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ui = morph::ui; + +namespace { + +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::ApplicationFactory const&) 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] { 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, std::vector args, + ui::EnvironmentReader const& env) { + return std::string{ui::selectFrontend(built, static_cast(args.size()), args.data(), env)->name()}; +} + +std::string selectionError(std::vector const& built, std::vector args, + ui::EnvironmentReader const& env) { + try { + static_cast(ui::selectFrontend(built, static_cast(args.size()), args.data(), env)); + } catch (ui::FrontendSelectionError const& error) { + return error.what(); + } + return "no error"; +} + +std::vector qtThenTui(bool qtUsable = true, bool tuiUsable = true) { + return {option("qt", qtUsable), option("tui", tuiUsable)}; +} + +} // namespace + +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 lists the built frontends", "[ui]") { + CHECK(selectionError(qtThenTui(), {"app", "--ui=web"}, morphUi(std::nullopt)) == + "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 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"); +} + +TEST_CASE("ui::processEnvironment: reads the process environment", "[ui]") { + auto const env = ui::processEnvironment(); + CHECK(env("PATH").has_value()); + CHECK_FALSE(env("MORPH_UI_TEST_VARIABLE_THAT_IS_NEVER_SET").has_value()); +} +``` + +Register it after `test_ui_table.cpp`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `fatal error: 'morph/ui/frontend.hpp' file not found`. + +- [ ] **Step 3: Implement the seam** + +Create `include/morph/ui/frontend.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/executor.hpp" +#include "../reactive/runtime.hpp" +#include "../reactive/scheduler.hpp" +#include "view.hpp" + +/// @file +/// @brief The frontend seam: an application is a factory the frontend calls, and `main` picks the frontend at +/// runtime. +/// +/// 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 hands the application it runs. +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 view is mounted in. Owned by the frontend; it outlives the application. + /// @return The runtime. + virtual reactive::Runtime& runtime() = 0; + + /// @brief The runtime's owner, and the callback executor of every `BridgeHandler` the application makes. + /// @return The executor. + virtual exec::IExecutor& executor() = 0; + + /// @brief Timers that fire on the owner executor. + /// @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. + /// @param exitCode What `run` returns. + virtual void quit(int exitCode = 0) = 0; + + /// @brief The running frontend's name. + /// @return The name `selectFrontend` matched, such as `tui` or `qt`. + [[nodiscard]] virtual std::string_view frontendName() const = 0; +}; + +/// @brief An application: its controllers, handlers and bridge wiring, and the view over them. +class Application { +public: + Application() = default; + virtual ~Application() = default; + Application(Application const&) = delete; + Application& operator=(Application const&) = delete; + Application(Application&&) = delete; + Application& operator=(Application&&) = delete; + + /// @brief The view tree to mount; called once, after construction. + /// @return The root node; never null. + [[nodiscard]] virtual Node view() = 0; +}; + +/// @brief Makes the application, given the running frontend's context. +using ApplicationFactory = std::function(AppContext&)>; + +/// @brief A frontend: owns an event loop and renders one application's view. +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, calls @p factory, mounts the application's view, and drives the + /// loop until `AppContext::quit`. Tears down in reverse: the mounted view, then the application, then + /// the runtime. + /// @param factory Makes the application. + /// @return The exit code passed to `quit`. + virtual int run(ApplicationFactory const& factory) = 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; must not return null. + std::function()> make; +}; + +/// @brief No frontend could be selected; the message names the built ones. +class FrontendSelectionError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +/// @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. + char const* const value = std::getenv(key.c_str()); // NOLINT(concurrency-mt-unsafe) + if (value == nullptr) { + return std::nullopt; + } + return std::string{value}; + }; +} + +namespace detail { + +/// @brief The built frontends' names, for an error message. +/// @param built The options. +/// @return The names joined by `, `, or `none`. +[[nodiscard]] inline std::string builtNames(std::span built) { + if (built.empty()) { + return "none"; + } + std::string names; + for (FrontendOption const& option : built) { + if (!names.empty()) { + names += ", "; + } + names += option.name; + } + return names; +} + +/// @brief The name the command line asks for: the last `--ui=` or `--ui `. +/// @param built The options, for the error message. +/// @param argc The argument count, as `main` received it. +/// @param argv The arguments, as `main` received them; `argv[0]` is skipped. +/// @return The name, or `nullopt` when no `--ui` is given. +/// @throws FrontendSelectionError for a `--ui` without a name. +[[nodiscard]] inline std::optional commandLineFrontend(std::span built, int argc, + char const* const* argv) { + std::size_t const count = argv == nullptr || argc <= 0 ? 0 : static_cast(argc); + std::span const args{argv, count}; + std::optional name; + bool nameFollows = false; + for (char const* const raw : args | std::views::drop(1)) { + std::string_view const arg = raw == nullptr ? std::string_view{} : std::string_view{raw}; + if (nameFollows) { + name = std::string{arg}; + nameFollows = false; + } else if (arg == "--ui") { + nameFollows = true; + } else if (arg.starts_with("--ui=")) { + name = std::string{arg.substr(std::string_view{"--ui="}.size())}; + } + } + if (nameFollows || (name.has_value() && name->empty())) { + throw FrontendSelectionError{"morph::ui: --ui needs a frontend name (built: " + builtNames(built) + ")"}; + } + return name; +} + +} // namespace detail + +/// @brief Picks the frontend to run: the last `--ui=` (or `--ui `), 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. +/// @param built The frontends this binary was built with, in order of preference. +/// @param argc The argument count, as `main` received it. +/// @param argv The arguments, as `main` received them. +/// @param env Reads `MORPH_UI`; the process environment by default. +/// @return The frontend, made. +/// @throws FrontendSelectionError for an unknown or unbuilt name, a `--ui` without one, no usable option, or an +/// option that makes nothing; the message names the built frontends. +[[nodiscard]] inline std::unique_ptr selectFrontend(std::span built, int argc, + char const* const* argv, + EnvironmentReader const& env = processEnvironment()) { + std::optional requested = detail::commandLineFrontend(built, argc, argv); + 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()) { + throw FrontendSelectionError{"morph::ui: no frontend named '" + *requested + "' is built (built: " + + detail::builtNames(built) + ")"}; + } + chosen = &*found; + } else { + auto const found = std::ranges::find_if( + built, [](FrontendOption const& candidate) { return !candidate.usable || candidate.usable(); }); + if (found == built.end()) { + throw FrontendSelectionError{"morph::ui: no built frontend is usable here (built: " + + detail::builtNames(built) + ")"}; + } + chosen = &*found; + } + std::unique_ptr frontend = chosen->make ? chosen->make() : nullptr; + if (frontend == nullptr) { + throw FrontendSelectionError{"morph::ui: frontend '" + chosen->name + "' could not be made"}; + } + return frontend; +} + +} // namespace morph::ui +``` + +Register it after `include/morph/ui/mount.hpp` in `CMakeLists.txt`'s `FILE_SET HEADERS`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[ui]"` +Expected: PASS. + +Mutation checks, one at a time, each restored afterwards: + +| Change | Test that must fail | +|---|---| +| In `selectFrontend`, read `MORPH_UI` first and the command line only when it is unset | "--ui= wins over MORPH_UI and over the order" | +| In the `find_if` predicate, return `true` unconditionally | "otherwise the first usable option, in the order given" | +| In `commandLineFrontend`, `return name;` before the `nameFollows` check | "--ui without a name is an error" | + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/ui/frontend.hpp tests/test_ui_frontend.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(ui): the frontend seam and selectFrontend + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 8: The backend-conformance suite + +The same scripted cases run against every backend: `RecordingBackend` here, the TUI backend in Part 3, Qt Quick in +Part 4. A backend author implements a `ConformanceProbe` (how to read a widget's text, visibility and children, how +to click, type and drag, how to run pending work) and loops over `conformanceCases()`. + +**Files:** +- Create: `include/morph/ui/testing/backend_conformance.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/ui/testing/recording_backend.hpp`: + `include/morph/ui/testing/backend_conformance.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_ui_conformance.cpp` after `test_ui_frontend.cpp` +- Test: `tests/test_ui_conformance.cpp` + +**Interfaces:** +- Consumes: `Mounted`, the builders, `reactive::Signal`, `Runtime::batch` (Tasks 1–6, Part 1). +- Produces: `ui::testing::ConformanceProbe` (`backend`, `runtime`, `settle`, `textOf`, `visibleOf`, `enabledOf`, + `childCount`, `childAt`, `click`, `type`, `drag`), `ui::testing::ConformanceCase{name, run}`, + `ui::testing::conformanceCases()` — thirteen cases; `ui::testing::detail::{Checks, Entry, entryList, + asContainer, childTexts}` and one `detail` function per case. `textOf` is a Text's or TextInput's text and a + Button's or Checkbox's label. A case reaches a widget only through `Mounted::root()`, `childAt` and a second + `Mounted` root (drag), so a probe never needs a widget lookup. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_ui_conformance.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace ui = morph::ui; + +namespace { + +// 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); + return _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 const* 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))); + } + +private: + 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() == 13); + 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()); + } +} +``` + +Register it after `test_ui_frontend.cpp`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `fatal error: 'morph/ui/testing/backend_conformance.hpp' file not found`. + +- [ ] **Step 3: Write the cases** + +Create `include/morph/ui/testing/backend_conformance.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../reactive/runtime.hpp" +#include "../../reactive/signal.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. +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. + /// @param widget The widget. + /// @return The text, UTF-8. + [[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. + [[nodiscard]] virtual Widget const* 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; `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 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. + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) -- a label, then expected before actual + 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 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. +/// @return The node. +[[nodiscard]] inline Node entryList(reactive::Signal> const& rows) { + return ui::forEach( + rows, [](Entry const& entry) { return Key{entry.id}; }, + [](reactive::Signal const& entry) { return ui::text({.text = [&entry] { return entry.get().label; }}); }); +} + +/// @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 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 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(); + checks.that(probe.childCount(*bar) == 2, "re-selecting a tab mounted its page again"); + checks.that(first != nullptr && probe.visibleOf(*first), "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. +/// @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"}}}; + 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"; + } + Widget const* const before = probe.childAt(*list, 0); + rows.set({{.id = 1, .label = "a2"}, {.id = 2, .label = "b"}}); + probe.settle(); + Checks checks; + 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 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 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 Dialog holds its content only while open", .run = detail::dialogHoldsContentWhileOpen}, + {.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 +``` + +Register it after `include/morph/ui/testing/recording_backend.hpp` in `CMakeLists.txt`'s `FILE_SET HEADERS`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[conformance]"` +Expected: PASS, `All tests passed (… assertions in 1 test case)`. + +Mutation checks, one at a time, each restored afterwards — each must fail the conformance test with the named case +in its `INFO` line: + +| Change | Case that must fail | +|---|---| +| In `FakeTextInput::setText`, add `if (callbacks().change) { callbacks().change(std::string{text}); }` | "typing reaches onChange and a value set by the application is not echoed" | +| In `Mounter::reconcile`, unmount every kept row too (as in Task 5's mutation) | "a ForEach updates a kept row in place" | +| In `Mounter::applyCommon`, delete the `if (!accepts) { … }` default | "a drop target without accepts takes every key" | +| In `showPage`, skip `previous->setVisible(false)` | "Tabs mount a page on first selection and show only the selected one" | + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/ui/testing/backend_conformance.hpp tests/test_ui_conformance.cpp tests/CMakeLists.txt \ + CMakeLists.txt +git commit -m "wip(ui): the backend-conformance cases, run against RecordingBackend + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 9: Specs, maps and changelog + +**Files:** +- Create: `docs/spec/ui/view_tree.md`, `docs/spec/ui/backend_contract.md`, `docs/spec/ui/frontend.md` +- Modify: `docs/spec/README.md` — "Start here" map +- Modify: `docs/ARCHITECTURE.md` — "Namespace map" table and "Header map" +- Modify: `CHANGELOG.md` — `## [Unreleased]` → `### Added` + +**Interfaces:** +- Consumes: the API exactly as Tasks 1–8 ship it. Where this text and the code disagree, fix the text. +- Produces: the authoritative specs Parts 3–10 read before touching `morph::ui`. No constant is pinned in + `pinned_facts.toml`: the layer has no numeric limit, and its one site name, like Part 1's, is asserted by the + tests that trigger it. + +- [ ] **Step 1: Write `docs/spec/ui/view_tree.md`** + +```markdown +# 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`): an application describes its UI once, as immutable nodes whose +properties are constants or reactive bindings, and `Mounted` turns that description into retained +widgets on any backend and keeps them current. The widgets themselves are +[backend_contract.md](backend_contract.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) +- [Misuse](#misuse) +- [Design decisions](#design-decisions) + +## 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 `ui::Action` (`std::function`) or + typed callbacks — `onChange(std::string)`, `onToggle(bool)`, `onSelect(Key)`. +- 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 + exceed 2^53. +- Every node carries `Common`: + +| Field | Type | Default | Meaning | +|---|---|---|---| +| `visible` | `Prop` | `true` | Shown | +| `enabled` | `Prop` | `true` | Accepts input | +| `layout` | `LayoutHints{width, height}` | content × content | `Sizing::content()`, `fixed(n)` or `stretch(weight)` per dimension, in backend units (a 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 | +| `onDrop` | `std::function` | empty | Set: the widget is a drop target | + +## Props + +`Prop` holds a constant or a binding: + +| Constructed from | Is | At mount | +|---|---|---| +| a non-callable value convertible to `T` | a constant | the setter is called once; no reactive node is made | +| a callable returning something convertible to `T` | a binding | an equality-gated `Computed` plus an `Effect` that calls the setter | + +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 braced list does not deduce through the constructor template; write the type +(`std::vector{…}`). + +## The palette + +| Node | Builder | Props and callbacks | +|---|---|---| +| `Text` | `text` | `text`, `role` (`TextRole{Normal, Muted, Heading, Error, Success}`) | +| `Button` | `button` | `label`, `onClick` | +| `TextInput` | `textInput` | `value`, `onChange`, `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 selects nothing), `onSelect`, `style` (`SelectStyle{Dropdown, Radio}`, set once) | +| `Menu` | `menu` | `items` (`MenuItem{label, onSelect}`: one table, no parallel lists; the list is set once, each label may be bound) | +| `Column`, `Row` | `column`, `row` | `children` (null children skipped), `gap` | +| `Grid` | `grid` | `columns`, `cells` (`GridCell{node, span}`), `gap` | +| `Spacer` | `spacer` | none | +| `Panel` | `panel` | `title`, `padding`, `child`, `collapsible` (set once), `collapsed`, `onToggle` — collapsible panels are accordion sections | +| `Scroll` | `scroll` | `child`, `axis` (set once) | +| `Switch` | `switchOf`, `switchOn` | `selector` (`Prop`), `cases` (`SwitchCase{key, node}`), `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}`), rows, key function, cells, and `TableOptions{selectionMode, selection, onSelectionChange, onActivate, common}` | +| `DateTimeInput` | `dateTimeInput` | `value` (`std::optional`), `onChange`, `mode` (`DateMode{Date, DateTime}`), `offsetMinutes` (the display zone; both set once) | +| `Slider` | `slider` | `value` (`std::int64_t`), `minimum`, `maximum`, `step` (set once), `onChange` | +| `FilePicker` | `filePicker` | `path`, `mode` (`FilePickerMode{Open, Save}`, set once), `onPicked` | + +The builder for a Switch is `switchOf` because `switch` is a keyword. `switchOn(selector, cases, +fallback)` is the enumeration form: each enumerator becomes the `int64` key of its case. + +Not in the palette: themes beyond `TextRole`, right-to-left layout, images, charts, i18n catalogues +(bindings produce translated text). Schema forms are spec 2's `forms::formView`, built on this +palette. + +## Keyed collections + +`forEach(rows, keyOf, rowView, axis, gap)` takes the rows as a binding +(`std::function()>`, so a `Query`'s `value()` works) or as a +`Signal> const&` that must outlive every mount of the node. The row type is erased +behind `detail::ForEachModel`: each mount opens a `ForEachSession`, which keeps the latest snapshot +and makes one `RowSlot` per row. A slot owns a `Signal` and the view `rowView` built from it +**once**. The view reads the row inside bindings; what it reads directly while it is built is read +once, and is not a dependency of anything. + +`table(columns, rows, keyOf, cells, options)` is the same machinery: the row view is a `Row` +whose children are `cells(row)`, one per column. + +## Mount + +`ui::Mounted(runtime, backend, root, parent = nullptr)` builds the tree once, untracked, into a +`reactive::Scope` it owns. `root()` is the root node's widget. A null root throws +`std::invalid_argument`. The runtime, the backend and every signal a binding reads outlive the +`Mounted`; the runtime's owner constructs and destroys it. + +### Per node + +1. The widget is made by its factory and adopted into the scope **first**. +2. `Common`: `visible` and `enabled` only when bound or `false`; `layout` only when 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. +3. The kind's props in field order. A constant calls its setter once; a binding makes a + `Computed` (equality-gated, so an unchanged result calls nothing) and an `Effect` calling one + setter. Every kind-specific prop is set even when it holds its default; a Grid child's span only + when not 1; a Panel's `collapsed` and `onToggle` only when it is collapsible. A Menu sends all + its labels through `setItems` whenever one bound label changes. +4. Its children, appended in order. + +Destroying the scope destroys in reverse creation order: bindings before the widget they drive, +children before their parent. + +### Callbacks + +Every callback handed to a widget runs inside `Runtime::widgetEvent`: one batch, during which a +flush that starts — a nested event loop pumping the owner — is refused and re-posted. Flushes are +always posted, so a write inside a handler never destroys the handler's own widget; the refusal +covers the one remaining way, a handler that pumps the loop itself. + +### Switch, Tabs, Dialog + +- **Switch** is a slot widget. Its case lives in a child `Scope`; a new key resets that scope (the + old case is torn down, newest first) and then mounts the new case. The selector's `Computed` is + equality-gated, so an unchanged key never remounts. A key with no case and no fallback shows + nothing. +- **Tabs** creates a page slot under the tab bar the first time a tab is selected, in its own + `Scope`, and keeps it: switching tabs hides the old page's slot and shows the new one, so a page's + widgets keep focus, scroll position and half-typed text. An index past the last tab shows no page. +- **Dialog** mounts its content into a child `Scope` when `open` becomes true, then opens the + overlay; when `open` becomes false it destroys the content, then closes the overlay. + +### ForEach and Table rows + +The mount adopts the model, the session, a row list and then one `Effect`, in that order. The +Effect pulls the keys (tracked: the rows binding is its only source) and reconciles untracked: + +1. The first occurrence of each key is kept; every later duplicate is reported + (`detail::site::kDuplicateKey`) and refused. +2. A row whose key is gone is unmounted. 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 its widgets — 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 a + longest run already in order stay; every other widget moves once, directly before its successor + in the target order. An append moves nothing; moving one row is one move; reversing three rows is + two. + +A Table's `selection` binding is made after its rows, so its first `setSelection` names rows that +exist. + +## Misuse + +Reported through the runtime's owner probe, then refused, as in +[../reactive/signals.md](../reactive/signals.md): + +| Misuse | Site | Refusal | +|---|---|---| +| A ForEach or Table snapshot repeats a key | `ui::detail::site::kDuplicateKey` | The later row is skipped; the first is kept | + +## 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. | +| Content in child scopes | One mechanism gives "bindings before widgets, children before parents" for a case, a page, a dialog and a row. | +| Lazy, kept Tabs pages | A tab that is never opened costs nothing; a tab left keeps its state. | +| 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. | +| `switchOf`, not `switch_` | A keyword cannot be a function name; the spelling reads as a phrase at the call site. | +``` + +- [ ] **Step 2: Write `docs/spec/ui/backend_contract.md`** + +```markdown +# 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` (`testing/recording_backend.hpp`) and the conformance cases +(`testing/backend_conformance.hpp`). Implemented by `morph::tui::Backend` and +`morph::qt_quick::Backend`. What the mount does with these widgets is +[view_tree.md](view_tree.md). + +## Contents + +- [Widgets](#widgets) +- [Containers](#containers) +- [Interfaces](#interfaces) +- [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's axis, a DateTimeInput's mode and 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. The mount + calls those setters only to change them. +- Every setter takes UTF-8 and may be called with the value the widget already has. +- **A setter never calls the widget's own callback**: `TextInputWidget::setText` does not call + `onChange`, `setChecked` does not call `onToggle`, and likewise for every selection, value and + path setter. A binding that writes back what the user typed therefore cannot loop. +- A widget's destructor detaches it from its parent and frees its native resources. The mount + destroys children before parents. +- Callbacks 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. + +## Containers + +`ContainerWidget::moveChild(child, index)` removes `child` and inserts it before the child now at +`index`; an index past the end means last. Only the mount reorders children, and only through this. + +| Container | Children | +|---|---| +| `StackWidget` | In order along the axis, `setGap` apart | +| `GridWidget` | Row-major in `setColumns` columns; `setSpan(child, n)` widens one, a child spans 1 until told | +| `PanelWidget` | Inside the frame, `setPadding` in; hidden while collapsed | +| `ScrollWidget` | One scrolled viewport 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, present only while open; a focus trap while `setOpen(true)` | +| `TableWidget` | Rows, each a horizontal stack of cells in column order; `setRowKey(row, key)` once per row, right after it is built | + +## Interfaces + +| Interface | Setters | Callbacks | +|---|---|---| +| `Widget` | `setVisible`, `setEnabled`, `setLayout`, `setDragKey` | `setDropHandler(accepts, onDrop)` | +| `TextWidget` | `setText`, `setRole` | — | +| `ButtonWidget` | `setLabel` | `setOnClick` (Enter, Space, click) | +| `TextInputWidget` | `setText`, `setPlaceholder` | `setOnChange` (whole text after each edit), `setOnSubmit` | +| `CheckboxWidget` | `setLabel`, `setChecked` | `setOnToggle(bool)` | +| `SelectWidget` | `setOptions`, `setSelected(optional)` | `setOnSelect(Key)` | +| `MenuWidget` | `setItems(labels)` | `setOnActivate(index)` | +| `PanelWidget` | `setTitle`, `setPadding`, `setCollapsible`, `setCollapsed` | `setOnToggle(collapsed)` | +| `TabsWidget` | `setTabs(labels)`, `setSelected(index)` | `setOnSelect(index)` | +| `DialogWidget` | `setOpen`, `setTitle` | `setOnDismiss` (Esc on the TUI) | +| `BusyWidget` | `setActive`, `setLabel` | — | +| `TableWidget` | `setColumns`, `setSelectionMode`, `setRowKey`, `setSelection(keys)` | `setOnSelectionChange(keys)`, `setOnActivate(Key)` | +| `DateTimeInputWidget` | `setValue(optional)` | `setOnChange(optional)` | +| `SliderWidget` | `setRange(minimum, maximum, step)`, `setValue` | `setOnChange(value)` | +| `FilePickerWidget` | `setPath` | `setOnPicked(path)` | + +A Table keeps its selection by key: it survives a reorder, and a key whose row is not there yet +selects that row when it appears. A Select's key outside its options shows no selection. + +## 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, and an accepted one calls `onDrop(key)` once. The mount always passes a +predicate (accept-everything when the node's `accepts` is empty), so a backend never sees an empty +one. + +## RecordingBackend + +The headless reference backend and the test double for everything above it. + +- 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`. +- **Operation log** (`log()`, `clearLog()`), one line per operation: + `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; callbacks are recorded without one. +- **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. +- Values: `true`/`false`; 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 ISO-8601 UTC (`empty` for an empty + `Timestamp`); sizing as `content`, `fixed(n)` or `stretch(n)`, layout as `width/height`; lists as + `[a,b]`; options as `[key:label,…]`; columns as `[label:sizing,…]`; a slider's range as + `minimum..maximum/step`. +- **Lookups**: `find(kind, name, value)`, `all(kind)`, `prop(id, name)` (empty when never set), + `exists`, `idOf(widget)`, `kindOf`, `children`, `widget(id)`. +- **Interaction helpers** act as the user would: `click`, `edit`, `submit`, `toggle`, `choose`, + `chooseIndex`, `collapse`, `dismiss`, `selectRows`, `activateRow`, `setDateTime`, `slide`, `pick`, + `drag`. A helper first changes what the native widget would change by itself (the field's text, the + box's check mark, the selection) without a log line, then calls the stored callback. A hidden or + disabled widget ignores every helper. A helper copies the callback before calling it, so a handler + may destroy its own widget. `drag(source, target)` returns true when the source has a drag key and + the target's predicate accepted it; an empty predicate accepts nothing. + +## The conformance suite + +`conformanceCases()` is a list of `ConformanceCase{name, run}`; each `run` takes a +`ConformanceProbe` and returns `nullopt` for a pass or the failure message. A backend author writes +one probe — `backend()`, `runtime()`, `settle()` (run posted flushes and native events until idle), +`textOf`, `visibleOf`, `enabledOf`, `childCount`, `childAt`, `click`, `type`, `drag` — and runs +every case on a fresh probe. `visibleOf` is the widget's own flag as its last `setVisible` left it, +not whether it is on screen: a hidden ancestor does not change it. A case reaches widgets only through `Mounted::root()`, `childAt` and a +second root, so a probe needs no lookup by name. + +The cases: a constant Text; a bound Text updating once per batch and not for an equal write; +`visible` and `enabled` bindings; a click reaching its action; typing reaching `onChange` with no +echo of an application-set value; a Switch's selected case, and nothing for a key without one; +lazily mounted, kept Tabs pages; a ForEach updating a kept row in place, and following inserts, +removals and reorders; a Dialog holding content only while open; a drag onto an accepting target, +onto a refusing one, and onto one without `accepts`. + +`RecordingBackend` passes them in `tests/test_ui_conformance.cpp`; the TUI and Qt Quick backends +run the same list in their own suites. + +## 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 echo | Two-way bindings (a field bound to the signal its `onChange` writes) cannot loop, on any toolkit. | +| 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. | +| 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. | +``` + +- [ ] **Step 3: Write `docs/spec/ui/frontend.md`** + +```markdown +# Frontend seam — design + +Design spec for `include/morph/ui/frontend.hpp`: how an application is written once and run on +whichever frontend `main` picks at runtime. Implemented by `morph::tui::Frontend` and +`morph::qt_quick::Frontend`. + +## Contents + +- [Shape](#shape) +- [What `run` guarantees](#what-run-guarantees) +- [Choosing a frontend](#choosing-a-frontend) +- [Design decisions](#design-decisions) + +## Shape + +- `ui::Application` is the application: its controllers, handlers and bridge wiring, and + `view()`, the tree to mount. +- `ui::ApplicationFactory` (`std::function(AppContext&)>`) makes it. + The factory is where an application constructs its `BridgeHandler`s, with `ctx.executor()` as + their callback executor. +- `ui::AppContext` is what the frontend hands the factory: + +| Member | Meaning | +|---|---| +| `runtime()` | The reactive runtime the view is mounted in; owned by the frontend | +| `executor()` | The runtime's owner; every `BridgeHandler`'s callback executor | +| `scheduler()` | Timers on that executor (`ui::Scheduler` is `reactive::Scheduler`, `ui::TimerHandle` is `reactive::TimerHandle`); a `Query` refreshed on a period uses it | +| `ioLoop()` | The caller-driven I/O loop the frontend runs on (the TUI), for sockets that share its thread; null when there is none (Qt Quick) | +| `quit(exitCode)` | Ends `run` once the current event is handled | +| `frontendName()` | The name `selectFrontend` matched | + +- `ui::Frontend` is a frontend: `name()` and `run(factory)`. +- `ui::FrontendOption{name, usable, make}` is one frontend a binary was built with. + +## What `run` guarantees + +1. The runtime and its owner executor exist before the factory is called. +2. The factory is called once, then `view()` once, and the view is mounted. +3. The loop runs until `AppContext::quit`; `run` returns its exit code. +4. Teardown is the reverse: the mounted view, then the application, then the runtime. A binding or + handler never outlives what it points at. + +This header defines the order; each frontend's own suite pins it, and both +pin the step that is easiest to get wrong — **the application is destroyed +before the runtime**: + +| Frontend | Test | +|---|---| +| `morph::tui` | `tests/tui/test_tui_frontend.cpp`, "tui::Frontend: the application is destroyed before the runtime" | +| `morph::qt_quick` | `tests/qt_quick/test_frontend.cpp`, "qt_quick Frontend: the mount dies before the application, the application before the runtime, Qt last" | + +Each application holds a `Signal` on the runtime, and each test asserts that +the runtime's destruction reports no live node +(`reactive::detail::site::kRuntimeOutlived`). + +## Choosing a frontend + +`selectFrontend(built, argc, argv, env = processEnvironment())`: + +1. the last `--ui=` or `--ui ` on the command line; +2. else `MORPH_UI`, when set and non-empty; +3. else the first option, in the order `main` lists them, whose `usable()` holds — an empty + `usable` counts as usable. `main` lists Qt Quick first (usable on macOS and Windows, and with + `DISPLAY` or `WAYLAND_DISPLAY` set), then the TUI (usable when stdin is a terminal). + +A frontend named on the command line or in `MORPH_UI` is used even when its `usable()` is false: +the user asked for it. Every failure throws `FrontendSelectionError` naming the built frontends: + +| Failure | Message | +|---|---| +| A name no option has | `morph::ui: no frontend named '' is built (built: qt, tui)` | +| `--ui` with no name, or `--ui=` | `morph::ui: --ui needs a frontend name (built: qt, tui)` | +| Nothing named and nothing usable | `morph::ui: no built frontend is usable here (built: qt, tui)` | +| An option whose `make` returns null | `morph::ui: frontend '' could not be made` | + +With nothing built the list reads `none`. `EnvironmentReader` is the seam a test replaces; +`processEnvironment()` reads `std::getenv`, which selection calls from `main` before any thread +exists. + +## Design decisions + +| Decision | Why | +|---|---| +| The frontend is injected, the application is a factory | Application code never names a toolkit; one binary carries every frontend it was built with. | +| 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. | +| 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. | +| Errors name the built frontends | "Not built" and "misspelled" look the same to the user; the list answers both. | +``` + +- [ ] **Step 4: Update the maps and the changelog** + +In `docs/spec/README.md`, under "Start here — specs by the question they answer", add after the +"**Reactive state and control**" group that Part 1 added: + +```markdown +**Declarative UI** +[`ui/view_tree.md`](ui/view_tree.md) · +[`ui/backend_contract.md`](ui/backend_contract.md) · +[`ui/frontend.md`](ui/frontend.md) +``` + +In `docs/ARCHITECTURE.md`, "Namespace map" table, add after the `morph::reactive` row: + +```markdown +| `morph::ui` | Toolkit-agnostic view tree, its mount, and the frontend seam | `Prop`, `Key`, `Action`, `Common`, the node types and their builders (`text`, `button`, …, `switchOf`, `switchOn`, `forEach`, `table`), `Widget` and the widget interfaces, `IViewBackend`, `Mounted`, `AppContext`, `Application`, `Frontend`, `FrontendOption`, `selectFrontend`; `ui::testing::RecordingBackend`, `ConformanceProbe`, `conformanceCases()` | +``` + +and in "Header map", after the `reactive/` sub-section Part 1 added: + +```markdown +#### `ui/` — view tree, mount, frontend seam + +| Header | Responsibility | +|---|---| +| `ui/view.hpp` | `Prop`, `Key`, `Common`, every node type and builder, `forEach`, `table`, the type-erased row model | +| `ui/backend.hpp` | `Widget`, `ContainerWidget`, one widget interface per node kind, `IViewBackend` | +| `ui/mount.hpp` | `Mounted` — builds widgets once, keeps them current through bindings; keyed-row reconciliation | +| `ui/frontend.hpp` | `AppContext`, `Application`, `Frontend`, `FrontendOption`, `selectFrontend` | +| `ui/testing/recording_backend.hpp` | `RecordingBackend` — headless widgets, operation log, golden dump, interaction helpers | +| `ui/testing/backend_conformance.hpp` | `ConformanceProbe`, `conformanceCases()` — the cases every backend passes | +``` + +In `CHANGELOG.md`, under `## [Unreleased]` → `### Added`, directly after the `morph::reactive` entry: + +```markdown +- **`morph::ui`: a toolkit-agnostic view tree, its mount and the frontend seam.** Header-only, in + the base `morph` target. + - Immutable nodes — text, buttons, inputs, check boxes, selects, menus, stacks, grids, panels + (collapsible), scroll areas, Switch, Tabs, Dialog, busy indicators, keyed `forEach` lists, + tables, date-time inputs, sliders and file pickers — whose properties are constants or reactive + bindings, with drag-and-drop on every node. + - `Mounted` builds retained widgets through an `IViewBackend` once and keeps them current through + equality-gated bindings; keyed rows are updated in place and reordered by moving only the rows + outside a longest run already in order. + - `AppContext`, `Application`, `Frontend` and `selectFrontend` (`--ui=`, `MORPH_UI`, the first + usable frontend) let `main` pick a frontend at runtime. + - `ui::testing::RecordingBackend` and the backend-conformance cases fix the contract every + frontend implements. + - Specified in `docs/spec/ui/`. +``` + +- [ ] **Step 5: Build the docs with warnings as errors** + +```bash +cmake -S . -B build/docs -G Ninja -DMORPH_BUILD_DOCUMENTATION=ON -DMORPH_BUILD_TESTS=OFF -DMORPH_BUILD_EXAMPLES=OFF +cmake --build build/docs --target doc +``` + +Expected: exits 0. A warning naming a `morph::ui` symbol is a missing `@param`/`@tparam`/`@return`: fix the +header, not the Doxygen configuration. Then +`pre-commit run --files docs/spec/ui/*.md docs/spec/README.md docs/ARCHITECTURE.md CHANGELOG.md` (whitespace, +end-of-file, codespell) and `npx --yes markdownlint-cli2 docs/spec/ui/*.md`: expected clean against +`.markdownlint.yaml` (119 columns, code blocks and tables exempt). + +- [ ] **Step 6: Commit** + +```bash +git add docs/spec/ui docs/spec/README.md docs/ARCHITECTURE.md CHANGELOG.md +git commit -m "wip(ui): specify morph::ui + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 10: Whole-part verification and the squash + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Strict build and the full suite** + +```bash +cmake --build build/reactive && ctest --test-dir build/reactive --output-on-failure +``` + +Expected: every test passes, not only `[ui]` — `VERIFY_INTERFACE_HEADER_SETS` compiles each of the six new public +headers standalone as part of the build, so a header that leans on another's includes fails here. Confirm they were +offered to it rather than assuming so: + +```bash +find build/reactive/morph_verify_interface_header_sets/morph/ui -name '*.hpp.cxx' | wc -l +``` + +Expected: `6` — one generated stub per `ui` header (`view`, `backend`, `mount`, `frontend`, +`testing/recording_backend`, `testing/backend_conformance`). Fewer means a header is missing from the `FILE_SET` +and nothing compiled it standalone. + +- [ ] **Step 2: The layering holds** + +```bash +grep -rnE '#include .*(bridge\.hpp|/net/|/qt/|/tui/|core/tui|Qt|io_loop\.hpp)' include/morph/ui && exit 1 || echo "ui includes no bridge, toolkit, terminal or loop header" +``` + +Expected: the echo line. `frontend.hpp` names `exec::IoLoop` through a forward declaration only. + +- [ ] **Step 3: Sanitizers** (Linux; on macOS use an ASan configure of the same tree) + +```bash +cmake --preset clang-asan && cmake --build --preset clang-asan +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-asan/tests/morph_tests asan +./build/clang-asan/tests/morph_tests "[ui]" +cmake --preset clang-tsan && cmake --build --preset clang-tsan +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-tsan/tests/morph_tests tsan +./build/clang-tsan/tests/morph_tests "[ui]" +``` + +Expected: clean. ASan is the observer for "a helper survives a handler that destroys its widget", for every +remount test (a binding left pointing at a destroyed widget is a use after free, not a wrong value), and for the +Review Focus 3 case; the instrumentation check is what makes a clean run mean something. + +- [ ] **Step 4: clang-tidy over the changed lines** — the recipe in CONTRIBUTING, "Running the `clang-tidy-diff` + gate locally", with `origin/master...HEAD` and the file count asserted non-zero. Because the + `tests/.clang-tidy` subtractions reach headers analysed from test translation units (its header comment), also + run clang-tidy directly on the header-set stubs, which inherit the root configuration: + +```bash +cmake build/reactive -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +clang-tidy -p build/reactive --quiet \ + build/reactive/morph_verify_interface_header_sets/morph/ui/*.hpp.cxx \ + build/reactive/morph_verify_interface_header_sets/morph/ui/testing/*.hpp.cxx +``` + +Expected: no findings from either. A finding in `include/morph/ui/` is fixed in the header; a `NOLINT` is added +only with the reason on the same line, as the plan's code already does for its four. + +- [ ] **Step 5: Install/export** + +```bash +bash scripts/check_install_export.sh +``` + +Expected: passes — its consumer translation unit includes every installed public header, the six `ui/` ones +included. + +- [ ] **Step 6: Commit any fixes** + +```bash +git add -A include/morph/ui tests docs CMakeLists.txt +git commit -m "wip(ui): fixes from the sanitizer, tidy and install gates + +Signed-off-by: Christian Parpart " +``` + +Skip the commit if there was nothing to fix, and say so in the hand-off. + +- [ ] **Step 7: Squash this part into its one commit** + +Write the message to `/tmp/ui-commit.txt`: + +```text +ui: toolkit-agnostic view tree, mount, frontend seam and RecordingBackend + +morph::ui, header-only in the base morph target. An immutable view tree +whose props are constants or reactive bindings, with drag-and-drop on +every node; 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 child scopes so teardown +is always bindings-first and child-first. The frontend seam (AppContext, +Application, Frontend, selectFrontend) lets main pick a frontend at +runtime. RecordingBackend and the conformance cases fix the contract the +TUI and Qt Quick backends implement. Specified in docs/spec/ui/. + +Signed-off-by: Christian Parpart +``` + +If the implementation deviated from this plan anywhere, add a paragraph before the sign-off saying what the plan +said, what was done instead, and why (master plan, "The docs commit"). + +Then run the master plan's "Squashing a part" procedure with `key=ui` and `git commit -F /tmp/ui-commit.txt`: + +```bash +key=ui +pre=$(git rev-parse HEAD) +first=$(git log --reverse --format=%H --grep="^wip($key):" master..HEAD | head -1) +test -n "$first" || { echo "no wip($key) commits -- wrong key?"; exit 1; } +git log --format=%s "$first^..HEAD" | grep -v "^wip($key): " && { echo "foreign commit inside the part"; exit 1; } +git reset --soft "$first^" +git commit -F /tmp/ui-commit.txt +git diff --exit-code "$pre" HEAD && echo "squash preserved the tree" +git log --oneline master..HEAD +``` + +Expected: `squash preserved the tree`, and the last command lists, newest first, +`ui: toolkit-agnostic view tree, mount, frontend seam and RecordingBackend`, +`reactive: signal graph, view state and declarative control`, `core: build against core-cpp 0.7`, +`docs: declarative UI program — design and implementation plans`. diff --git a/docs/superpowers/plans/2026-10-04-declarative-ui-tui-3-tui.md b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-3-tui.md new file mode 100644 index 000000000..be61e4725 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-3-tui.md @@ -0,0 +1,8858 @@ +# Declarative UI, Part 3 — `IoLoopDriver::Caller` and `morph::tui` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Let an `exec::IoLoop` be turned by the thread that built it, then ship `morph::tui` — a compiled, +optional terminal frontend that renders a `morph::ui` view tree through core-cpp's `core::tui` and runs a whole +application (UI, sockets, timers, bridge callbacks) on that one caller-driven loop. + +**Architecture:** `IoLoopDriver::Caller` starts no thread and treats the constructing thread as the loop's, so +`runAndWait` and a component's close run inline there, the way the single-threaded WebAssembly build already +works. `morph::tui` is a STATIC library (`morph_tui`): `LoopExecutor` adapts the loop to `exec::IExecutor`; +`Backend` implements `ui::IViewBackend` with one private widget class per widget interface, each owning a +`core::tui::Component` view that the containers arrange in `render()` with a cell-based stack/grid solver; +`Frontend` owns the loop, the executor, the reactive runtime, the `Screen` and a `TuiRuntime`, pumps one dispatch +per input event, coalesces redraws to one per loop turn, and races the pump against a quit signal. + +**Tech Stack:** C++23; core-cpp 0.7 (`core::net::EventLoop`, `core::async::{Task, whenAny, AsyncQueue}`, +`core::tui::{Screen, Component, InputField, List, Canvas}`, `core::tui::runtime::TuiRuntime`); libunicode 0.9.3; +Part 1's `morph::reactive`; Part 2's `morph::ui`; Catch2 v3. + +**Spec:** `docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md` §2 (`morph::tui` packaging), §5b, §6, +§7 (tui), §9; `docs/superpowers/specs/2026-10-04-core-cpp-mouse-design.md` (mouse tracking, pointer capture, +`Screen::componentAt`, all provided by core-cpp 0.7 after Part 0). + +This is **Part 3 of 11** of the declarative-UI program. Read the master plan +`docs/superpowers/plans/2026-10-04-declarative-ui-tui.md` first: it fixes the branch, the commit layout and the +`wip(...)` commit convention. This part has **two groups**: Tasks 1–4 use key `ioloop` and end by squashing into +`core: IoLoopDriver::Caller, the I/O loop on the calling thread`; Tasks 5–20 use key `tui` and end by squashing into +`tui: morph::tui terminal frontend`. Parts 0, 1 and 2 have landed: core-cpp is pinned at 0.7 (`MouseTracking`, +`Terminal::setMouseTracking`, `Screen::releasePointer`, public `Screen::componentAt`, implicit pointer capture), +and `morph::reactive` and `morph::ui` exist exactly as the interface contract lists them. + +## Global Constraints + +- Part 1's constraints apply unchanged: SPDX line first, `#pragma once`, naming per `.clang-tidy` (types + `CamelCase`, functions/variables `camelBack`, private members `_camelBack`, constants `kName`, identifiers ≥ 3 + characters except `i j k x y n fn cb op`), present-tense comments with no history or issue numbers, full Doxygen + on every symbol of `include/morph/**`, `-Weverything -Werror`, clang-tidy clean, the sign-off trailer. +- `morph::tui` is the only compiled library in `morph` besides Qt's MOC'd `morph_qt_impl`; its public headers + (`include/morph/tui/`) name `core::tui` types but no private `src/tui/` type. Widgets, the solver, the scheduler + and the session are private `.hpp`/`.cpp` pairs under `src/tui/`, included as `"tui/.hpp"`. +- New `.cpp` files are analysed by clang-tidy as main files: no `operator[]` on containers + (`cppcoreguidelines-pro-bounds-avoid-unchecked-container-access` — use `.at()`, iterators or ranges), no + `switch` over an enum (`-Wswitch-enum` plus `-Wswitch-default` — use `if` chains), no two adjacent parameters + of one type unless they are used together (`bugprone-easily-swappable-parameters` — coordinates travel as + `core::tui::Point`), `auto const` for every local that is not modified. +- Every widget's view is a `core::tui::Component` that the widget owns; the widget registers it in the backend's + `detail::Context` and clears the `Screen`'s focus from it before it is destroyed. `core::tui` holds raw pointers + to components (children, focus, overlays); nothing in `src/tui/` may leave one dangling. +- Exactly one `Screen::dispatchEvent` per input event, everywhere in `src/tui/` (spec 1 §1, "one dispatch per + input event"). +- Coordinates: `MouseEvent::x/y` are 1-based and relative to the receiving component's `screenBounds()`; + `Screen::componentAt(row, col)` and `core::tui::Point` are 0-based viewport cells. + +## Review Focus + +1. **A binding that echoes the text the user just typed back into a TextInput** (the controlled-input loop: + `onChange` writes a signal, the signal's binding calls `setText` with the same string) must neither move the + cursor nor fire `onChange` again (Task 10 test "setText with the field's own text keeps the cursor and fires + nothing"). +2. **One input event dispatched more than once** — `core::tui::runtime::runModal` dispatches twice — would type + every character twice (Task 17 test "each input event is dispatched once: typing h, i reports \"hi\""). +3. **A widget destroyed while it holds keyboard focus** (a remounted ForEach row, a closed Switch case) must not + leave the `Screen`'s focus pointing at a freed component (Task 8 test "destroying the focused widget clears the + screen's focus"). +4. **Tab inside an open Dialog** must cycle inside it and never reach the tree behind it, because `Screen`'s own + focus walk does not know overlays exist (Task 12 test "Tab stays inside the open dialog", Task 17 test "Tab + cycles inside an open dialog and never reaches the tree behind it"). +5. **A drag source destroyed mid-gesture** (the card was moved by another client and remounted) must end the drag, + hide the drag label and highlight, and leave no dangling source pointer (Task 15 test "destroying the drag + source mid-drag ends the gesture"). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `include/morph/core/io_loop.hpp` | `IoLoopDriver`, `IoLoop(IoLoopDriver)`, `driver()`, Caller-mode `runningHere()` and destructor | +| `tests/test_io_loop.cpp` | Both drivers for the five existing cases; Caller-only cases | +| `tests/test_timeout_scheduler.cpp` | A `TimeoutScheduler` on a Caller loop | +| `tests/net/test_socket_backend.cpp` | An asynchronous round trip with the client on a Caller loop; the synchronous-verb throw | +| `include/morph/tui/loop_executor.hpp`, `src/tui/loop_executor.cpp` | `tui::LoopExecutor` | +| `include/morph/tui/backend.hpp`, `src/tui/backend.cpp` | `tui::Backend` — the factories, `fit`, focus traversal, animation | +| `include/morph/tui/frontend.hpp`, `src/tui/frontend.cpp` | `tui::FrontendConfig`, `tui::Frontend`, `tui::frontendOption` | +| `src/tui/layout.hpp/.cpp` | `tui::layout` — stack and grid solver in cells | +| `src/tui/context.hpp/.cpp` | `detail::Context` — what every widget of one backend shares | +| `src/tui/widget.hpp/.cpp` | `WidgetBase`, `ContainerBase`, `Hosted<>`, `View`, `TuiWidget<>`, `TuiContainer<>`, stack arrangement, focus helpers | +| `src/tui/leaf_widgets.hpp/.cpp` | Text, Button, Checkbox, Spacer, Busy, Slider | +| `src/tui/container_widgets.hpp/.cpp` | Stack, Slot, Grid, Panel, Scroll, Dialog | +| `src/tui/field_widgets.hpp/.cpp` | `FieldView`; TextInput, DateTimeInput, FilePicker; local date-time text | +| `src/tui/list_widgets.hpp/.cpp` | `ListView`; Select (radio, dropdown), Menu, Tabs | +| `src/tui/table_widget.hpp/.cpp` | Table | +| `src/tui/drag.hpp/.cpp` | `DragController` — drag label, drop highlight, target search | +| `src/tui/scheduler.hpp/.cpp` | `LoopScheduler` — `reactive::Scheduler` on `EventLoop` timers | +| `src/tui/session.hpp/.cpp` | `Session` — the `ui::AppContext`, the event pump, draw coalescing, quit | +| `tests/tui/CMakeLists.txt`, `tests/tui/tui_harness.hpp` | The `morph_tui_tests` target; a headless screen + backend harness | +| `tests/tui/test_tui_*.cpp` | One file per task below | +| `CMakeLists.txt`, `cmake/morphConfig.cmake.in`, `CMakePresets.json`, `.github/workflows/ci.yml` | Option, dependency, target, install component, presets, CI | +| `docs/spec/core/executor.md`, `docs/spec/concurrency_and_lifetimes.md` | The I/O loop's drivers | +| `docs/spec/tui/frontend.md`, `docs/spec/README.md`, `docs/ARCHITECTURE.md`, `docs/GETTING-STARTED.md`, `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md` | Spec, maps, pointer, changelog | + +## Build and test commands + +```bash +# ioloop group (Tasks 1-4): the base tree, plus morph::net for the socket cases +cmake -S . -B build/reactive -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_EXAMPLES=OFF -DMORPH_BUILD_NET=ON +cmake --build build/reactive --target morph_tests morph_net_tests +./build/reactive/tests/morph_tests "[io_loop],[timeout_scheduler]" +./build/reactive/tests/net/morph_net_tests "[caller]" + +# tui group (Tasks 5-19) +cmake -S . -B build/tui -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_EXAMPLES=OFF -DMORPH_BUILD_TUI=ON +cmake --build build/tui --target morph_tui_tests +./build/tui/tests/tui/morph_tui_tests "[tui]" +``` + +Configuring prints `morph: warnings: ... strict=ON`; if it does not, stop and say so (CONTRIBUTING, "Warnings are +errors"). The first `MORPH_BUILD_TUI=ON` configure without an installed libunicode fetches it, and libunicode's own +configure downloads `UCD.zip` from www.unicode.org. + +--- + +### Task 1: `IoLoopDriver::Caller` + +**Files:** +- Modify: `include/morph/core/io_loop.hpp` — the whole file (below) +- Test: `tests/test_io_loop.cpp` — the whole file (below) + +**Interfaces:** +- Consumes: `core::net::PlatformLoop`, `EventLoop::runOnce(std::optional)`, + `EventLoop::runUntilIdle()`, `EventLoop::pendingTimerCount()` (core-cpp `src/core/net/EventLoop.hpp`); + `morph::exec::runningOn(core::async::IExecutor const&)` (`include/morph/core/executor.hpp:147`); + `morph::async::detail::TimeoutScheduler` (`include/morph/core/timeout_scheduler.hpp:56`). +- Produces: `morph::exec::IoLoopDriver { OwnThread, Caller }`; `explicit IoLoop(IoLoopDriver = OwnThread)`; + `[[nodiscard]] IoLoopDriver IoLoop::driver() const noexcept`. + +- [ ] **Step 1: Write the failing test** + +Replace `tests/test_io_loop.cpp` with: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// morph::exec::IoLoop, the loop every socket, timer and probe of an +// application shares: docs/spec/core/executor.md, "The I/O loop". A case that +// holds for both drivers runs once per driver: on an OwnThread loop the test +// waits for the loop's thread, on a Caller loop the test thread turns the loop. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace { + +using morph::exec::IoLoop; +using morph::exec::IoLoopDriver; +using namespace std::chrono_literals; + +bool onLoop(IoLoop& loop) { + return morph::exec::runningOn(static_cast(loop.loop())); +} + +/// Waits until @p done holds: by polling while an OwnThread loop's thread runs, +/// or by turning a Caller loop on this thread. +template +bool driveUntil(IoLoop& loop, Pred done, std::chrono::milliseconds budget = 2000ms) { + if (loop.driver() == IoLoopDriver::OwnThread) { + return morph::testing::waitUntil(done, morph::testing::WaitBudget{budget}); + } + auto const deadline = std::chrono::steady_clock::now() + budget; + while (!done()) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + static_cast(loop.loop().runOnce(5ms)); + } + return true; +} + +} // namespace + +TEST_CASE("IoLoop: a posted task runs as a task of the loop, on the loop's thread", "[io_loop]") { + auto const driver = GENERATE(IoLoopDriver::OwnThread, IoLoopDriver::Caller); + IoLoop loop{driver}; + CHECK(loop.driver() == driver); + REQUIRE_FALSE(onLoop(loop)); + // Outside a turn, only a Caller loop's driving thread is the loop's. + CHECK(loop.runningHere() == (driver == IoLoopDriver::Caller)); + + std::atomic ran{false}; + std::atomic runningHere{false}; + std::atomic scoped{false}; + std::thread::id where; + loop.post([&] { + runningHere = loop.runningHere(); + scoped = onLoop(loop); + where = std::this_thread::get_id(); + ran = true; + }); + REQUIRE(driveUntil(loop, [&] { return ran.load(); })); + CHECK(runningHere.load()); + CHECK(scoped.load()); + CHECK((where == std::this_thread::get_id()) == (driver == IoLoopDriver::Caller)); +} + +TEST_CASE("IoLoop: runAndWait returns once the task has run, and nests inline", "[io_loop]") { + auto const driver = GENERATE(IoLoopDriver::OwnThread, IoLoopDriver::Caller); + IoLoop loop{driver}; + bool ranHere = false; + bool ranInTurn = false; + loop.runAndWait([&] { + ranHere = loop.runningHere(); + ranInTurn = onLoop(loop); + }); + CHECK(ranHere); + // A Caller loop runs it inline on the driving thread, outside any turn. + CHECK(ranInTurn == (driver == IoLoopDriver::OwnThread)); + + // From inside a task of the loop, waiting on the loop would wait forever: + // it runs the nested task inline instead. + bool nestedRan = false; + loop.runAndWait([&] { loop.runAndWait([&] { nestedRan = loop.runningHere(); }); }); + CHECK(nestedRan); +} + +TEST_CASE("IoLoop: a task that throws is logged and later tasks still run", "[io_loop]") { + auto const driver = GENERATE(IoLoopDriver::OwnThread, IoLoopDriver::Caller); + IoLoop loop{driver}; + loop.post([] { throw std::runtime_error{"boom"}; }); + std::atomic later{false}; + loop.post([&] { later = true; }); + CHECK(driveUntil(loop, [&] { return later.load(); })); +} + +TEST_CASE("IoLoop: a weak handle stops posting once the loop is gone", "[io_loop]") { + auto const driver = GENERATE(IoLoopDriver::OwnThread, IoLoopDriver::Caller); + auto loop = std::make_unique(driver); + auto const weak = loop->weak(); + std::atomic ran{false}; + REQUIRE(weak.post([&] { ran = true; })); + REQUIRE(driveUntil(*loop, [&] { return ran.load(); })); + loop.reset(); + CHECK_FALSE(weak.post([] {})); +} + +// OwnThread only: a Caller loop is destroyed on its driving thread outside a +// turn (the next cases), never from one of its own tasks. +TEST_CASE("IoLoop: destroyed from one of its own tasks, it lets the thread finish instead of joining it", + "[io_loop]") { + auto loop = std::make_unique(); + auto const weak = loop->weak(); + std::atomic destroyed{false}; + loop->post([&] { + loop.reset(); + destroyed = true; + }); + REQUIRE(morph::testing::waitUntil([&] { return destroyed.load(); })); + // The thread held the loop's last share and released it on the way out. + CHECK(morph::testing::waitUntil([&] { return !weak.post([] {}); })); +} + +TEST_CASE("IoLoop (Caller): nothing runs until the driving thread turns the loop", "[io_loop][caller]") { + IoLoop loop{IoLoopDriver::Caller}; + std::atomic ran{false}; + loop.post([&] { ran = true; }); + std::this_thread::sleep_for(20ms); + CHECK_FALSE(ran.load()); + static_cast(loop.loop().runUntilIdle()); + CHECK(ran.load()); +} + +TEST_CASE("IoLoop (Caller): runAndWait on the driving thread outside a turn runs inline", "[io_loop][caller]") { + IoLoop loop{IoLoopDriver::Caller}; + REQUIRE(loop.runningHere()); + bool ran = false; + std::thread::id where; + loop.runAndWait([&] { + ran = true; + where = std::this_thread::get_id(); + }); + CHECK(ran); + CHECK(where == std::this_thread::get_id()); +} + +TEST_CASE("IoLoop (Caller): another thread's post and runAndWait run on the driving thread's next turn", + "[io_loop][caller]") { + IoLoop loop{IoLoopDriver::Caller}; + std::atomic posted{false}; + std::atomic scoped{false}; + std::atomic waited{false}; + std::atomic otherRunningHere{true}; + std::thread::id postedOn; + std::thread::id waitedOn; + std::thread other{[&] { + // Recorded, not asserted: Catch2 assertions are not thread-safe. + otherRunningHere = loop.runningHere(); + loop.post([&] { + postedOn = std::this_thread::get_id(); + scoped = onLoop(loop); + posted = true; + }); + loop.runAndWait([&] { waitedOn = std::this_thread::get_id(); }); + waited = true; + }}; + bool const done = driveUntil(loop, [&] { return waited.load(); }); + other.join(); + REQUIRE(done); + CHECK_FALSE(otherRunningHere.load()); + CHECK(posted.load()); + CHECK(scoped.load()); + CHECK(postedOn == std::this_thread::get_id()); + CHECK(waitedOn == std::this_thread::get_id()); +} + +TEST_CASE("IoLoop (Caller): destroyed outside a turn, it drops queued work unrun", "[io_loop][caller]") { + auto loop = std::make_unique(IoLoopDriver::Caller); + auto const weak = loop->weak(); + auto sentinel = std::make_shared(0); + bool ran = false; + loop->post([&ran, held = sentinel] { + static_cast(held); + ran = true; + }); + CHECK(sentinel.use_count() == 2); + loop.reset(); + CHECK_FALSE(ran); + CHECK(sentinel.use_count() == 1); + CHECK_FALSE(weak.post([] {})); +} + +TEST_CASE("IoLoop (Caller): a component's close runs inline on the driving thread", "[io_loop][caller]") { + IoLoop loop{IoLoopDriver::Caller}; + std::atomic fired{false}; + { + morph::async::detail::TimeoutScheduler scheduler{loop}; + static_cast(scheduler.schedule(1ms, [&] { fired = true; })); + // No turn has run, so the arming is still queued. The destructor's + // close runs inline here; waiting for a turn would never return. + } + std::this_thread::sleep_for(5ms); + static_cast(loop.loop().runUntilIdle()); + CHECK_FALSE(fired.load()); + CHECK(loop.loop().pendingTimerCount() == 0U); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL to compile — `no member named 'IoLoopDriver' in namespace 'morph::exec'`. + +- [ ] **Step 3: Implement** + +Replace `include/morph/core/io_loop.hpp` with: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "executor.hpp" +#include "logger.hpp" +#include "profiler.hpp" + +#if defined(__EMSCRIPTEN__) && !defined(__EMSCRIPTEN_PTHREADS__) +#define MORPH_IO_LOOP_HOST_DRIVEN 1 +#else +#include +#endif + +/// @file +/// `IoLoop` — the one I/O loop an application runs, and the owner of every +/// socket, timer and probe built on it. +/// +/// `morph::net::SocketBackend`, `morph::net::SocketServer`, +/// `TimeoutScheduler` and `offline::NetworkMonitor` own no thread. Each keeps +/// its state on a `core::net::PlatformLoop` and touches it only in tasks that +/// loop runs; its public verbs post to the loop and return. An application +/// constructs one `IoLoop` and hands it to every one of them, so one thread +/// carries every connection, every deadline and the connectivity probe. +/// +/// @par Who turns the loop +/// - **`IoLoopDriver::OwnThread`** (native default): the `IoLoop` owns one +/// thread that runs the loop until the `IoLoop` is destroyed. +/// - **`IoLoopDriver::Caller`**: no thread is started. The constructing thread +/// is the loop's and turns it — `loop().runOnce()`, `loop().runUntilIdle()` +/// or a `blockOn()` — so a terminal UI and its sockets, timers and bridge +/// callbacks share that one thread. +/// - **Single-threaded WebAssembly** (`__EMSCRIPTEN__` without +/// `__EMSCRIPTEN_PTHREADS__`): there is no thread to start whichever driver is +/// asked for. The loop is host-driven — the browser's timer pumps it — and +/// the one thread there is is always the loop's, so a verb runs its body +/// inline. + +namespace morph::exec { + +/// @brief Who turns an `IoLoop`. +enum class IoLoopDriver : std::uint8_t { + /// The `IoLoop` starts one thread that runs the loop until it is destroyed. + OwnThread, + /// No thread is started: the constructing thread turns the loop, and is the + /// loop's between turns as well as inside them. + Caller, +}; + +/// @brief Owns a `core::net::PlatformLoop` and, for `IoLoopDriver::OwnThread` +/// natively, the one thread that runs it. +/// +/// Cross-thread surface: `post()`, `runAndWait()`, `runningHere()`, `driver()` +/// and `weak()`. Everything else about the loop — timers, sockets, flows — is +/// touched only in tasks it runs, or, for a `Caller` loop, on its driving +/// thread. +/// +/// @par Lifetime +/// Must outlive every component built on it: a component's destructor runs +/// its close on the loop and waits for it, which a stopped loop would never +/// run. Destroy the components first, then the `IoLoop`. A `Caller` loop and +/// its components are destroyed on the driving thread, outside a turn. +class IoLoop { + /// The loop, shared with the thread that runs it, so that an `IoLoop` + /// destroyed on its own thread can let that thread finish the turn it is in. + struct Impl { + ::core::net::PlatformLoop loop; + }; + +public: + /// @brief A non-owning handle that posts to the loop only while it exists. + /// + /// For a callback that another executor runs and that may outlive the + /// component that armed it — a `RemoteServer` reply arriving on a pool + /// thread after its transport has gone. + class Weak { + public: + /// @brief Posts @p task if the loop still exists. + /// @tparam F A copyable callable taking no arguments. + /// @param task What to run on the loop thread. + /// @return `false` if the loop is gone and @p task was dropped. + template + bool post(F&& task) const { + std::shared_ptr const impl = _impl.lock(); + if (!impl) { + return false; + } + impl->loop.post(guarded(std::forward(task))); + return true; + } + + private: + friend class IoLoop; + explicit Weak(std::weak_ptr impl) : _impl{std::move(impl)} {} + std::weak_ptr _impl; + }; + +#ifndef MORPH_IO_LOOP_HOST_DRIVEN + /// @brief Creates the loop and, with `IoLoopDriver::OwnThread`, starts the + /// thread that runs it. + /// @param driver Who turns the loop. `Caller` starts no thread and makes the + /// constructing thread the loop's. + explicit IoLoop(IoLoopDriver driver = IoLoopDriver::OwnThread) + : _impl{std::make_shared()}, _driver{driver}, _driverThread{std::this_thread::get_id()} { + if (driver == IoLoopDriver::OwnThread) { + _thread = std::thread{[impl = _impl] { + // One name for every IoLoop thread: an application runs one, and + // its sockets, deadlines and probe all turn on it. + MORPH_THREAD_NAME("morph.io"); + impl->loop.run(); + }}; + } + } + + /// @brief Stops the loop and joins its thread; a `Caller` loop is + /// destroyed in place. + /// + /// Work still queued is dropped by the loop's own teardown, not run. On the + /// loop's own thread — a task that dropped the last owner — an `OwnThread` + /// loop cannot join: it asks the loop to stop and lets the thread end with + /// the turn it is in, the loop kept alive by the thread's own share of it. + /// A `Caller` loop has no thread to stop: destroyed outside a turn nothing + /// is running it, so its teardown is serialised with dispatch. Destroyed + /// inside one of its own turns, that turn would return into a destroyed + /// loop; an assertion refuses it. + ~IoLoop() { + if (_driver == IoLoopDriver::Caller) { + assert(!::morph::exec::runningOn(static_cast<::core::async::IExecutor const&>(_impl->loop)) && + "a caller-driven IoLoop must be destroyed outside its own turns"); + return; + } + _impl->loop.stop(); + if (_thread.get_id() == std::this_thread::get_id()) { + _thread.detach(); + } else if (_thread.joinable()) { + _thread.join(); + } + } +#else + /// @brief Creates the loop. Starts no thread whichever driver is asked for: + /// the browser's timer pumps it. + /// @param driver Recorded and reported by `driver()`; both drivers behave + /// alike here. + explicit IoLoop(IoLoopDriver driver = IoLoopDriver::OwnThread) : _impl{std::make_shared()}, _driver{driver} {} + + /// @brief Destroys the loop, dropping whatever it still holds. + ~IoLoop() = default; +#endif + + IoLoop(const IoLoop&) = delete; + IoLoop& operator=(const IoLoop&) = delete; + IoLoop(IoLoop&&) = delete; + IoLoop& operator=(IoLoop&&) = delete; + + /// @brief The loop itself, for a component arming timers or opening + /// sockets from inside one of its tasks, and for the driving thread + /// of a `Caller` loop to turn it. + /// @return The platform loop; valid for this object's lifetime. + [[nodiscard]] ::core::net::EventLoop& loop() noexcept { return _impl->loop; } + + /// @brief Who turns this loop. + /// @return The driver passed at construction. + [[nodiscard]] IoLoopDriver driver() const noexcept { return _driver; } + + /// @brief Whether the calling thread may touch the loop's state directly. + /// + /// Natively that is the loop's own `ExecutorScope`, stated for every turn, + /// and — for a `Caller` loop — also the driving thread between turns, where + /// nothing else can be running the loop. Under single-threaded WebAssembly + /// there is one thread, so the answer is always yes. + /// @return True inside one of the loop's tasks, or on a `Caller` loop's + /// driving thread. + [[nodiscard]] bool runningHere() const noexcept { +#ifndef MORPH_IO_LOOP_HOST_DRIVEN + return ::morph::exec::runningOn(static_cast<::core::async::IExecutor const&>(_impl->loop)) || + (_driver == IoLoopDriver::Caller && std::this_thread::get_id() == _driverThread); +#else + return true; +#endif + } + + /// @brief Queues @p task to run on the loop thread, in a later turn. + /// + /// A throw out of @p task is logged and swallowed: one escaping a turn + /// would end the loop's thread. + /// @tparam F A copyable callable taking no arguments. + /// @param task What to run. + template + void post(F&& task) { + _impl->loop.post(guarded(std::forward(task))); + } + + /// @brief Runs @p task on the loop and returns once it has run. + /// + /// Inline where `runningHere()` holds, so a task of the loop — or a + /// `Caller` loop's driving thread — never waits on itself. For teardown and + /// for the few verbs that must answer with the loop's own result. A task + /// the loop drops unrun (it was destroyed first) ends the wait rather than + /// hanging it. + /// @tparam F A copyable callable taking no arguments. + /// @param task What to run. + template + void runAndWait(F&& task) { + if (runningHere()) { + std::forward(task)(); + return; + } + auto done = std::make_shared>(); + std::future finished = done->get_future(); + post([task = std::forward(task), done]() mutable { + task(); + done->set_value(); + }); + try { + finished.get(); + } catch (const std::future_error&) { // NOLINT(bugprone-empty-catch) + // Dropped unrun, or it threw (logged by `post`): nothing is left to + // wait for either way. + } + } + + /// @brief A handle that posts only while this loop exists. + /// @return The weak handle. + [[nodiscard]] Weak weak() const { return Weak{_impl}; } + +private: + template + static auto guarded(F&& task) { + return [task = std::forward(task)]() mutable noexcept { + try { + task(); + } catch (const std::exception& exc) { + ::morph::log::logError("[io-loop] task threw: {}", exc.what()); + } catch (...) { + ::morph::log::logError("[io-loop] task threw an unknown exception"); + } + }; + } + + std::shared_ptr _impl; + IoLoopDriver _driver; +#ifndef MORPH_IO_LOOP_HOST_DRIVEN + std::thread::id _driverThread; + std::thread _thread; +#endif +}; + +} // namespace morph::exec +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[io_loop]"` +Expected: PASS — 10 test cases (the four two-driver cases count once each, their sections twice). + +Mutation check: in `runningHere()`, delete the `|| (_driver == IoLoopDriver::Caller && ...)` clause and rebuild. +Expected: FAIL at `REQUIRE(loop.runningHere())` in "runAndWait on the driving thread outside a turn runs inline" +(the REQUIRE stops the case before `runAndWait` would wait for a turn nobody drives), and FAIL in "a posted task +runs as a task of the loop" on the `Caller` section. Restore the clause. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/core/io_loop.hpp tests/test_io_loop.cpp +git commit -m "wip(ioloop): IoLoopDriver::Caller, the I/O loop on the calling thread + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 2: `TimeoutScheduler` and `SocketBackend` on a Caller loop + +**Files:** +- Modify: `tests/test_timeout_scheduler.cpp` — append one case at the end +- Modify: `tests/net/test_socket_backend.cpp` — append two cases at the end of the file (after the last + `TEST_CASE`, "a synchronous verb called on the loop's own thread fails instead of waiting on itself") +- Test: the two files above + +**Interfaces:** +- Consumes: `IoLoop(IoLoopDriver::Caller)` (Task 1); `SocketBackend(IoLoop&, std::string_view)`, + `SocketBackend::bindModel(BindRequest, IExecutor&)`, `SocketBackend::execute(ModelId, ActionCall, IExecutor*)`, + `SocketBackend::registerModel` (`include/morph/net/socket_backend.hpp:105, 249, 346, 175`); the file-local helpers + `privateBind()` (`tests/net/test_socket_backend.cpp:1707`) and `echoCall()` (`:2215`), both in that file's + anonymous namespaces above the append point. +- Produces: nothing new; it proves the components already built on `IoLoop` work on a Caller loop. + +- [ ] **Step 1: Write the tests** + +Append to `tests/test_timeout_scheduler.cpp`: + +```cpp +TEST_CASE("TimeoutScheduler: on a caller-driven loop, a callback fires in the driving thread's turn", + "[timeout_scheduler][caller]") { + morph::exec::IoLoop loop{morph::exec::IoLoopDriver::Caller}; + TimeoutScheduler scheduler{loop}; + bool fired = false; + bool cancelledFired = false; + std::thread::id where; + static_cast(scheduler.schedule(5ms, [&] { + fired = true; + where = std::this_thread::get_id(); + })); + auto const cancelled = scheduler.schedule(5ms, [&] { cancelledFired = true; }); + scheduler.cancel(cancelled); + + auto const deadline = std::chrono::steady_clock::now() + 2s; + while (!fired && std::chrono::steady_clock::now() < deadline) { + static_cast(loop.loop().runOnce(5ms)); + } + static_cast(loop.loop().runOnce(20ms)); + CHECK(fired); + CHECK(where == std::this_thread::get_id()); + CHECK_FALSE(cancelledFired); +} +``` + +Append to `tests/net/test_socket_backend.cpp`: + +```cpp +// ── A client on a caller-driven loop ──────────────────────────────────────── +// +// The client's IoLoop has no thread: this test thread turns it, the way a TUI +// application's one loop is turned by its input pump. The server keeps a loop +// of its own, so only the client side is caller-driven. + +TEST_CASE("SocketBackend: on a caller-driven loop, an asynchronous round trip completes in the driving thread's turns", + "[net][socket_backend][caller]") { + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + morph::net::SocketServer wsServer{*server, 0}; + REQUIRE(wsServer.listen()); + + morph::exec::IoLoop loop{morph::exec::IoLoopDriver::Caller}; + morph::exec::MainThreadExecutor cbOwner; + auto backend = std::make_unique( + loop, "ws://127.0.0.1:" + std::to_string(static_cast(wsServer.port()))); + auto const drive = [&](auto done) { + auto const deadline = std::chrono::steady_clock::now() + std::chrono::seconds{5}; + while (!done()) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + static_cast(loop.loop().runOnce(std::chrono::milliseconds{5})); + static_cast(cbOwner.runOnce()); + } + return true; + }; + + // On the driving thread waitForConnected cannot wait for the loop: it answers at once. + REQUIRE(drive([&] { return backend->waitForConnected(std::chrono::milliseconds{0}); })); + + std::optional mid; + auto bound = backend->bindModel(privateBind("SbEchoModel"), cbOwner); + bound.then([&](morph::exec::detail::ModelId boundId) { mid = boundId; }).onError([](const std::exception_ptr&) {}); + REQUIRE(drive([&] { return mid.has_value(); })); + + std::optional reply; + std::thread::id repliedOn; + auto executed = backend->execute(*mid, echoCall(), &cbOwner); + executed + .then([&](const std::shared_ptr& result) { + reply = *std::static_pointer_cast(result); + repliedOn = std::this_thread::get_id(); + }) + .onError([](const std::exception_ptr&) {}); + REQUIRE(drive([&] { return reply.has_value(); })); + CHECK(*reply == "7"); + CHECK(repliedOn == std::this_thread::get_id()); + + // The close runs inline on the driving thread, outside a turn, with the + // reader flow still parked; the loop's teardown unwinds it afterwards. + backend.reset(); +} + +TEST_CASE("SocketBackend: on a caller-driven loop, a synchronous verb on the driving thread throws instead of waiting", + "[net][socket_backend][caller]") { + morph::exec::IoLoop loop{morph::exec::IoLoopDriver::Caller}; + // Never driven: the throw comes before any I/O, and the destructor's close + // runs inline with the connect still queued. + morph::net::SocketBackend backend{loop, "ws://127.0.0.1:9"}; + CHECK_THROWS_WITH(static_cast(backend.registerModel("SbEchoModel", nullptr)), + Catch::Matchers::ContainsSubstring("cannot wait on the I/O loop's own thread")); +} +``` + +Add `#include ` to `tests/net/test_socket_backend.cpp`'s includes (alphabetically after ``). + +- [ ] **Step 2: Run them** + +Run: + +```bash +cmake --build build/reactive --target morph_tests morph_net_tests && ./build/reactive/tests/morph_tests "[caller]" && ./build/reactive/tests/net/morph_net_tests "[caller]" +``` +Expected: PASS — 1 case and 2 cases. These test behaviour Task 1 already made true, so there is no red step; the +mutation below is what shows they measure it. + +- [ ] **Step 3: Prove the cases can fail** + +In `include/morph/core/io_loop.hpp`, change the `Caller` clause of `runningHere()` to `false &&` and rebuild. +Expected: "a synchronous verb on the driving thread throws instead of waiting" FAILS (`registerModel` now posts +and waits for a turn — the case hangs until ctest's 120 s timeout, which is the failure); run it alone with +`timeout 20 ./build/reactive/tests/net/morph_net_tests "*synchronous verb on the driving thread*"` and read the +non-zero exit. Restore the clause. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_timeout_scheduler.cpp tests/net/test_socket_backend.cpp +git commit -m "wip(ioloop): TimeoutScheduler and SocketBackend on a caller-driven loop + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 3: Specify the drivers + +**Files:** +- Modify: `docs/spec/core/executor.md` — "Type overview" (the sentence "There are eleven types…" and its table), + and the whole "The I/O loop — `IoLoop`" section +- Modify: `docs/spec/concurrency_and_lifetimes.md` — the `exec::IoLoop` row of the thread-roles table +- Modify: `CHANGELOG.md` — `## [Unreleased]` → `### Added` (the heading Part 1 created), appended as its last + entry + +**Interfaces:** +- Consumes: the API exactly as Task 1 ships it. +- Produces: the authoritative description the TUI frontend (Task 17) and Part 6's transport rely on. + +- [ ] **Step 1: Update `docs/spec/core/executor.md`** + +In "Type overview", change "There are eleven types and one free function" to "There are twelve types and one free +function", and add this row after the `IoLoop` row: + +```markdown +| `IoLoopDriver` | `morph::exec` (in `io_loop.hpp`) | Who turns an `IoLoop`: `OwnThread` (its own thread) or `Caller` (the constructing thread); see [the I/O loop](#the-io-loop--ioloop). | +``` + +Replace the section from `## The I/O loop — \`IoLoop\`` up to (not including) `## Failure modes` with: + +```markdown +## The I/O loop — `IoLoop` + +`IoLoop` owns a `core::net::PlatformLoop`. Who turns it is fixed at +construction by `IoLoopDriver`: + +- **`OwnThread`** (the default): the `IoLoop` starts one thread that runs the + loop; its destructor stops the loop and joins that thread. +- **`Caller`**: no thread is started. The constructing thread is the loop's and + turns it — `loop().runOnce()`, `loop().runUntilIdle()`, or a `blockOn()` such + as `core::tui::runtime::TuiRuntime`'s. A terminal application runs this way, + so its UI, sockets, timers and bridge callbacks share one thread. +- Under single-threaded WebAssembly no thread exists to start, whichever driver + is asked for; the browser's timer pumps the loop. + +An application constructs one and passes it to every component that does I/O +or keeps time — `morph::net::SocketBackend`, `morph::net::SocketServer`, +`TimeoutScheduler`, `offline::NetworkMonitor` — each of which keeps its state +on the loop and touches it only in the loop's tasks. + +| Member | Cross-thread? | What it does | +|---|---|---| +| `IoLoop(driver)` | — | Creates the loop; `OwnThread` also starts its thread. | +| `driver()` | yes | The `IoLoopDriver` it was built with. | +| `loop()` | returns a reference | The `core::net::EventLoop`, for timers, sockets and flows armed from inside a task of it, and for a `Caller` loop's driving thread to turn it. | +| `post(task)` | yes | Queues `task` for a later turn. A throw out of it is logged and swallowed: one escaping a turn would end the loop's thread. | +| `runAndWait(task)` | yes | Runs `task` on the loop and returns once it has run — inline where `runningHere()` holds, so a task never waits on itself. A task the loop drops unrun ends the wait. Components use it for teardown and for the verbs that must answer (`SocketServer::listen`). | +| `runningHere()` | yes | Whether the caller may touch the loop's state: inside one of the loop's tasks, or — for a `Caller` loop — anywhere on the driving thread, between turns included. Always true under single-threaded WebAssembly. | +| `weak()` | yes | A handle whose `post` is a no-op returning `false` once the loop is gone — for a callback another executor runs, such as a `RemoteServer` reply. | + +**It must outlive every component built on it.** Their destructors run their +close on the loop and wait for it. Destroyed on its own thread (a task dropped +the last owner), an `OwnThread` loop cannot join: it stops the loop and +detaches, and the thread's own share of the loop keeps it alive until the turn +it is in ends. + +**A `Caller` loop.** `runningHere()` holds on the driving thread between turns, +because nothing else can be running the loop then: core-cpp's +`EventLoop::teardownIsSerialisedWithDispatch()` holds, so the loop's state may +be touched directly. That is why `runAndWait` and a component's close run +inline there instead of waiting for a turn only the waiting thread could drive. +Other threads still `post()`, `weak().post()` and `runAndWait()`, which waits +for the driving thread's next turn. The loop and its components are destroyed +on the driving thread, outside a turn: a component's close runs inline, and the +loop's own teardown drops whatever is still queued without running it. +Destroying a `Caller` loop inside one of its own turns is refused by an +assertion, because that turn would return into a destroyed loop. A synchronous +`SocketBackend` verb called on the driving thread throws, as it does on an +`OwnThread` loop's thread: it would wait for a reply only that thread can +deliver. +``` + +- [ ] **Step 2: Update `docs/spec/concurrency_and_lifetimes.md`** + +Replace the `exec::IoLoop` row of the thread-roles table with: + +```markdown +| `exec::IoLoop` | One thread natively (`IoLoopDriver::OwnThread`), or the constructing thread (`IoLoopDriver::Caller`); host-pumped under single-threaded WebAssembly | The I/O loop (`io_loop.hpp`): a core-cpp `PlatformLoop` that owns every `morph::net` socket, every `TimeoutScheduler` timer and `NetworkMonitor`'s probe. The application constructs one and injects it into each; `IoLoop::post` is the one way in from another thread. A `Caller` loop is turned by the thread that built it — a terminal UI's input pump — and is that thread's between turns too. | +``` + +- [ ] **Step 3: Add the changelog entry** + +Append to `CHANGELOG.md`, `## [Unreleased]` → `### Added`: + +```markdown +- **`exec::IoLoopDriver::Caller`: an I/O loop turned by the thread that built it.** + `IoLoop(IoLoopDriver::Caller)` starts no thread; the constructing thread turns + the loop (`loop().runOnce()`, `runUntilIdle()`, a `blockOn()`), and + `runningHere()` holds there between turns too, so `runAndWait` and a + component's close run inline. Other threads still `post()`. `IoLoop()` keeps + its own thread (`IoLoopDriver::OwnThread`), unchanged. Specified in + `docs/spec/core/executor.md`, "The I/O loop". +``` + +- [ ] **Step 4: Build the docs with warnings as errors** + +```bash +cmake -S . -B build/docs -G Ninja -DMORPH_BUILD_DOCUMENTATION=ON -DMORPH_BUILD_TESTS=OFF -DMORPH_BUILD_EXAMPLES=OFF +cmake --build build/docs --target doc +``` + +Expected: exits 0. A warning naming `IoLoopDriver` or `IoLoop` is a missing brief or `@param`: fix the header. + +- [ ] **Step 5: Commit** + +```bash +git add docs/spec/core/executor.md docs/spec/concurrency_and_lifetimes.md CHANGELOG.md +git commit -m "wip(ioloop): specify the I/O loop's drivers + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 4: Verify and squash the `ioloop` group + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Strict build and the full suites** + +```bash +cmake --build build/reactive && ctest --test-dir build/reactive --output-on-failure +``` + +Expected: every test passes (`morph_tests` and `morph_net_tests`). + +- [ ] **Step 2: Sanitizers** (Linux; on macOS an ASan configure of the same tree) + +```bash +cmake --preset clang-tsan -DMORPH_BUILD_NET=ON && cmake --build --preset clang-tsan +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-tsan/tests/morph_tests tsan +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-tsan/tests/net/morph_net_tests tsan +./build/clang-tsan/tests/morph_tests "[io_loop],[timeout_scheduler]" +./build/clang-tsan/tests/net/morph_net_tests "[caller]" +cmake --preset clang-asan -DMORPH_BUILD_NET=ON && cmake --build --preset clang-asan +./build/clang-asan/tests/morph_tests "[io_loop],[timeout_scheduler]" +./build/clang-asan/tests/net/morph_net_tests "[caller]" +``` + +Expected: clean. The cross-thread `post`/`runAndWait` case is what TSan watches; the round trip's `backend.reset()` +with a parked reader followed by the loop's teardown is what ASan watches. + +- [ ] **Step 3: clang-tidy over the changed lines** — CONTRIBUTING's "Running the `clang-tidy-diff` gate locally" + recipe with `origin/master...HEAD` and the file count asserted non-zero. Expected: no findings. + +- [ ] **Step 4: Commit any fixes**, as `wip(ioloop): fixes from the sanitizer and tidy gates`; skip when there + were none, and say so in the hand-off. + +- [ ] **Step 5: Squash the group** + +Follow the master plan's "Squashing a part" procedure with `key=ioloop` and this message: + +```text +core: IoLoopDriver::Caller, the I/O loop on the calling thread + +IoLoop(IoLoopDriver::Caller) starts no thread: the constructing thread +turns the loop, and runningHere() holds there between turns as well, so +runAndWait and a component's close run inline instead of waiting for a +turn only that thread could drive. It generalises the single-threaded +WebAssembly build's host-driven loop to native builds, so a terminal +UI's sockets, timers, bridge callbacks and input share one thread. The +IoLoop tests run under both drivers; TimeoutScheduler and an +asynchronous SocketBackend round trip run on a Caller loop. + +Signed-off-by: Christian Parpart +``` + +The last line of the procedure must list, after Parts 0–2's commits, +`core: IoLoopDriver::Caller, the I/O loop on the calling thread`. + +--- +### Task 5: `MORPH_BUILD_TUI`, the `morph_tui` target and `LoopExecutor` + +**Files:** +- Modify: `CMakeLists.txt` — five places, each named by the text around it (below) +- Create: `include/morph/tui/loop_executor.hpp`, `src/tui/loop_executor.cpp` +- Create: `tests/tui/CMakeLists.txt` +- Test: `tests/tui/test_tui_loop_executor.cpp` + +**Interfaces:** +- Consumes: `core::net::EventLoop::post(std::function)` (core-cpp `EventLoop.hpp`); + `core::async::ExecutorScope` (core-cpp `async/ExecutorContext.hpp`); `morph::exec::IExecutor`, + `IExecutor::coreExecutor()`, `runningOn(IExecutor&)` (`include/morph/core/executor.hpp:66–147`); + `morph::log::logError`, `ScopedLoggerOverride` (`include/morph/core/logger.hpp:256, 279`); CMake helpers + `apply_warnings`, `apply_sanitizers`, `apply_coverage` (`cmake/compiler_options.cmake:474, 539, 742`) and + `morph_use_cpm` (`CMakeLists.txt:163`). +- Produces: option `MORPH_BUILD_TUI`; variable `MORPH_LIBUNICODE_VERSION`; targets `morph_tui` / `morph::tui` and + `morph_tui_tests`; `morph::tui::LoopExecutor(core::net::EventLoop&)` with `post(std::function)`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/tui/test_tui_loop_executor.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using morph::tui::LoopExecutor; +using namespace std::chrono_literals; + +TEST_CASE("tui::LoopExecutor: a posted task runs in a later loop turn, with the executor current", + "[tui][loop_executor]") { + core::net::PlatformLoop loop; + LoopExecutor executor{loop}; + bool ran = false; + bool current = false; + executor.post([&] { + ran = true; + current = morph::exec::runningOn(executor); + }); + CHECK_FALSE(ran); + static_cast(loop.runUntilIdle()); + CHECK(ran); + CHECK(current); +} + +TEST_CASE("tui::LoopExecutor: a task still queued when the executor is destroyed is dropped", "[tui][loop_executor]") { + core::net::PlatformLoop loop; + bool ran = false; + { + LoopExecutor executor{loop}; + executor.post([&] { ran = true; }); + } + static_cast(loop.runUntilIdle()); + CHECK_FALSE(ran); +} + +TEST_CASE("tui::LoopExecutor: a throwing task is logged, and later tasks still run", "[tui][loop_executor]") { + std::vector logged; + morph::log::ScopedLoggerOverride const capture{ + [&](morph::log::LogLevel, std::string_view message) { logged.emplace_back(message); }}; + core::net::PlatformLoop loop; + LoopExecutor executor{loop}; + bool later = false; + executor.post([] { throw std::runtime_error{"boom"}; }); + executor.post([&] { later = true; }); + static_cast(loop.runUntilIdle()); + CHECK(later); + REQUIRE(logged.size() == 1); + CHECK(logged.front().find("boom") != std::string::npos); +} + +TEST_CASE("tui::LoopExecutor: a post from another thread runs on the thread turning the loop", + "[tui][loop_executor]") { + core::net::PlatformLoop loop; + LoopExecutor executor{loop}; + std::atomic ran{false}; + std::thread::id where; + std::thread other{[&] { + executor.post([&] { + where = std::this_thread::get_id(); + ran = true; + }); + }}; + other.join(); + auto const deadline = std::chrono::steady_clock::now() + 2s; + while (!ran.load() && std::chrono::steady_clock::now() < deadline) { + static_cast(loop.runOnce(5ms)); + } + REQUIRE(ran.load()); + CHECK(where == std::this_thread::get_id()); +} +``` + +Create `tests/tui/CMakeLists.txt`: + +```cmake +add_executable(morph_tui_tests + test_tui_loop_executor.cpp +) + +target_link_libraries(morph_tui_tests + PRIVATE + morph::tui + morph_test_main +) +# The widgets, the layout solver and the session are private to morph_tui +# (src/tui/); their tests include them as "tui/.hpp". +target_include_directories(morph_tui_tests PRIVATE ${PROJECT_SOURCE_DIR}/src) + +apply_warnings(morph_tui_tests) + +if(DEFINED AF_SANITIZER) + apply_sanitizers(morph_tui_tests ${AF_SANITIZER}) +endif() +if(AF_COVERAGE) + apply_coverage(morph_tui_tests) +endif() + +include(Catch) +# LABELS "tui" is what makes `ctest -L tui` select this suite: Catch2 tags are +# not ctest labels (see tests/net/CMakeLists.txt). +catch_discover_tests(morph_tui_tests DISCOVERY_MODE PRE_TEST PROPERTIES TIMEOUT 120 LABELS "tui") +``` + +- [ ] **Step 2: Wire the option, the dependency and the target** + +In `CMakeLists.txt`: + +**(a)** After the line `option(MORPH_BUILD_NET ...)`, add: + +```cmake +option(MORPH_BUILD_TUI "Build morph::tui, the terminal frontend over core-cpp's core::tui (a compiled library; fetches libunicode if not installed)" OFF) +# core-cpp builds no core::tui under Emscripten, so there is nothing for +# morph::tui to wrap there. +if(MORPH_BUILD_TUI AND EMSCRIPTEN) + message(WARNING "MORPH_BUILD_TUI is ignored under Emscripten: core-cpp builds no core::tui there.") + set(MORPH_BUILD_TUI OFF) +endif() +``` + +**(b)** Directly before the line `# ── core-cpp ───…`, add: + +```cmake +# ── libunicode (MORPH_BUILD_TUI) ──────────────────────────────────────────── +# core::tui segments graphemes and measures display widths with libunicode. +# core-cpp is added below with CORE_CPP_FETCH_DEPS OFF, so it takes the +# unicode::unicode target its parent already provides; this block provides it. +# Found first and fetched only when no installed one satisfies the bound, as +# glaze and Tracy are. The version and options are core-cpp's own row for the +# same dependency (its cmake/CoreCppDependencies.cmake), so an application that +# links both libraries gets one libunicode. A fetch runs libunicode's own +# configure, which downloads UCD.zip from www.unicode.org. +set(MORPH_LIBUNICODE_VERSION 0.9.3) +if(MORPH_BUILD_TUI AND NOT TARGET unicode::unicode) + find_package(libunicode ${MORPH_LIBUNICODE_VERSION} CONFIG QUIET) + if(NOT libunicode_FOUND) + morph_use_cpm(libunicode) + CPMAddPackage( + NAME libunicode + VERSION ${MORPH_LIBUNICODE_VERSION} + GITHUB_REPOSITORY contour-terminal/libunicode + GIT_TAG v${MORPH_LIBUNICODE_VERSION} + EXCLUDE_FROM_ALL YES + SYSTEM YES + OPTIONS "LIBUNICODE_TESTING OFF" "LIBUNICODE_BENCHMARK OFF" "LIBUNICODE_TOOLS OFF" + "LIBUNICODE_EXAMPLES OFF" "PEDANTIC_COMPILER OFF" "PEDANTIC_COMPILER_WERROR OFF" + "BUILD_SHARED_LIBS OFF") + endif() +endif() +``` + +**(c)** In the core-cpp block: in its leading comment, replace "none of core-cpp's own tests, examples, TUI or TLS" +with "none of core-cpp's own tests, examples or TLS, and its TUI only with MORPH_BUILD_TUI (without image +decoding)"; in the `CPMAddPackage(NAME core-cpp …)` call replace `"CORE_CPP_WITH_TUI OFF"` with +`"CORE_CPP_WITH_TUI ${MORPH_BUILD_TUI}" "CORE_CPP_WITH_IMAGES OFF"`. After that block's closing `endif()` (the one +after `unset(_morph_core_cpp_exclude_from_all)`), add: + +```cmake +# A core-cpp found installed brings whatever modules it was built with. One +# built with CORE_CPP_WITH_TUI=OFF has no core::tui, and morph::tui would fail +# much later, at link time, naming a target nobody created. +if(MORPH_BUILD_TUI AND NOT TARGET core::tui) + message(FATAL_ERROR + "MORPH_BUILD_TUI=ON, but the core-cpp this configure found (${core-cpp_DIR}) was built without " + "core::tui (CORE_CPP_WITH_TUI=OFF). Install a core-cpp built with CORE_CPP_WITH_TUI=ON, or take " + "it off CMAKE_PREFIX_PATH so morph fetches one.") +endif() +``` + +**(d)** Directly before the line that starts +`# ── Guard: every public header must belong to some target's FILE_SET`, add: + +```cmake +# ── morph::tui terminal frontend (optional, compiled) ─────────────────────── +# A STATIC library, unlike every other morph component: its widgets are +# core::tui components, compiled once here rather than in every consumer. +# Created before the public-header guard below, so include/morph/tui/ is +# checked whenever the option is on. Its tests are added after the Tests +# section, which provides Catch2. +if(MORPH_BUILD_TUI) + add_library(morph_tui STATIC + src/tui/loop_executor.cpp + ) + add_library(morph::tui ALIAS morph_tui) + target_sources(morph_tui + PUBLIC + FILE_SET HEADERS + BASE_DIRS include + FILES + include/morph/tui/loop_executor.hpp + ) + # Same standalone-compile check the core target gets. + set_target_properties(morph_tui PROPERTIES VERIFY_INTERFACE_HEADER_SETS ON) + target_include_directories(morph_tui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(morph_tui PUBLIC morph core::tui) + apply_warnings(morph_tui) + # Every consumer of this archive (tests/tui, the examples that choose a + # frontend) carries an AF_SANITIZER block of its own, which is what makes + # instrumenting a static library safe (see morph_qt_impl below). + if(DEFINED AF_SANITIZER) + apply_sanitizers(morph_tui ${AF_SANITIZER}) + endif() + if(AF_COVERAGE) + apply_coverage(morph_tui) + endif() +endif() +``` + +In the guard below it, add `"include/morph/tui/:morph_tui"` as the last entry of `set(_morph_optional_components …)` +and `morph_tui` to `foreach(_morph_target IN ITEMS morph morph_net morph_qt morph_qt_forms morph_offline_sqlite)` +(it becomes `… morph_offline_sqlite morph_tui)`). Also extend that block's comment list "(morph::net, morph::qt, +morph::qt_forms, morph::offline_sqlite)" with ", morph::tui". + +**(e)** After the `# ── morph::net raw-socket WebSocket transport …` block's final `endif()`, add: + +```cmake +# ── morph::tui tests (optional) ───────────────────────────────────────────── +# The target itself is created before the public-header guard; its suite is +# added here, after the Tests section has provided Catch2 and morph_test_main. +if(MORPH_BUILD_TUI AND MORPH_BUILD_TESTS) + add_subdirectory(tests/tui) +endif() +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `cmake -S . -B build/tui -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_EXAMPLES=OFF -DMORPH_BUILD_TUI=ON` +Expected: configure FAILS — `Cannot find source file: src/tui/loop_executor.cpp`. + +- [ ] **Step 4: Implement** + +Create `include/morph/tui/loop_executor.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include + +#include "../attributes.hpp" +#include "../core/executor.hpp" + +/// @file +/// @brief `morph::tui::LoopExecutor`: a morph executor whose tasks run in turns of a core-cpp event loop. +/// +/// Specified in `docs/spec/tui/frontend.md`, "The loop executor". + +namespace morph::tui { + +/// @brief Runs every posted task in a later turn of one `core::net::EventLoop`, with this executor current. +/// +/// The TUI frontend's reactive `Runtime` is owned by one, and every `BridgeHandler` an application builds receives +/// it as its callback executor, so model replies, timers, sockets and input all run on the thread that turns the +/// loop. `post()` may be called from any thread. A task still queued when the executor is destroyed is dropped, +/// and a task that throws is logged and does not stop the loop. +class LoopExecutor final : public exec::IExecutor { +public: + /// @param loop The loop to post to. Borrowed: it must outlive this executor. + explicit LoopExecutor(::core::net::EventLoop& loop MORPH_LIFETIMEBOUND); + + /// @brief Destroys the executor; tasks it posted that have not run yet are dropped when the loop reaches them. + ~LoopExecutor() override; + + LoopExecutor(LoopExecutor const&) = delete; + LoopExecutor& operator=(LoopExecutor const&) = delete; + LoopExecutor(LoopExecutor&&) = delete; + LoopExecutor& operator=(LoopExecutor&&) = delete; + + /// @brief Queues @p task for a later turn of the loop. + /// @param task What to run; inside it `exec::runningOn(*this)` holds. + void post(std::function task) override; + +private: + ::core::net::EventLoop* _loop; + /// Read by each queued task: once this executor is gone the task is dropped. A box rather than the executor, + /// because a task outlives the executor that posted it. + std::shared_ptr _alive; +}; + +} // namespace morph::tui +``` + +Create `src/tui/loop_executor.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +namespace morph::tui { + +LoopExecutor::LoopExecutor(::core::net::EventLoop& loop) : _loop{&loop}, _alive{std::make_shared(this)} {} + +LoopExecutor::~LoopExecutor() = default; + +void LoopExecutor::post(std::function task) { + _loop->post([alive = std::weak_ptr{_alive}, task = std::move(task)] { + auto const self = alive.lock(); + if (!self) { + return; + } + ::core::async::ExecutorScope const scope{(*self)->coreExecutor()}; + // Caught here: one exception escaping a loop turn would end the frontend's event loop. + try { + task(); + } catch (std::exception const& failure) { + ::morph::log::logError("[tui] loop task threw: {}", failure.what()); + } catch (...) { + ::morph::log::logError("[tui] loop task threw an unknown exception"); + } + }); +} + +} // namespace morph::tui +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cmake -S . -B build/tui -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_EXAMPLES=OFF -DMORPH_BUILD_TUI=ON +cmake --build build/tui --target morph_tui_tests morph_verify_interface_header_sets +./build/tui/tests/tui/morph_tui_tests "[loop_executor]" +``` + +Expected: configure prints `morph: warnings: ... strict=ON` and `-- morph: warnings: '-Weverything' verified on N +target(s)` with N one higher than `build/reactive`'s; the build passes (the header set check compiles +`loop_executor.hpp` standalone); 4 test cases pass. + +Mutation check: in `LoopExecutor::post`, remove the `try`/`catch` and call `task();` bare. Expected: FAIL in "a +throwing task is logged, and later tasks still run" (the exception leaves `runUntilIdle()`). Restore. + +Then prove the header guard covers `include/morph/tui/`: remove `include/morph/tui/loop_executor.hpp` from the +`FILE_SET` list and re-configure. Expected: configure FAILS listing `include/morph/tui/loop_executor.hpp` as a +public header in no FILE_SET. Restore. + +- [ ] **Step 6: Commit** + +```bash +git add CMakeLists.txt include/morph/tui/loop_executor.hpp src/tui/loop_executor.cpp tests/tui +git commit -m "wip(tui): MORPH_BUILD_TUI, the morph_tui target and LoopExecutor + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 6: Install, presets, CI and the compiled-component rule + +**Files:** +- Modify: `CMakeLists.txt` — the "Install & export" section: a block after the `if(TARGET morph_qt_impl)` block, and + the `write_basic_package_version_file` call with the comment above it. The line + `foreach(_morph_component IN ITEMS offline_sqlite qt_forms qt net)` stays byte-identical: + `scripts/test_check_install_export.sh:252` sed-matches it. +- Modify: `cmake/morphConfig.cmake.in` — a block before `include("${CMAKE_CURRENT_LIST_DIR}/morphTargets.cmake")` +- Modify: `CMakePresets.json` — `windows-everything` and `linux-everything` `cacheVariables` +- Modify: `.github/workflows/ci.yml` — the Configure steps of `linux-sanitizers` + (`cmake --preset ${{ matrix.preset }} + -DMORPH_BUILD_NET=ON -DMORPH_BUILD_OFFLINE_SQLITE=ON …`, near line 608), `linux-all-features` (near line 2450) and + `clang-tidy` (near line 3034) +- Modify: `CONTRIBUTING.md` — "Toolchain", first paragraph +- Modify: `README.md` — the "Relevant CMake options" sentence and the "The components are …" sentence + +**Interfaces:** +- Consumes: `morph_tui`, `MORPH_LIBUNICODE_VERSION` (Task 5). +- Produces: install component `tui` (export name `morph::tui`), listed in `MORPH_INSTALLED_COMPONENTS`. + +- [ ] **Step 1: Install and export the component** + +In `CMakeLists.txt`, after the `if(TARGET morph_qt_impl) … endif()` block of the install section, add: + +```cmake + # morph::tui is a compiled archive, like morph_qt_impl: it installs its + # headers and its library. It links core::tui, which core-cpp installs only + # when libunicode was found rather than fetched -- a fetched dependency is in + # no export set, so core-cpp leaves core::tui out of its package and says + # why. Exporting morph::tui then would name a target no installed package + # provides, so it is left out too, and says so. + if(TARGET morph_tui) + get_target_property(_morph_core_tui_imported core::tui IMPORTED) + get_target_property(_morph_unicode_imported unicode::unicode IMPORTED) + if(_morph_core_tui_imported OR _morph_unicode_imported) + set_target_properties(morph_tui PROPERTIES EXPORT_NAME tui) + install(TARGETS morph_tui + EXPORT morphTargets + FILE_SET HEADERS DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}" + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + ) + list(APPEND MORPH_INSTALLED_COMPONENTS tui) + else() + message(STATUS + "morph: the tui component is not installed: libunicode was fetched, so core-cpp cannot " + "install core::tui for it to link. Install libunicode ${MORPH_LIBUNICODE_VERSION} and put " + "it on CMAKE_PREFIX_PATH to install morph::tui.") + endif() + unset(_morph_core_tui_imported) + unset(_morph_unicode_imported) + endif() +``` + +Replace the comment above `write_basic_package_version_file` and the call with: + +```cmake + # SameMajorVersion follows docs/spec/VERSIONING.md, which is what decides + # this: there, minor releases are "additive and source-compatible" and + # only a major breaks the stable surface, so an install of 0.2 does + # satisfy a consumer asking for 0.1. ARCH_INDEPENDENT only while every + # installed component is header-only: without it CMake rejects an install + # made on a differently-sized-void* machine, which is exactly right once a + # compiled archive (morph_qt_impl, the MorphForms module, morph::tui) is in + # the package. + set(_morph_arch_independent ARCH_INDEPENDENT) + if(TARGET morph_qt_impl OR "forms_qml" IN_LIST MORPH_INSTALLED_COMPONENTS + OR "tui" IN_LIST MORPH_INSTALLED_COMPONENTS) + set(_morph_arch_independent "") + endif() + write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/morphConfigVersion.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion + ${_morph_arch_independent} + ) + unset(_morph_arch_independent) +``` + +In `cmake/morphConfig.cmake.in`, directly before `include("${CMAKE_CURRENT_LIST_DIR}/morphTargets.cmake")`, add: + +```cmake +# morph::tui links core::tui, which the core-cpp package found above provides +# only when it was installed with its TUI. Without it morphTargets.cmake would +# name a target nobody created. +if("tui" IN_LIST morph_KNOWN_COMPONENTS AND NOT TARGET core::tui) + set(morph_FOUND FALSE) + set(morph_NOT_FOUND_MESSAGE + "this morph install has the tui component, but the core-cpp package found at '${core-cpp_DIR}' " + "provides no core::tui.") + return() +endif() +``` + +- [ ] **Step 2: Presets, CI and the documented rule** + +`CMakePresets.json`: add `"MORPH_BUILD_TUI": "ON",` after `"MORPH_BUILD_QT": "ON",` in both `windows-everything` and +`linux-everything`. + +`.github/workflows/ci.yml`: in each of the three Configure steps named above, add the line `-DMORPH_BUILD_TUI=ON \` +directly after `-DMORPH_BUILD_NET=ON \`. In `linux-sanitizers`, extend the comment above its Configure step with: +"morph::tui is built too: its event pump, its posted redraws and its loop executor are where a terminal +application's threading lives." + +`CONTRIBUTING.md`, "Toolchain": after "morph is a header-only C++23 library." insert: + +```markdown +One kind of component is compiled: an optional frontend that wraps a compiled +toolkit — `morph::tui` (`MORPH_BUILD_TUI`) over core-cpp's `core::tui`, like +`morph_qt_impl` for Qt's MOC — is a static library, so its widgets compile once +rather than in every consumer. +``` + +`README.md`: in "Relevant CMake options: …" add `` `MORPH_BUILD_TUI`, `` after `` `MORPH_BUILD_QT`, ``; replace +"The components are `net` (`MORPH_BUILD_NET`, POSIX only), `offline_sqlite`" with "The components are `net` +(`MORPH_BUILD_NET`, POSIX only), `tui` (`MORPH_BUILD_TUI`; installed when libunicode was found rather than +fetched), `offline_sqlite`". + +- [ ] **Step 3: Verify the install, both ways it can go** + +```bash +cmake -S . -B build/install-tui -G Ninja -DCMAKE_BUILD_TYPE=Release -DMORPH_BUILD_TESTS=OFF \ + -DMORPH_BUILD_EXAMPLES=OFF -DMORPH_BUILD_TUI=ON 2>&1 | tee build/install-tui.log +cmake --build build/install-tui +cmake --install build/install-tui --prefix "$PWD/build/install-tui/prefix" +grep -c "morph: the tui component is not installed" build/install-tui.log +``` + +- **libunicode was fetched** (the grep prints 1): `cmake --install` exits 0, + `prefix/lib/cmake/morph/morphTargets.cmake` + names no `morph::tui`, and `morphConfig.cmake`'s `morph_KNOWN_COMPONENTS` has no `tui`. Mutation: replace the + `if(_morph_core_tui_imported OR _morph_unicode_imported)` condition with `if(TRUE)` and re-configure. Expected: + generation FAILS — `install(EXPORT "morphTargets" ...) includes target "morph_tui" which requires target + "core-cpp-tui" that is not in any export set`. Restore. +- **libunicode was found** (the grep prints 0): `morphTargets.cmake` contains `morph::tui`, and a consumer with + `find_package(morph CONFIG REQUIRED COMPONENTS tui)` and `target_link_libraries(app PRIVATE morph::tui)` configures + against the prefix. Mutation: delete `list(APPEND MORPH_INSTALLED_COMPONENTS tui)`, re-install. Expected: that + consumer FAILS with "morph component 'tui' is not part of this install". Restore. + +State in the hand-off which branch this machine took; the other is inferred from the code, not measured. + +Then: `grep -c CMAKE_SIZEOF_VOID_P build/install-tui/morphConfigVersion.cmake` prints 1 when the tui component was +installed and 0 when it was not. And run the existing gates, which must still pass untouched: + +```bash +bash scripts/check_install_export.sh +bash scripts/test_check_install_export.sh +``` + +- [ ] **Step 4: Commit** + +```bash +git add CMakeLists.txt cmake/morphConfig.cmake.in CMakePresets.json .github/workflows/ci.yml CONTRIBUTING.md README.md +git commit -m "wip(tui): install the tui component, and build it in the everything presets and CI + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 7: The layout solver + +**Files:** +- Create: `src/tui/layout.hpp`, `src/tui/layout.cpp` +- Modify: `CMakeLists.txt` — `add_library(morph_tui STATIC …)`: add `src/tui/layout.cpp` and `src/tui/layout.hpp` +- Modify: `tests/tui/CMakeLists.txt` — add `test_tui_layout.cpp` +- Test: `tests/tui/test_tui_layout.cpp` + +**Interfaces:** +- Consumes: `ui::Sizing` (`Kind::{Content, Fixed, Stretch}`, `amount`, `content()`, `fixed(int)`, `stretch(int)`) + from `include/morph/ui/view.hpp` (Part 2); `core::tui::Rect` (core-cpp `tui/Rect.hpp`). +- Produces (`namespace morph::tui::layout`): `Item{sizing, natural}`, `Track{length, gap}`, + `distribute(std::span, Track) -> std::vector`, `crossExtent(ui::Sizing, int space) -> int`, + `GridCell{span, naturalHeight, height}`, `GridSlot{column, row, span}`, + `GridPlan{slots, columnX, columnWidths, rowY, rowHeights, gap}`, + `planGrid(int columns, std::span, Track width) -> GridPlan`, + `slotArea(GridPlan const&, GridSlot const&) -> core::tui::Rect`, `pad(core::tui::Rect, int) -> core::tui::Rect`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/tui/test_tui_layout.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +#include "tui/layout.hpp" + +namespace layout = morph::tui::layout; +using morph::ui::Sizing; + +namespace { + +struct DistributeCase { + std::string_view name; + std::vector items; + layout::Track track; + std::vector expected; +}; + +struct GridCase { + std::string_view name; + int columns = 1; + std::vector cells; + layout::Track width; + std::vector slots; + std::vector rowHeights; +}; + +} // namespace + +TEST_CASE("tui::layout::distribute", "[tui][layout]") { + auto const testCase = GENERATE(values({ + {"fixed, content and stretch", + {{Sizing::fixed(3), 0}, {Sizing::content(), 2}, {Sizing::stretch(1), 9}}, + {.length = 10, .gap = 0}, + {3, 2, 5}}, + {"equal weights: the remainder goes to the first", {{Sizing::stretch(1), 0}, {Sizing::stretch(1), 0}, {Sizing::stretch(1), 0}}, {.length = 10, .gap = 0}, {4, 3, 3}}, + {"weights and a gap", {{Sizing::stretch(2), 0}, {Sizing::stretch(1), 0}}, {.length = 10, .gap = 1}, {6, 3}}, + {"uneven weights: the remainder still goes to the first", {{Sizing::stretch(1), 0}, {Sizing::stretch(2), 0}}, {.length = 10, .gap = 0}, {4, 6}}, + {"overflow shrinks the last item first", {{Sizing::fixed(6), 0}, {Sizing::fixed(6), 0}}, {.length = 10, .gap = 0}, {6, 4}}, + {"overflow past one item reaches the one before", {{Sizing::content(), 3}, {Sizing::fixed(4), 0}, {Sizing::content(), 5}}, {.length = 8, .gap = 1}, {3, 3, 0}}, + {"no items", {}, {.length = 10, .gap = 0}, {}}, + {"stretch into nothing", {{Sizing::stretch(1), 4}}, {.length = 0, .gap = 0}, {0}}, + {"a negative fixed size is zero", {{Sizing::fixed(-2), 0}}, {.length = 5, .gap = 0}, {0}}, + {"a zero weight counts as one", {{Sizing::stretch(0), 0}, {Sizing::stretch(1), 0}}, {.length = 4, .gap = 0}, {2, 2}}, + })); + INFO(testCase.name); + CHECK(layout::distribute(testCase.items, testCase.track) == testCase.expected); +} + +TEST_CASE("tui::layout::crossExtent", "[tui][layout]") { + CHECK(layout::crossExtent(Sizing::fixed(4), 10) == 4); + CHECK(layout::crossExtent(Sizing::fixed(12), 10) == 10); + CHECK(layout::crossExtent(Sizing::fixed(-1), 10) == 0); + CHECK(layout::crossExtent(Sizing::content(), 10) == 10); + CHECK(layout::crossExtent(Sizing::stretch(3), 10) == 10); +} + +TEST_CASE("tui::layout::planGrid", "[tui][layout]") { + auto const testCase = GENERATE(values({ + {"two columns, a full-width third cell", + 2, + {{.span = 1, .naturalHeight = 1, .height = {}}, {.span = 1, .naturalHeight = 1, .height = {}}, {.span = 2, .naturalHeight = 1, .height = {}}}, + {.length = 21, .gap = 1}, + {{.column = 0, .row = 0, .span = 1}, {.column = 1, .row = 0, .span = 1}, {.column = 0, .row = 1, .span = 2}}, + {1, 1}}, + {"a span that does not fit the rest of the row starts the next", + 2, + {{.span = 1, .naturalHeight = 1, .height = {}}, {.span = 2, .naturalHeight = 1, .height = {}}}, + {.length = 20, .gap = 0}, + {{.column = 0, .row = 0, .span = 1}, {.column = 0, .row = 1, .span = 2}}, + {1, 1}}, + {"a span wider than the grid is clamped", + 3, + {{.span = 5, .naturalHeight = 2, .height = {}}}, + {.length = 9, .gap = 0}, + {{.column = 0, .row = 0, .span = 3}}, + {2}}, + {"a row is as tall as its tallest cell; Fixed height wins over natural", + 2, + {{.span = 1, .naturalHeight = 1, .height = {}}, {.span = 1, .naturalHeight = 3, .height = {}}, {.span = 1, .naturalHeight = 5, .height = Sizing::fixed(2)}}, + {.length = 10, .gap = 0}, + {{.column = 0, .row = 0, .span = 1}, {.column = 1, .row = 0, .span = 1}, {.column = 0, .row = 1, .span = 1}}, + {3, 2}}, + {"zero columns are one", 0, {{.span = 1, .naturalHeight = 1, .height = {}}}, {.length = 4, .gap = 0}, {{.column = 0, .row = 0, .span = 1}}, {1}}, + })); + INFO(testCase.name); + auto const plan = layout::planGrid(testCase.columns, testCase.cells, testCase.width); + CHECK(plan.slots == testCase.slots); + CHECK(plan.rowHeights == testCase.rowHeights); +} + +TEST_CASE("tui::layout::slotArea spans columns and the gaps between them", "[tui][layout]") { + std::vector const cells{{.span = 1, .naturalHeight = 1, .height = {}}, + {.span = 1, .naturalHeight = 1, .height = {}}, + {.span = 2, .naturalHeight = 1, .height = {}}}; + auto const plan = layout::planGrid(2, cells, {.length = 21, .gap = 1}); + CHECK(plan.columnX == std::vector{0, 11}); + CHECK(plan.columnWidths == std::vector{10, 10}); + CHECK(plan.rowY == std::vector{0, 2}); + CHECK(layout::slotArea(plan, plan.slots.at(1)) == core::tui::Rect{.x = 11, .y = 0, .width = 10, .height = 1}); + CHECK(layout::slotArea(plan, plan.slots.at(2)) == core::tui::Rect{.x = 0, .y = 2, .width = 21, .height = 1}); +} + +TEST_CASE("tui::layout::pad", "[tui][layout]") { + CHECK(layout::pad({.x = 0, .y = 0, .width = 10, .height = 6}, 1) == + core::tui::Rect{.x = 1, .y = 1, .width = 8, .height = 4}); + CHECK(layout::pad({.x = 2, .y = 2, .width = 3, .height = 3}, 2) == + core::tui::Rect{.x = 4, .y = 4, .width = 0, .height = 0}); +} +``` + +Add `test_tui_layout.cpp` to `add_executable(morph_tui_tests …)` in `tests/tui/CMakeLists.txt`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/tui --target morph_tui_tests` +Expected: FAIL — `'tui/layout.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `src/tui/layout.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include + +/// The stack and grid solver the TUI containers arrange their children with, in terminal cells. Specified in +/// docs/spec/tui/frontend.md, "Layout". +namespace morph::tui::layout { + +/// One child along a stack's main axis. +struct Item { + ui::Sizing sizing{}; ///< How the child asks for space. + int natural = 0; ///< Its content extent along the axis. +}; + +/// The space along one axis and the gap between two neighbours on it. +struct Track { + int length = 0; ///< Cells available. + int gap = 0; ///< Cells between two neighbours. +}; + +/// Extents along a stack's main axis, in item order. Fixed items get their amount and Content items their natural +/// extent; Stretch items share what is left by weight (a weight below one counts as one), the cells a division +/// leaves over going one each to the first Stretch items; an overflow is taken from the last item first. +[[nodiscard]] std::vector distribute(std::span items, Track track); + +/// A child's extent across a stack: a Fixed child's amount, at most `space`; Content and Stretch fill `space`. +[[nodiscard]] int crossExtent(ui::Sizing sizing, int space); + +/// What one grid cell asks for. +struct GridCell { + int span = 1; ///< Columns it covers; clamped to [1, columns]. + int naturalHeight = 1; ///< Its content height. + ui::Sizing height{}; ///< Fixed overrides the natural height; Content and Stretch use it. +}; + +/// Where one cell lands. +struct GridSlot { + int column = 0; ///< First column. + int row = 0; ///< Row. + int span = 1; ///< Columns covered. + + bool operator==(GridSlot const&) const = default; +}; + +/// A solved grid: one slot per cell, in cell order, and the geometry of its columns and rows. +struct GridPlan { + std::vector slots; ///< One per cell. + std::vector columnX; ///< Left edge of each column. + std::vector columnWidths; ///< Width of each column. + std::vector rowY; ///< Top edge of each row. + std::vector rowHeights; ///< Height of each row. + int gap = 0; ///< Cells between columns and between rows. +}; + +/// Places cells row-major in `columns` equal columns (fewer than one counts as one). A cell whose span does not fit +/// the rest of its row starts the next row; a row is as tall as its tallest cell. +[[nodiscard]] GridPlan planGrid(int columns, std::span cells, Track width); + +/// The rectangle a slot of `plan` covers, gaps between its columns included. +[[nodiscard]] ::core::tui::Rect slotArea(GridPlan const& plan, GridSlot const& slot); + +/// `area` shrunk by `padding` cells on every side; never negative. +[[nodiscard]] ::core::tui::Rect pad(::core::tui::Rect area, int padding); + +} // namespace morph::tui::layout +``` + +Create `src/tui/layout.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "tui/layout.hpp" + +#include +#include +#include +#include + +namespace morph::tui::layout { + +namespace { + +bool isStretch(ui::Sizing const& sizing) noexcept { + return sizing.kind == ui::Sizing::Kind::Stretch; +} + +int weightOf(ui::Sizing const& sizing) noexcept { + return std::max(1, sizing.amount); +} + +int gapsBetween(std::size_t count, int gap) noexcept { + return count == 0 ? 0 : gap * (static_cast(count) - 1); +} + +} // namespace + +std::vector distribute(std::span items, Track track) { + std::vector sizes; + sizes.reserve(items.size()); + int used = 0; + int weights = 0; + for (auto const& item : items) { + if (isStretch(item.sizing)) { + weights += weightOf(item.sizing); + sizes.push_back(0); + continue; + } + int const size = std::max(0, item.sizing.kind == ui::Sizing::Kind::Fixed ? item.sizing.amount : item.natural); + sizes.push_back(size); + used += size; + } + int const spare = track.length - gapsBetween(items.size(), track.gap) - used; + if (spare > 0 && weights > 0) { + int given = 0; + auto size = sizes.begin(); + for (auto const& item : items) { + if (isStretch(item.sizing)) { + *size = spare * weightOf(item.sizing) / weights; + given += *size; + } + ++size; + } + // The cells the division left over go one each to the first Stretch items. + int remainder = spare - given; + size = sizes.begin(); + for (auto const& item : items) { + if (remainder == 0) { + break; + } + if (isStretch(item.sizing)) { + ++*size; + --remainder; + } + ++size; + } + } else if (spare < 0) { + // An overflow is taken from the last item first. + int deficit = -spare; + for (auto& size : sizes | std::views::reverse) { + int const take = std::min(size, deficit); + size -= take; + deficit -= take; + } + } + return sizes; +} + +int crossExtent(ui::Sizing sizing, int space) { + if (sizing.kind == ui::Sizing::Kind::Fixed) { + return std::clamp(sizing.amount, 0, std::max(0, space)); + } + return std::max(0, space); +} + +GridPlan planGrid(int columns, std::span cells, Track width) { + int const count = std::max(1, columns); + GridPlan plan; + plan.gap = width.gap; + std::vector const equal(static_cast(count), Item{.sizing = ui::Sizing::stretch(1), .natural = 0}); + plan.columnWidths = distribute(equal, width); + int x = 0; + for (int const columnWidth : plan.columnWidths) { + plan.columnX.push_back(x); + x += columnWidth + width.gap; + } + + int column = 0; + int row = 0; + for (auto const& cell : cells) { + int const span = std::clamp(cell.span, 1, count); + if (column + span > count) { + ++row; + column = 0; + } + plan.slots.push_back(GridSlot{.column = column, .row = row, .span = span}); + int const height = cell.height.kind == ui::Sizing::Kind::Fixed ? std::max(0, cell.height.amount) + : std::max(0, cell.naturalHeight); + if (static_cast(plan.rowHeights.size()) <= row) { + plan.rowHeights.push_back(height); + } else { + plan.rowHeights.back() = std::max(plan.rowHeights.back(), height); + } + column += span; + } + int y = 0; + for (int const rowHeight : plan.rowHeights) { + plan.rowY.push_back(y); + y += rowHeight + width.gap; + } + return plan; +} + +::core::tui::Rect slotArea(GridPlan const& plan, GridSlot const& slot) { + auto const first = static_cast(slot.column); + auto const spanned = plan.columnWidths | std::views::drop(first) | std::views::take(static_cast(slot.span)); + int const width = std::accumulate(spanned.begin(), spanned.end(), 0) + plan.gap * (slot.span - 1); + auto const row = static_cast(slot.row); + return ::core::tui::Rect{.x = plan.columnX.at(first), + .y = plan.rowY.at(row), + .width = width, + .height = plan.rowHeights.at(row)}; +} + +::core::tui::Rect pad(::core::tui::Rect area, int padding) { + return ::core::tui::Rect{.x = area.x + padding, + .y = area.y + padding, + .width = std::max(0, area.width - (2 * padding)), + .height = std::max(0, area.height - (2 * padding))}; +} + +} // namespace morph::tui::layout +``` + +Add `src/tui/layout.cpp` and `src/tui/layout.hpp` to `add_library(morph_tui STATIC …)` in `CMakeLists.txt`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/tui --target morph_tui_tests && ./build/tui/tests/tui/morph_tui_tests "[layout]"` +Expected: PASS, 5 test cases. + +Mutation check: in `distribute`, delete the remainder loop (the one after "The cells the division left over"). +Expected: FAIL in "equal weights: the remainder goes to the first" (`{3, 3, 3}`) and "uneven weights: the remainder +still goes to the first" (`{3, 6}`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/layout.hpp src/tui/layout.cpp tests/tui CMakeLists.txt +git commit -m "wip(tui): the stack and grid layout solver + +Signed-off-by: Christian Parpart " +``` + +--- +### Task 8: The widget base, stacks and the first leaf widgets + +**Files:** +- Create: `src/tui/context.hpp`, `src/tui/context.cpp`, `src/tui/widget.hpp`, `src/tui/widget.cpp` +- Create: `src/tui/leaf_widgets.hpp`, `src/tui/leaf_widgets.cpp` (Text, Button, Checkbox, Spacer) +- Create: `src/tui/container_widgets.hpp`, `src/tui/container_widgets.cpp` (Stack, Slot) +- Create: `tests/tui/tui_harness.hpp` +- Modify: `CMakeLists.txt` — `add_library(morph_tui STATIC …)`: add the six `src/tui/` files above +- Modify: `tests/tui/CMakeLists.txt` — add `test_tui_widgets.cpp` +- Test: `tests/tui/test_tui_widgets.cpp` + +**Interfaces:** +- Consumes: `ui::Widget`, `ui::ContainerWidget`, `ui::TextWidget`, `ui::ButtonWidget`, `ui::CheckboxWidget`, + `ui::SpacerWidget`, `ui::StackWidget`, `ui::SlotWidget`, `ui::LayoutHints`, `ui::Sizing`, `ui::Key`, `ui::Axis`, + `ui::TextRole`, `ui::Action` (Part 2, `include/morph/ui/{view,backend}.hpp`); `layout::*` (Task 7); + `core::tui::{Component, LayoutParams, Screen, Canvas, Theme, InputEvent, KeyEvent, MouseEvent, EventResult, Point, + Rect, Size, stringWidth, withoutLockKeys}` (core-cpp 0.7). +- Produces (`namespace morph::tui::detail`, private): + - `enum class Direction { Forward, Backward }`; `struct Context { screen, owners, roots, openDialogs, activeBusy, + animationFrame; ownerOf(Component const*), forget(WidgetBase&) }`. + - `class WidgetBase` — `of(ui::Widget&)`, `view()`, `context()`, `container()`, `asWidget()`, `applyVisible`, + `setStructuralVisible`, `userVisible()`, `shown()`, `applyEnabled`, `enabled()`, `applyLayout`, `layout()`, + `applyDragKey`, `dragKey()`, `applyDropHandler`, `isDropTarget()`, `accepts(Key)`, `drop(Key)`, virtual + `naturalSize()`, `wantsFocus()`, `expandsByDefault()`, `probeText()`, `paint(Canvas&)`, `key(KeyEvent)`, + `activate()`, `click(Point)`, `wheel(int)`; `dispatch(InputEvent)`, `pointer(MouseEvent)`, `focusable()`, + `hasFocus()`, `refresh()`; protected `adopt(std::unique_ptr)`. + - `class ContainerBase : WidgetBase` — `of(ui::ContainerWidget&)`, `attach`, `forget`, `move`, `children()`, + `shownChildren()`, virtual `host()`, protected `childAttached`, `childForgotten`. + - `template class Hosted`, `class View`, `template class TuiWidget`, + `template class TuiContainer`. + - `struct StackSpec { axis, gap, skip, extents }`; `stackNaturalSize`, `arrangeStack`, `splitLines`, + `displayWidth`, `isActivation`, `collectFocusable`, `isWithin`, `attach(Context&, ui::ContainerWidget*, + WidgetBase&)`, `fit(Context&)`, `moveFocus(Context&, Direction)`. + - Widgets `TextImpl`, `ButtonImpl`, `CheckboxImpl`, `SpacerImpl`, `StackImpl` (with `axis()`, `gap()`, + `setSkip`, `skip()`, `setColumnLayout(std::vector, int)`), `SlotImpl`. + - Test helper `morph::tui::testing::{rowsOf, ResizableOutput, Harness}`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/tui/tui_harness.hpp`: + +```cpp +// 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 "tui/context.hpp" +#include "tui/widget.hpp" + +namespace morph::tui::testing { + +/// The rows of the screen's last frame, trailing blanks removed and trailing empty rows dropped. +inline std::vector rowsOf(::core::tui::Screen const& screen) { + auto const text = ::core::tui::test::canvasToString(screen.renderedBuffer()); + std::vector rows; + std::size_t start = 0; + while (start <= text.size()) { + auto const end = std::min(text.find('\n', start), text.size()); + auto row = text.substr(start, end - start); + while (!row.empty() && row.back() == ' ') { + row.pop_back(); + } + rows.push_back(std::move(row)); + start = end + 1; + } + while (!rows.empty() && rows.back().empty()) { + rows.pop_back(); + } + return rows; +} + +/// A mock terminal whose size a test can change, and which counts the frames drawn to it. +class ResizableOutput final : public ::core::tui::MockTerminalOutput { +public: + explicit ResizableOutput(::core::tui::Size size) : MockTerminalOutput{size.width, size.height}, _size{size} {} + [[nodiscard]] auto columns() const noexcept -> int override { return _size.width; } + [[nodiscard]] auto rows() const noexcept -> int override { return _size.height; } + void resize(::core::tui::Size size) noexcept { _size = size; } + /// One synchronised update per `Screen::draw()` in fullscreen mode, so this counts frames. + [[nodiscard]] auto syncGuard() -> ::core::tui::SyncGuard override { + ++_frames; + return MockTerminalOutput::syncGuard(); + } + [[nodiscard]] int frames() const noexcept { return _frames; } + +private: + ::core::tui::Size _size; + int _frames = 0; +}; + +/// A headless screen and the widget context of one backend over it. +class Harness { +public: + explicit Harness(int columns = 40, int rows = 12) + : Harness{std::make_unique<::core::tui::MockTerminalOutput>(columns, rows)} {} + explicit Harness(std::unique_ptr<::core::tui::TerminalOutput> output) + : _terminal{std::move(output)}, _screen{_terminal}, _context{_screen} {} + + /// Creates a widget and attaches it under @p parent (null: the screen's root), as a backend factory does. + template + std::unique_ptr make(ui::ContainerWidget* parent, Args&&... args) { + auto widget = std::make_unique(_context, std::forward(args)...); + detail::attach(_context, parent, *widget); + return widget; + } + + /// Fits the roots to the screen, draws a frame and returns its rows. + std::vector draw() { + detail::fit(_context); + _screen.draw(); + return rowsOf(_screen); + } + + ::core::tui::EventResult send(::core::tui::InputEvent const& event) { return _screen.dispatchEvent(event); } + + ::core::tui::EventResult key(::core::tui::KeyCode code, + ::core::tui::Modifier modifiers = ::core::tui::Modifier::None) { + return send(::core::tui::test::specialKey(code, modifiers)); + } + + /// Sends one key event per character of @p text; returns the last result. + ::core::tui::EventResult type(std::string_view text) { + auto result = ::core::tui::EventResult::Ignored; + for (char const character : text) { + result = send(::core::tui::test::charKey(character)); + } + return result; + } + + /// A left-button press and release on a 0-based viewport cell; returns the release's result. + ::core::tui::EventResult click(::core::tui::Point cell) { + using Type = ::core::tui::MouseEvent::Type; + static_cast(send(::core::tui::MouseEvent{.type = Type::Press, .button = 0, .x = cell.x + 1, .y = cell.y + 1})); + return send(::core::tui::MouseEvent{.type = Type::Release, .button = 0, .x = cell.x + 1, .y = cell.y + 1}); + } + + /// Decodes @p bytes as terminal input (SGR mouse reports included) and dispatches each event. + void sgr(std::string_view bytes) { + ::core::tui::VtParser parser; + for (auto const& event : parser.feed(bytes)) { + static_cast(_screen.dispatchEvent(event)); + } + } + + void focus(ui::Widget const& widget) { _screen.setFocus(&detail::WidgetBase::of(widget).view()); } + + [[nodiscard]] bool focused(ui::Widget const& widget) const { + return _screen.focusedComponent() == &detail::WidgetBase::of(widget).view(); + } + + [[nodiscard]] ::core::tui::Screen& screen() noexcept { return _screen; } + [[nodiscard]] detail::Context& context() noexcept { return _context; } + +private: + ::core::tui::Terminal _terminal; + ::core::tui::Screen _screen; + detail::Context _context; +}; + +} // namespace morph::tui::testing +``` + +Create `tests/tui/test_tui_widgets.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "tui/container_widgets.hpp" +#include "tui/leaf_widgets.hpp" +#include "tui_harness.hpp" + +namespace ui = morph::ui; +using core::tui::EventResult; +using core::tui::KeyCode; +using morph::tui::detail::ButtonImpl; +using morph::tui::detail::CheckboxImpl; +using morph::tui::detail::Direction; +using morph::tui::detail::SpacerImpl; +using morph::tui::detail::StackImpl; +using morph::tui::detail::TextImpl; +using morph::tui::testing::Harness; +using Rows = std::vector; + +TEST_CASE("tui widgets: a column renders its children top to bottom", "[tui][widgets]") { + Harness harness{20, 4}; + auto const column = harness.make(nullptr, ui::Axis::Vertical); + auto const title = harness.make(column.get()); + title->setText("Title"); + auto const goButton = harness.make(column.get()); + goButton->setLabel("Go"); + CHECK(harness.draw() == Rows{"Title", "[ Go ]"}); +} + +TEST_CASE("tui widgets: a row lays out left to right, with its gap", "[tui][widgets]") { + Harness harness{20, 2}; + auto const row = harness.make(nullptr, ui::Axis::Horizontal); + row->setGap(2); + auto const left = harness.make(row.get()); + left->setText("ab"); + auto const right = harness.make(row.get()); + right->setText("cd"); + CHECK(harness.draw() == Rows{"ab cd"}); +} + +TEST_CASE("tui widgets: a spacer takes the space its siblings leave", "[tui][widgets]") { + Harness harness{20, 1}; + auto const row = harness.make(nullptr, ui::Axis::Horizontal); + auto const left = harness.make(row.get()); + left->setText("L"); + auto const spacer = harness.make(row.get()); + auto const right = harness.make(row.get()); + right->setText("R"); + CHECK(harness.draw() == Rows{"L" + std::string(18, ' ') + "R"}); +} + +TEST_CASE("tui widgets: a hidden child takes no space", "[tui][widgets]") { + Harness harness{10, 3}; + auto const column = harness.make(nullptr, ui::Axis::Vertical); + auto const first = harness.make(column.get()); + first->setText("a"); + auto const second = harness.make(column.get()); + second->setText("b"); + auto const third = harness.make(column.get()); + third->setText("c"); + second->setVisible(false); + CHECK(harness.draw() == Rows{"a", "c"}); +} + +TEST_CASE("tui widgets: multi-line text takes one row per line", "[tui][widgets]") { + Harness harness{10, 3}; + auto const text = harness.make(nullptr); + text->setText("one\ntwo"); + CHECK(harness.draw() == Rows{"one", "two"}); +} + +TEST_CASE("tui widgets: moveChild reorders both rendering and focus order", "[tui][widgets]") { + Harness harness{10, 2}; + auto const column = harness.make(nullptr, ui::Axis::Vertical); + auto const first = harness.make(column.get()); + first->setLabel("A"); + auto const second = harness.make(column.get()); + second->setLabel("B"); + column->moveChild(*second, 0); + CHECK(harness.draw() == Rows{"[ B ]", "[ A ]"}); + morph::tui::detail::moveFocus(harness.context(), Direction::Forward); + CHECK(harness.focused(*second)); +} + +TEST_CASE("tui widgets: Enter, Space and a click activate a button; a disabled one ignores them", "[tui][widgets]") { + Harness harness{20, 2}; + auto const button = harness.make(nullptr); + int clicks = 0; + button->setLabel("Go"); + button->setOnClick([&] { ++clicks; }); + harness.focus(*button); + CHECK(harness.key(KeyCode::Enter) == EventResult::Handled); + CHECK(harness.type(" ") == EventResult::Handled); + CHECK(clicks == 2); + static_cast(harness.draw()); + CHECK(harness.click({.x = 1, .y = 0}) == EventResult::Handled); + CHECK(clicks == 3); + + button->setEnabled(false); + CHECK(harness.key(KeyCode::Enter) == EventResult::Ignored); + static_cast(harness.click({.x = 1, .y = 0})); + CHECK(clicks == 3); +} + +TEST_CASE("tui widgets: a checkbox toggles on Enter and reports the new state; setChecked reports nothing", + "[tui][widgets]") { + Harness harness{20, 1}; + auto const box = harness.make(nullptr); + std::vector toggles; + box->setLabel("Done"); + box->setChecked(false); + box->setOnToggle([&](bool checked) { toggles.push_back(checked); }); + CHECK(harness.draw() == Rows{"[ ] Done"}); + harness.focus(*box); + static_cast(harness.key(KeyCode::Enter)); + CHECK(toggles == std::vector{true}); + CHECK(harness.draw() == Rows{"[x] Done"}); + box->setChecked(false); + CHECK(harness.draw() == Rows{"[ ] Done"}); + CHECK(toggles.size() == 1); +} + +TEST_CASE("tui widgets: destroying the focused widget clears the screen's focus", "[tui][widgets]") { + Harness harness{20, 2}; + auto const column = harness.make(nullptr, ui::Axis::Vertical); + auto button = harness.make(column.get()); + button->setLabel("Go"); + harness.focus(*button); + REQUIRE(harness.screen().focusedComponent() != nullptr); + button.reset(); + CHECK(harness.screen().focusedComponent() == nullptr); + CHECK(harness.draw().empty()); +} +``` + +Add `test_tui_widgets.cpp` to `tests/tui/CMakeLists.txt`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/tui --target morph_tui_tests` +Expected: FAIL — `'tui/context.hpp' file not found`. + +- [ ] **Step 3: Implement the context and the widget base** + +Create `src/tui/context.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include + +namespace morph::tui::detail { + +class WidgetBase; + +/// Which way keyboard focus moves. +enum class Direction : std::uint8_t { Forward, Backward }; + +/// What every widget of one backend shares: the screen, which widget owns which view, the roots, the open dialogs +/// and the spinner state. +struct Context { + explicit Context(::core::tui::Screen& target); + ~Context(); + Context(Context const&) = delete; + Context& operator=(Context const&) = delete; + Context(Context&&) = delete; + Context& operator=(Context&&) = delete; + + ::core::tui::Screen* screen; ///< Where every view renders. + std::unordered_map<::core::tui::Component const*, WidgetBase*> owners; ///< Each registered view's widget. + std::vector roots; ///< Widgets created with no parent. + std::vector<::core::tui::Component*> openDialogs; ///< Open dialog frames, innermost last. + std::size_t activeBusy = 0; ///< Busy widgets that are spinning. + std::size_t animationFrame = 0; ///< The spinner frame to draw. + + /// The widget owning @p view, or null for a component no widget registered (an overlay, the root). + [[nodiscard]] WidgetBase* ownerOf(::core::tui::Component const* view) const; + /// Drops every reference to a widget being destroyed. + void forget(WidgetBase& widget); +}; + +} // namespace morph::tui::detail +``` + +Create `src/tui/context.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "tui/context.hpp" + +#include + +namespace morph::tui::detail { + +Context::Context(::core::tui::Screen& target) : screen{&target} {} + +Context::~Context() = default; + +WidgetBase* Context::ownerOf(::core::tui::Component const* view) const { + auto const found = owners.find(view); + return found == owners.end() ? nullptr : found->second; +} + +void Context::forget(WidgetBase& widget) { + std::erase(roots, &widget); +} + +} // namespace morph::tui::detail +``` + +Create `src/tui/widget.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tui/context.hpp" + +namespace morph::tui::detail { + +class ContainerBase; + +/// The morph side of one TUI widget: the Common props, the container it sits in, and the core::tui view it owns. +/// +/// The view is registered in the Context while the widget lives. The destructor clears the screen's focus from it +/// and unregisters it before the view itself is destroyed, so core::tui never keeps a pointer to a freed view. +class WidgetBase { +public: + explicit WidgetBase(Context& context) noexcept; + virtual ~WidgetBase(); + WidgetBase(WidgetBase const&) = delete; + WidgetBase& operator=(WidgetBase const&) = delete; + WidgetBase(WidgetBase&&) = delete; + WidgetBase& operator=(WidgetBase&&) = delete; + + /// The TUI widget behind @p widget; throws std::logic_error for a widget another backend made. + [[nodiscard]] static WidgetBase& of(ui::Widget& widget); + /// The TUI widget behind @p widget; throws std::logic_error for a widget another backend made. + [[nodiscard]] static WidgetBase const& of(ui::Widget const& widget); + + [[nodiscard]] ::core::tui::Component& view() const noexcept { return *_view; } + [[nodiscard]] Context& context() const noexcept { return *_context; } + [[nodiscard]] ContainerBase* container() const noexcept { return _container; } + void setContainer(ContainerBase* container) noexcept { _container = container; } + /// This widget as the ui interface it implements. + [[nodiscard]] virtual ui::Widget const& asWidget() const noexcept = 0; + + void applyVisible(bool visible); + /// Shown or hidden by its container (a collapsed panel), independently of `visible`. + void setStructuralVisible(bool visible); + [[nodiscard]] bool userVisible() const noexcept { return _userVisible; } + [[nodiscard]] bool shown() const noexcept { return _userVisible && _structuralVisible; } + void applyEnabled(bool enabled); + [[nodiscard]] bool enabled() const noexcept { return _enabled; } + void applyLayout(ui::LayoutHints const& hints); + [[nodiscard]] ui::LayoutHints const& layout() const noexcept { return _layout; } + void applyDragKey(std::optional const& key); + [[nodiscard]] std::optional const& dragKey() const noexcept { return _dragKey; } + void applyDropHandler(std::function accepts, std::function onDrop); + [[nodiscard]] bool isDropTarget() const noexcept { return static_cast(_onDrop); } + /// Whether a drop of @p key lands here: a drop target whose `accepts` is empty or holds. + [[nodiscard]] bool accepts(ui::Key const& key) const; + void drop(ui::Key key) const; + + /// The size this widget asks for, in cells. + [[nodiscard]] virtual ::core::tui::Size naturalSize() const = 0; + /// Whether keyboard focus may land here (and, while enabled, does). + [[nodiscard]] virtual bool wantsFocus() const { return false; } + /// Whether a Content main-axis size means Stretch(1) in a stack (a Spacer). + [[nodiscard]] virtual bool expandsByDefault() const { return false; } + /// The text a conformance probe reads. + [[nodiscard]] virtual std::string probeText() const { return {}; } + /// Draws the widget into its own area; a container also places its children here. + virtual void paint(::core::tui::Canvas& canvas); + /// Handles a key while enabled and focused (or bubbled to). + [[nodiscard]] virtual ::core::tui::EventResult key(::core::tui::KeyEvent const& key); + /// The primary action: what Enter, Space and a click do. + virtual void activate() {} + /// A click on @p cell (0-based, relative to this widget); runs `activate()` unless overridden. + virtual void click(::core::tui::Point cell); + /// A wheel notch, -1 up and +1 down; true when handled. + [[nodiscard]] virtual bool wheel(int delta); + + /// Routes one event of this widget's view: mouse to `pointer()`, keys to `key()` while enabled. + [[nodiscard]] ::core::tui::EventResult dispatch(::core::tui::InputEvent const& event); + /// Press, motion, release and wheel on this widget's view. + [[nodiscard]] ::core::tui::EventResult pointer(::core::tui::MouseEvent const& mouse); + [[nodiscard]] bool focusable() const { return wantsFocus() && _enabled; } + [[nodiscard]] bool hasFocus() const noexcept { return _view != nullptr && _view->focused(); } + /// Asks the screen for a full repaint on the next draw. + void refresh() const; + +protected: + /// Takes ownership of the widget's view and registers it; returns it typed. + template + ViewType& adopt(std::unique_ptr view) { + ViewType& adopted = *view; + _view = std::move(view); + _context->owners.insert_or_assign(_view.get(), this); + return adopted; + } + +private: + void syncVisible(); + + Context* _context; + ContainerBase* _container = nullptr; + bool _userVisible = true; + bool _structuralVisible = true; + bool _enabled = true; + bool _pressed = false; + ui::LayoutHints _layout{}; + std::optional _dragKey; + std::function _accepts; + std::function _onDrop; + std::unique_ptr<::core::tui::Component> _view; +}; + +/// A widget whose children's views sit in a host component it provides (its own view, unless overridden). +class ContainerBase : public WidgetBase { +public: + explicit ContainerBase(Context& context) noexcept : WidgetBase{context} {} + ~ContainerBase() override; + ContainerBase(ContainerBase const&) = delete; + ContainerBase& operator=(ContainerBase const&) = delete; + ContainerBase(ContainerBase&&) = delete; + ContainerBase& operator=(ContainerBase&&) = delete; + + /// The TUI container behind @p container; throws std::logic_error for one another backend made. + [[nodiscard]] static ContainerBase& of(ui::ContainerWidget& container); + /// The TUI container behind @p container; throws std::logic_error for one another backend made. + [[nodiscard]] static ContainerBase const& of(ui::ContainerWidget const& container); + + void attach(WidgetBase& child); + void forget(WidgetBase& child); + void move(WidgetBase& child, std::size_t index); + [[nodiscard]] std::span children() const noexcept { return _children; } + [[nodiscard]] std::vector shownChildren() const; + [[nodiscard]] virtual ::core::tui::Component& host() { return view(); } + +protected: + virtual void childAttached(WidgetBase& child); + virtual void childForgotten(WidgetBase& child); + +private: + void restack(); + + std::vector _children; +}; + +/// A core::tui component that renders and reacts for its owner, and is visible only while its ancestors are. +template +class Hosted : public Base { +public: + explicit Hosted(WidgetBase& owner) : _owner{&owner} {} + + [[nodiscard]] bool visible() const noexcept override { + return Base::visible() && (this->parent() == nullptr || this->parent()->visible()); + } + [[nodiscard]] bool focusable() const override { return _owner->focusable(); } + [[nodiscard]] ::core::tui::Size preferredSize() const override { return _owner->naturalSize(); } + [[nodiscard]] WidgetBase& owner() const noexcept { return *_owner; } + +private: + WidgetBase* _owner; +}; + +/// The plain view most widgets use: painting and events go to the owner. +class View final : public Hosted<::core::tui::Component> { +public: + using Hosted::Hosted; + void render(::core::tui::Canvas& canvas) override { owner().paint(canvas); } + [[nodiscard]] ::core::tui::EventResult onEvent(::core::tui::InputEvent const& event) override { + return owner().dispatch(event); + } +}; + +/// Implements the Common setters of ui interface @p Interface over WidgetBase. +template +class TuiWidget : public Interface, public WidgetBase { +public: + explicit TuiWidget(Context& context) noexcept : WidgetBase{context} {} + void setVisible(bool visible) override { applyVisible(visible); } + void setEnabled(bool enabled) override { applyEnabled(enabled); } + void setLayout(ui::LayoutHints const& hints) override { applyLayout(hints); } + void setDragKey(std::optional const& key) override { applyDragKey(key); } + void setDropHandler(std::function accepts, std::function onDrop) override { + applyDropHandler(std::move(accepts), std::move(onDrop)); + } + [[nodiscard]] ui::Widget const& asWidget() const noexcept override { return *this; } +}; + +/// Implements the Common setters and `moveChild` of container interface @p Interface over ContainerBase. +template +class TuiContainer : public Interface, public ContainerBase { +public: + explicit TuiContainer(Context& context) noexcept : ContainerBase{context} {} + void setVisible(bool visible) override { applyVisible(visible); } + void setEnabled(bool enabled) override { applyEnabled(enabled); } + void setLayout(ui::LayoutHints const& hints) override { applyLayout(hints); } + void setDragKey(std::optional const& key) override { applyDragKey(key); } + void setDropHandler(std::function accepts, std::function onDrop) override { + applyDropHandler(std::move(accepts), std::move(onDrop)); + } + void moveChild(ui::Widget& child, std::size_t index) override { move(WidgetBase::of(child), index); } + [[nodiscard]] ui::Widget const& asWidget() const noexcept override { return *this; } +}; + +/// How `arrangeStack` places children. +struct StackSpec { + ui::Axis axis = ui::Axis::Vertical; ///< The main axis. + int gap = 0; ///< Cells between neighbours. + std::size_t skip = 0; ///< Leading shown children given no area: scrolled out of view. + std::span extents{}; ///< When non-empty, the main-axis extents to use, in order (table columns). +}; + +/// The natural size of @p children stacked along @p axis with @p gap between them; hidden children count nothing. +[[nodiscard]] ::core::tui::Size stackNaturalSize(std::span children, ui::Axis axis, int gap); +/// Places the shown @p children inside @p area (parent-relative) with the layout solver; hidden and skipped +/// children get an empty area, so they neither render nor take space but stay focusable. +void arrangeStack(std::span children, ::core::tui::Rect area, StackSpec const& spec); +/// @p text split at '\n'. +[[nodiscard]] std::vector splitLines(std::string_view text); +/// The display width of @p text in cells. +[[nodiscard]] int displayWidth(std::string_view text); +/// Enter, or Space, with no modifier. +[[nodiscard]] bool isActivation(::core::tui::KeyEvent const& key) noexcept; +/// Appends every focusable, visible component below @p root, depth first. +void collectFocusable(::core::tui::Component& root, std::vector<::core::tui::Component*>& out); +/// Whether @p node is @p root or one of its descendants. +[[nodiscard]] bool isWithin(::core::tui::Component const* node, ::core::tui::Component const& root) noexcept; +/// Puts @p widget under @p parent, or at the screen's root when @p parent is null. +void attach(Context& context, ui::ContainerWidget* parent, WidgetBase& widget); +/// Sizes every root widget to the viewport. +void fit(Context& context); +/// Moves keyboard focus to the next or previous focusable component. +void moveFocus(Context& context, Direction direction); + +} // namespace morph::tui::detail +``` + +Create `src/tui/widget.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "tui/widget.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tui/layout.hpp" + +namespace morph::tui::detail { + +using ::core::tui::EventResult; + +WidgetBase::WidgetBase(Context& context) noexcept : _context{&context} {} + +// NOLINTNEXTLINE(bugprone-exception-escape): unregistering cannot stop half-way; leaving the screen pointing at a freed view would be worse than terminating. +WidgetBase::~WidgetBase() { + if (_view != nullptr) { + if (_context->screen->focusedComponent() == _view.get()) { + _context->screen->setFocus(nullptr); + } + _context->owners.erase(_view.get()); + } + _context->forget(*this); + if (_container != nullptr) { + _container->forget(*this); + } +} + +WidgetBase& WidgetBase::of(ui::Widget& widget) { + auto* const base = dynamic_cast(&widget); + if (base == nullptr) { + throw std::logic_error{"morph::tui: a widget another backend created"}; + } + return *base; +} + +WidgetBase const& WidgetBase::of(ui::Widget const& widget) { + auto const* const base = dynamic_cast(&widget); + if (base == nullptr) { + throw std::logic_error{"morph::tui: a widget another backend created"}; + } + return *base; +} + +void WidgetBase::applyVisible(bool visible) { + _userVisible = visible; + syncVisible(); +} + +void WidgetBase::setStructuralVisible(bool visible) { + _structuralVisible = visible; + syncVisible(); +} + +void WidgetBase::syncVisible() { + if (_view != nullptr) { + _view->setVisible(shown()); + } + refresh(); +} + +void WidgetBase::applyEnabled(bool enabled) { + _enabled = enabled; + refresh(); +} + +void WidgetBase::applyLayout(ui::LayoutHints const& hints) { + _layout = hints; + refresh(); +} + +void WidgetBase::applyDragKey(std::optional const& key) { + _dragKey = key; +} + +void WidgetBase::applyDropHandler(std::function accepts, std::function onDrop) { + _accepts = std::move(accepts); + _onDrop = std::move(onDrop); +} + +bool WidgetBase::accepts(ui::Key const& key) const { + return isDropTarget() && (!_accepts || _accepts(key)); +} + +void WidgetBase::drop(ui::Key key) const { + if (_onDrop) { + _onDrop(std::move(key)); + } +} + +void WidgetBase::paint(::core::tui::Canvas& /*canvas*/) {} + +EventResult WidgetBase::key(::core::tui::KeyEvent const& /*key*/) { + return EventResult::Ignored; +} + +void WidgetBase::click(::core::tui::Point /*cell*/) { + activate(); +} + +bool WidgetBase::wheel(int /*delta*/) { + return false; +} + +EventResult WidgetBase::dispatch(::core::tui::InputEvent const& event) { + if (auto const* mouse = std::get_if<::core::tui::MouseEvent>(&event)) { + return pointer(*mouse); + } + if (auto const* pressed = std::get_if<::core::tui::KeyEvent>(&event); pressed != nullptr && _enabled) { + return key(*pressed); + } + return EventResult::Ignored; +} + +EventResult WidgetBase::pointer(::core::tui::MouseEvent const& mouse) { + using Type = ::core::tui::MouseEvent::Type; + if (mouse.type == Type::ScrollUp || mouse.type == Type::ScrollDown) { + return wheel(mouse.type == Type::ScrollUp ? -1 : 1) ? EventResult::Handled : EventResult::Ignored; + } + ::core::tui::Point const cell{.x = mouse.x - 1, .y = mouse.y - 1}; + if (mouse.type == Type::Press) { + if (mouse.button != 0 || !(focusable() || _dragKey)) { + return EventResult::Ignored; + } + _pressed = true; + if (focusable()) { + _context->screen->setFocus(_view.get()); + } + return EventResult::Handled; + } + if (!_pressed) { + return EventResult::Ignored; + } + if (mouse.type == Type::Release) { + _pressed = false; + auto const bounds = _view->screenBounds(); + bool const inside = cell.x >= 0 && cell.y >= 0 && cell.x < bounds.width && cell.y < bounds.height; + if (inside && _enabled) { + click(cell); + } + } + return EventResult::Handled; +} + +void WidgetBase::refresh() const { + _context->screen->invalidate(); +} + +ContainerBase::~ContainerBase() { + for (auto* child : _children) { + child->setContainer(nullptr); + } +} + +ContainerBase& ContainerBase::of(ui::ContainerWidget& container) { + auto* const base = dynamic_cast(&container); + if (base == nullptr) { + throw std::logic_error{"morph::tui: a container another backend created"}; + } + return *base; +} + +ContainerBase const& ContainerBase::of(ui::ContainerWidget const& container) { + auto const* const base = dynamic_cast(&container); + if (base == nullptr) { + throw std::logic_error{"morph::tui: a container another backend created"}; + } + return *base; +} + +void ContainerBase::attach(WidgetBase& child) { + _children.push_back(&child); + child.setContainer(this); + host().addChild(child.view(), ::core::tui::LayoutParams{.area = {}, .visible = child.shown()}); + childAttached(child); + refresh(); +} + +void ContainerBase::forget(WidgetBase& child) { + std::erase(_children, &child); + childForgotten(child); + refresh(); +} + +void ContainerBase::move(WidgetBase& child, std::size_t index) { + auto const found = std::ranges::find(_children, &child); + if (found == _children.end()) { + return; + } + _children.erase(found); + auto const position = static_cast(std::min(index, _children.size())); + _children.insert(std::next(_children.begin(), position), &child); + restack(); +} + +std::vector ContainerBase::shownChildren() const { + std::vector shown; + std::ranges::copy_if(_children, std::back_inserter(shown), [](WidgetBase const* child) { return child->shown(); }); + return shown; +} + +void ContainerBase::childAttached(WidgetBase& /*child*/) {} + +void ContainerBase::childForgotten(WidgetBase& /*child*/) {} + +// The child views are re-added in order: core::tui's focus walk and paint order follow its child list. +void ContainerBase::restack() { + auto& target = host(); + for (auto* child : _children) { + target.removeChild(child->view()); + } + for (auto* child : _children) { + target.addChild(child->view(), ::core::tui::LayoutParams{.area = child->view().area(), .visible = child->shown()}); + } + refresh(); +} + +::core::tui::Size stackNaturalSize(std::span children, ui::Axis axis, int gap) { + bool const vertical = axis == ui::Axis::Vertical; + int along = 0; + int across = 0; + int count = 0; + for (auto const* child : children) { + if (!child->shown()) { + continue; + } + auto const natural = child->naturalSize(); + along += vertical ? natural.height : natural.width; + across = std::max(across, vertical ? natural.width : natural.height); + ++count; + } + if (count > 1) { + along += gap * (count - 1); + } + return vertical ? ::core::tui::Size{.width = across, .height = along} : ::core::tui::Size{.width = along, .height = across}; +} + +void arrangeStack(std::span children, ::core::tui::Rect area, StackSpec const& spec) { + bool const vertical = spec.axis == ui::Axis::Vertical; + std::vector placed; + std::size_t skipped = 0; + for (auto* child : children) { + if (child->shown() && skipped >= spec.skip) { + placed.push_back(child); + continue; + } + if (child->shown()) { + ++skipped; + } + child->view().setArea({}); + } + + std::vector extents; + if (!spec.extents.empty()) { + auto given = spec.extents.begin(); + for (auto const* child : placed) { + auto const natural = child->naturalSize(); + if (given != spec.extents.end()) { + extents.push_back(*given); + ++given; + } else { + extents.push_back(vertical ? natural.height : natural.width); + } + } + } else { + std::vector items; + for (auto const* child : placed) { + auto sizing = vertical ? child->layout().height : child->layout().width; + if (sizing.kind == ui::Sizing::Kind::Content && child->expandsByDefault()) { + sizing = ui::Sizing::stretch(1); + } + auto const natural = child->naturalSize(); + items.push_back(layout::Item{.sizing = sizing, .natural = vertical ? natural.height : natural.width}); + } + extents = layout::distribute(items, layout::Track{.length = vertical ? area.height : area.width, .gap = spec.gap}); + } + + int offset = 0; + auto extent = extents.begin(); + for (auto* child : placed) { + int const across = layout::crossExtent(vertical ? child->layout().width : child->layout().height, + vertical ? area.width : area.height); + child->view().setArea(vertical + ? ::core::tui::Rect{.x = area.x, .y = area.y + offset, .width = across, .height = *extent} + : ::core::tui::Rect{.x = area.x + offset, .y = area.y, .width = *extent, .height = across}); + offset += *extent + spec.gap; + ++extent; + } +} + +std::vector splitLines(std::string_view text) { + std::vector lines; + std::size_t start = 0; + for (;;) { + auto const end = text.find('\n', start); + lines.push_back(text.substr(start, end == std::string_view::npos ? std::string_view::npos : end - start)); + if (end == std::string_view::npos) { + return lines; + } + start = end + 1; + } +} + +int displayWidth(std::string_view text) { + return ::core::tui::stringWidth(text); +} + +bool isActivation(::core::tui::KeyEvent const& key) noexcept { + return ::core::tui::withoutLockKeys(key.modifiers) == ::core::tui::Modifier::None && + (key.key == ::core::tui::KeyCode::Enter || key.codepoint == U' '); +} + +void collectFocusable(::core::tui::Component& root, std::vector<::core::tui::Component*>& out) { + for (auto* child : root.children()) { + if (child->focusable() && child->visible()) { + out.push_back(child); + } + collectFocusable(*child, out); + } +} + +bool isWithin(::core::tui::Component const* node, ::core::tui::Component const& root) noexcept { + for (; node != nullptr; node = node->parent()) { + if (node == &root) { + return true; + } + } + return false; +} + +void attach(Context& context, ui::ContainerWidget* parent, WidgetBase& widget) { + if (parent == nullptr) { + context.roots.push_back(&widget); + context.screen->root().addChild(widget.view(), ::core::tui::LayoutParams{.area = context.screen->viewportArea()}); + return; + } + ContainerBase::of(*parent).attach(widget); +} + +void fit(Context& context) { + auto const area = context.screen->viewportArea(); + for (auto* root : context.roots) { + root->view().setArea(area); + } +} + +void moveFocus(Context& context, Direction direction) { + if (direction == Direction::Forward) { + context.screen->focusNext(); + } else { + context.screen->focusPrev(); + } +} + +} // namespace morph::tui::detail +``` + +- [ ] **Step 4: Implement the first leaf widgets and the stacks** + +Create `src/tui/leaf_widgets.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include + +#include "tui/widget.hpp" + +namespace morph::tui::detail { + +/// Text: one row per line, styled by role. +class TextImpl final : public TuiWidget { +public: + explicit TextImpl(Context& context); + void setText(std::string_view text) override; + void setRole(ui::TextRole role) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] std::string probeText() const override { return _text; } + void paint(::core::tui::Canvas& canvas) override; + +private: + std::string _text; + ui::TextRole _role = ui::TextRole::Normal; +}; + +/// Button: `[ label ]`, activated by Enter, Space or a click. +class ButtonImpl final : public TuiWidget { +public: + explicit ButtonImpl(Context& context); + void setLabel(std::string_view label) override; + void setOnClick(ui::Action onClick) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return true; } + [[nodiscard]] std::string probeText() const override { return _label; } + void paint(::core::tui::Canvas& canvas) override; + [[nodiscard]] ::core::tui::EventResult key(::core::tui::KeyEvent const& key) override; + void activate() override; + +private: + std::string _label; + ui::Action _onClick; +}; + +/// Checkbox: `[x] label`; Enter, Space or a click flips it and reports the new state. +class CheckboxImpl final : public TuiWidget { +public: + explicit CheckboxImpl(Context& context); + void setLabel(std::string_view label) override; + void setChecked(bool checked) override; + void setOnToggle(std::function onToggle) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return true; } + [[nodiscard]] std::string probeText() const override { return _label; } + void paint(::core::tui::Canvas& canvas) override; + [[nodiscard]] ::core::tui::EventResult key(::core::tui::KeyEvent const& key) override; + void activate() override; + +private: + std::string _label; + bool _checked = false; + std::function _onToggle; +}; + +/// Spacer: empty; in a stack a Content-sized spacer stretches. +class SpacerImpl final : public TuiWidget { +public: + explicit SpacerImpl(Context& context); + [[nodiscard]] ::core::tui::Size naturalSize() const override { return {.width = 0, .height = 0}; } + [[nodiscard]] bool expandsByDefault() const override { return true; } +}; + +} // namespace morph::tui::detail +``` + +Create `src/tui/leaf_widgets.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "tui/leaf_widgets.hpp" + +#include +#include +#include +#include + +namespace morph::tui::detail { + +namespace { + +::core::tui::Style const& roleStyle(::core::tui::Theme const& theme, ui::TextRole role) { + if (role == ui::TextRole::Muted) { + return theme.textMuted; + } + if (role == ui::TextRole::Heading) { + return theme.textBold; + } + if (role == ui::TextRole::Error) { + return theme.error; + } + if (role == ui::TextRole::Success) { + return theme.success; + } + return theme.textNormal; +} + +} // namespace + +TextImpl::TextImpl(Context& context) : TuiWidget{context} { + adopt(std::make_unique(*this)); +} + +void TextImpl::setText(std::string_view text) { + _text = std::string{text}; + refresh(); +} + +void TextImpl::setRole(ui::TextRole role) { + _role = role; + refresh(); +} + +::core::tui::Size TextImpl::naturalSize() const { + auto const lines = splitLines(_text); + int width = 0; + for (auto const line : lines) { + width = std::max(width, displayWidth(line)); + } + return {.width = width, .height = static_cast(lines.size())}; +} + +void TextImpl::paint(::core::tui::Canvas& canvas) { + auto const& style = roleStyle(canvas.theme(), _role); + int row = 0; + for (auto const line : splitLines(_text)) { + if (row >= canvas.height()) { + break; + } + canvas.putString(row, 0, line, style); + ++row; + } +} + +ButtonImpl::ButtonImpl(Context& context) : TuiWidget{context} { + adopt(std::make_unique(*this)); +} + +void ButtonImpl::setLabel(std::string_view label) { + _label = std::string{label}; + refresh(); +} + +void ButtonImpl::setOnClick(ui::Action onClick) { + _onClick = std::move(onClick); +} + +::core::tui::Size ButtonImpl::naturalSize() const { + return {.width = displayWidth(_label) + 4, .height = 1}; +} + +void ButtonImpl::paint(::core::tui::Canvas& canvas) { + auto const& theme = canvas.theme(); + auto const& style = !enabled() ? theme.buttonDisabled : hasFocus() ? theme.buttonFocused : theme.buttonNormal; + canvas.putString(0, 0, "[ " + _label + " ]", style); +} + +::core::tui::EventResult ButtonImpl::key(::core::tui::KeyEvent const& key) { + if (!isActivation(key)) { + return ::core::tui::EventResult::Ignored; + } + activate(); + return ::core::tui::EventResult::Handled; +} + +void ButtonImpl::activate() { + if (enabled() && _onClick) { + _onClick(); + } +} + +CheckboxImpl::CheckboxImpl(Context& context) : TuiWidget{context} { + adopt(std::make_unique(*this)); +} + +void CheckboxImpl::setLabel(std::string_view label) { + _label = std::string{label}; + refresh(); +} + +void CheckboxImpl::setChecked(bool checked) { + _checked = checked; + refresh(); +} + +void CheckboxImpl::setOnToggle(std::function onToggle) { + _onToggle = std::move(onToggle); +} + +::core::tui::Size CheckboxImpl::naturalSize() const { + return {.width = displayWidth(_label) + 4, .height = 1}; +} + +void CheckboxImpl::paint(::core::tui::Canvas& canvas) { + auto const& theme = canvas.theme(); + auto const& style = !enabled() ? theme.textMuted : hasFocus() ? theme.buttonFocused : theme.textNormal; + canvas.putString(0, 0, std::string{_checked ? "[x] " : "[ ] "} + _label, style); +} + +::core::tui::EventResult CheckboxImpl::key(::core::tui::KeyEvent const& key) { + if (!isActivation(key)) { + return ::core::tui::EventResult::Ignored; + } + activate(); + return ::core::tui::EventResult::Handled; +} + +// Optimistic, like a text field: the box shows the new state at once, and a binding that +// disagrees sets it back through setChecked. +void CheckboxImpl::activate() { + if (!enabled()) { + return; + } + _checked = !_checked; + refresh(); + if (_onToggle) { + _onToggle(_checked); + } +} + +SpacerImpl::SpacerImpl(Context& context) : TuiWidget{context} { + adopt(std::make_unique(*this)); +} + +} // namespace morph::tui::detail +``` + +Create `src/tui/container_widgets.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include + +#include "tui/widget.hpp" + +namespace morph::tui::detail { + +/// Column or Row (and ForEach, and a Table row): children along one axis, with a gap. +class StackImpl final : public TuiContainer { +public: + StackImpl(Context& context, ui::Axis axis); + void setGap(int gap) override; + [[nodiscard]] ui::Axis axis() const noexcept { return _axis; } + [[nodiscard]] int gap() const noexcept { return _gap; } + /// Leading shown children given no area: what a Scroll around this stack has scrolled past. + void setSkip(std::size_t skip); + [[nodiscard]] std::size_t skip() const noexcept { return _skip; } + /// Fixed main-axis extents and their gap, for a Table row; an empty list returns the row to the solver. + void setColumnLayout(std::vector extents, int gap); + [[nodiscard]] ::core::tui::Size naturalSize() const override; + void paint(::core::tui::Canvas& canvas) override; + +private: + ui::Axis _axis; + int _gap = 0; + std::size_t _skip = 0; + std::vector _extents; + int _extentGap = 0; +}; + +/// The slot a Switch case or a Tabs page mounts into: its children stacked vertically, no gap. +class SlotImpl final : public TuiContainer { +public: + explicit SlotImpl(Context& context); + [[nodiscard]] ::core::tui::Size naturalSize() const override; + void paint(::core::tui::Canvas& canvas) override; +}; + +} // namespace morph::tui::detail +``` + +Create `src/tui/container_widgets.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "tui/container_widgets.hpp" + +#include +#include +#include + +namespace morph::tui::detail { + +StackImpl::StackImpl(Context& context, ui::Axis axis) : TuiContainer{context}, _axis{axis} { + adopt(std::make_unique(*this)); +} + +void StackImpl::setGap(int gap) { + _gap = gap; + refresh(); +} + +void StackImpl::setSkip(std::size_t skip) { + _skip = skip; +} + +void StackImpl::setColumnLayout(std::vector extents, int gap) { + _extents = std::move(extents); + _extentGap = gap; +} + +::core::tui::Size StackImpl::naturalSize() const { + if (_extents.empty()) { + return stackNaturalSize(children(), _axis, _gap); + } + auto const natural = stackNaturalSize(children(), _axis, 0); + int const columns = std::accumulate(_extents.begin(), _extents.end(), 0) + + (_extentGap * (static_cast(_extents.size()) - 1)); + return _axis == ui::Axis::Horizontal ? ::core::tui::Size{.width = columns, .height = natural.height} + : ::core::tui::Size{.width = natural.width, .height = columns}; +} + +void StackImpl::paint(::core::tui::Canvas& canvas) { + arrangeStack(children(), canvas.area(), + StackSpec{.axis = _axis, + .gap = _extents.empty() ? _gap : _extentGap, + .skip = _skip, + .extents = _extents}); +} + +SlotImpl::SlotImpl(Context& context) : TuiContainer{context} { + adopt(std::make_unique(*this)); +} + +::core::tui::Size SlotImpl::naturalSize() const { + return stackNaturalSize(children(), ui::Axis::Vertical, 0); +} + +void SlotImpl::paint(::core::tui::Canvas& canvas) { + arrangeStack(children(), canvas.area(), StackSpec{.axis = ui::Axis::Vertical}); +} + +} // namespace morph::tui::detail +``` + +Add to `add_library(morph_tui STATIC …)`: `src/tui/context.cpp`, `src/tui/context.hpp`, `src/tui/widget.cpp`, +`src/tui/widget.hpp`, `src/tui/leaf_widgets.cpp`, `src/tui/leaf_widgets.hpp`, `src/tui/container_widgets.cpp`, +`src/tui/container_widgets.hpp`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cmake --build build/tui --target morph_tui_tests && ./build/tui/tests/tui/morph_tui_tests "[widgets]"` +Expected: PASS, 9 test cases. + +Mutation check: in `WidgetBase::~WidgetBase`, delete the `setFocus(nullptr)` block. Expected: FAIL in "destroying +the focused widget clears the screen's focus" at `focusedComponent() == nullptr` (and, under ASan, a +use-after-free report from the `draw()` that follows, which reads the focused component's cursor shape). Restore. + +- [ ] **Step 6: Commit** + +```bash +git add src/tui tests/tui CMakeLists.txt +git commit -m "wip(tui): widget base, stacks, text, button, checkbox and spacer + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 9: Grid, Panel and Scroll + +**Files:** +- Modify: `src/tui/container_widgets.hpp`, `src/tui/container_widgets.cpp` — append `GridImpl`, `PanelImpl`, + `ScrollImpl` +- Modify: `tests/tui/CMakeLists.txt` — add `test_tui_containers.cpp` +- Test: `tests/tui/test_tui_containers.cpp` + +**Interfaces:** +- Consumes: `ui::GridWidget`, `ui::PanelWidget`, `ui::ScrollWidget` (Part 2); `layout::planGrid`, `slotArea`, + `pad` (Task 7); `StackImpl::setSkip`, `arrangeStack`, `stackNaturalSize` (Task 8); + `core::tui::BorderStyle` (core-cpp `tui/Box.hpp`). +- Produces: `GridImpl(Context&)`, `PanelImpl(Context&)`, `ScrollImpl(Context&, ui::Axis)`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/tui/test_tui_containers.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "tui/container_widgets.hpp" +#include "tui/leaf_widgets.hpp" +#include "tui_harness.hpp" + +namespace ui = morph::ui; +using core::tui::KeyCode; +using morph::tui::detail::ButtonImpl; +using morph::tui::detail::GridImpl; +using morph::tui::detail::PanelImpl; +using morph::tui::detail::ScrollImpl; +using morph::tui::detail::StackImpl; +using morph::tui::detail::TextImpl; +using morph::tui::testing::Harness; +using Rows = std::vector; + +TEST_CASE("tui containers: a grid places cells row-major, a span covers columns and the gap", "[tui][containers]") { + Harness harness{21, 3}; + auto const grid = harness.make(nullptr); + grid->setColumns(2); + grid->setGap(1); + auto const first = harness.make(grid.get()); + first->setText("a"); + auto const second = harness.make(grid.get()); + second->setText("b"); + auto const wide = harness.make(grid.get()); + wide->setText("c"); + grid->setSpan(*wide, 2); + CHECK(harness.draw() == Rows{"a b", "", "c"}); +} + +TEST_CASE("tui containers: a panel draws its box and title around its child", "[tui][containers]") { + Harness harness{12, 4}; + auto const panel = harness.make(nullptr); + panel->setTitle("Info"); + auto const body = harness.make(panel.get()); + body->setText("hi"); + CHECK(harness.draw() == Rows{"┌─Info─────┐", "│hi │", "│ │", "└──────────┘"}); +} + +TEST_CASE("tui containers: a panel's padding insets its child", "[tui][containers]") { + Harness harness{12, 5}; + auto const panel = harness.make(nullptr); + panel->setTitle("Info"); + panel->setPadding(1); + auto const body = harness.make(panel.get()); + body->setText("hi"); + CHECK(harness.draw().at(2) == "│ hi │"); +} + +TEST_CASE("tui containers: a collapsible panel toggles on Enter, hides its child and reports the state", + "[tui][containers]") { + Harness harness{14, 4}; + auto const panel = harness.make(nullptr); + panel->setTitle("Info"); + panel->setCollapsible(true); + panel->setCollapsed(false); + std::vector toggles; + panel->setOnToggle([&](bool collapsed) { toggles.push_back(collapsed); }); + auto const body = harness.make(panel.get()); + body->setText("hi"); + CHECK(harness.draw().front() == "┌─▾ Info─────┐"); + harness.focus(*panel); + static_cast(harness.key(KeyCode::Enter)); + CHECK(toggles == std::vector{true}); + CHECK(harness.draw() == Rows{"▸ Info"}); +} + +TEST_CASE("tui containers: a scroll keeps the focused child in view", "[tui][containers]") { + Harness harness{10, 3}; + auto const scroll = harness.make(nullptr, ui::Axis::Vertical); + auto const column = harness.make(scroll.get(), ui::Axis::Vertical); + std::vector> buttons; + for (int i = 0; i < 5; ++i) { + buttons.push_back(harness.make(column.get())); + buttons.back()->setLabel(std::to_string(i)); + } + CHECK(harness.draw() == Rows{"[ 0 ]", "[ 1 ]", "[ 2 ]"}); + harness.focus(*buttons.back()); + CHECK(harness.draw() == Rows{"[ 2 ]", "[ 3 ]", "[ 4 ]"}); + harness.focus(*buttons.front()); + CHECK(harness.draw() == Rows{"[ 0 ]", "[ 1 ]", "[ 2 ]"}); + buttons.clear(); +} + +TEST_CASE("tui containers: the wheel scrolls a scroll with nothing focused in it", "[tui][containers]") { + Harness harness{10, 3}; + auto const scroll = harness.make(nullptr, ui::Axis::Vertical); + auto const column = harness.make(scroll.get(), ui::Axis::Vertical); + std::vector> lines; + for (int i = 0; i < 5; ++i) { + lines.push_back(harness.make(column.get())); + lines.back()->setText("line " + std::to_string(i)); + } + static_cast(harness.draw()); + static_cast(harness.send(core::tui::MouseEvent{.type = core::tui::MouseEvent::Type::ScrollDown, .button = 0, .x = 1, .y = 1})); + CHECK(harness.draw() == Rows{"line 1", "line 2", "line 3"}); + lines.clear(); +} +``` + +Add `test_tui_containers.cpp` to `tests/tui/CMakeLists.txt`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/tui --target morph_tui_tests` +Expected: FAIL — `no type named 'GridImpl' in namespace 'morph::tui::detail'`. + +- [ ] **Step 3: Implement** + +Append to `src/tui/container_widgets.hpp`, inside the namespace (and add `#include `, +`#include `, +`#include `, `#include `, `#include `, `#include ` and +`#include "tui/layout.hpp"`): + +```cpp +/// Grid: equal columns, cells row-major, a cell spanning several columns. +class GridImpl final : public TuiContainer { +public: + explicit GridImpl(Context& context); + void setColumns(int columns) override; + void setGap(int gap) override; + void setSpan(ui::Widget& child, int span) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + void paint(::core::tui::Canvas& canvas) override; + +protected: + void childForgotten(WidgetBase& child) override; + +private: + [[nodiscard]] int spanOf(WidgetBase const* child) const; + [[nodiscard]] std::vector cellsOf(std::span shown) const; + + int _columns = 1; + int _gap = 0; + std::unordered_map _spans; +}; + +/// Panel: a box with a title around its children, inset by padding; a collapsible panel folds to its title line. +class PanelImpl final : public TuiContainer { +public: + explicit PanelImpl(Context& context); + void setTitle(std::string_view title) override; + void setPadding(int padding) override; + void setCollapsible(bool collapsible) override; + void setCollapsed(bool collapsed) override; + void setOnToggle(std::function onToggle) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return _collapsible; } + [[nodiscard]] std::string probeText() const override { return _title; } + void paint(::core::tui::Canvas& canvas) override; + [[nodiscard]] ::core::tui::EventResult key(::core::tui::KeyEvent const& key) override; + void activate() override; + +protected: + void childAttached(WidgetBase& child) override; + +private: + [[nodiscard]] std::string heading() const; + void syncChildren(); + + std::string _title; + int _padding = 0; + bool _collapsible = false; + bool _collapsed = false; + std::function _onToggle; +}; + +/// Scroll: shows as much of its content as fits. When the content is a stack along the scroll's axis, whole +/// children scroll out of view, the focused one is always kept in view, and the wheel moves one child. +class ScrollImpl final : public TuiContainer { +public: + ScrollImpl(Context& context, ui::Axis axis); + [[nodiscard]] ::core::tui::Size naturalSize() const override; + void paint(::core::tui::Canvas& canvas) override; + [[nodiscard]] bool wheel(int delta) override; + +private: + [[nodiscard]] StackImpl* scrolledStack() const; + [[nodiscard]] std::size_t skipFor(StackImpl const& stack, int viewport) const; + + ui::Axis _axis; + std::size_t _skip = 0; +}; +``` + +Append to `src/tui/container_widgets.cpp`, inside the namespace (and add `#include `, +`#include `, `#include `, `#include ` and `#include `): + +```cpp +namespace { + +/// The extent of shown stack children [first, last] along the stack's axis, gaps included. +int extentOf(std::span items, std::size_t first, std::size_t last, StackImpl const& stack) { + bool const vertical = stack.axis() == ui::Axis::Vertical; + int extent = stack.gap() * static_cast(last - first); + for (auto const* item : items.subspan(first, last - first + 1)) { + auto const natural = item->naturalSize(); + extent += vertical ? natural.height : natural.width; + } + return extent; +} + +/// Which of @p items holds the screen's focus, itself or below it. +std::optional indexHoldingFocus(std::span items, ::core::tui::Screen const& screen) { + auto const* const focused = screen.focusedComponent(); + auto const found = std::ranges::find_if(items, [focused](WidgetBase const* item) { return isWithin(focused, item->view()); }); + if (found == items.end()) { + return std::nullopt; + } + return static_cast(std::distance(items.begin(), found)); +} + +} // namespace + +GridImpl::GridImpl(Context& context) : TuiContainer{context} { + adopt(std::make_unique(*this)); +} + +void GridImpl::setColumns(int columns) { + _columns = columns; + refresh(); +} + +void GridImpl::setGap(int gap) { + _gap = gap; + refresh(); +} + +void GridImpl::setSpan(ui::Widget& child, int span) { + _spans.insert_or_assign(&WidgetBase::of(child), span); + refresh(); +} + +void GridImpl::childForgotten(WidgetBase& child) { + _spans.erase(&child); +} + +int GridImpl::spanOf(WidgetBase const* child) const { + auto const found = _spans.find(child); + return found == _spans.end() ? 1 : found->second; +} + +std::vector GridImpl::cellsOf(std::span shown) const { + std::vector cells; + for (auto const* child : shown) { + cells.push_back(layout::GridCell{ + .span = spanOf(child), .naturalHeight = child->naturalSize().height, .height = child->layout().height}); + } + return cells; +} + +::core::tui::Size GridImpl::naturalSize() const { + auto const shown = shownChildren(); + if (shown.empty()) { + return {.width = 0, .height = 0}; + } + int const columns = std::max(1, _columns); + int unit = 0; + for (auto const* child : shown) { + int const span = std::clamp(spanOf(child), 1, columns); + unit = std::max(unit, (child->naturalSize().width + span - 1) / span); + } + int const width = (unit * columns) + (_gap * (columns - 1)); + auto const plan = layout::planGrid(_columns, cellsOf(shown), layout::Track{.length = width, .gap = _gap}); + int height = _gap * (static_cast(plan.rowHeights.size()) - 1); + for (int const rowHeight : plan.rowHeights) { + height += rowHeight; + } + return {.width = width, .height = height}; +} + +void GridImpl::paint(::core::tui::Canvas& canvas) { + for (auto* child : children()) { + if (!child->shown()) { + child->view().setArea({}); + } + } + auto const shown = shownChildren(); + auto const plan = layout::planGrid(_columns, cellsOf(shown), layout::Track{.length = canvas.width(), .gap = _gap}); + auto slot = plan.slots.begin(); + for (auto* child : shown) { + child->view().setArea(layout::slotArea(plan, *slot)); + ++slot; + } +} + +PanelImpl::PanelImpl(Context& context) : TuiContainer{context} { + adopt(std::make_unique(*this)); +} + +void PanelImpl::setTitle(std::string_view title) { + _title = std::string{title}; + refresh(); +} + +void PanelImpl::setPadding(int padding) { + _padding = std::max(0, padding); + refresh(); +} + +void PanelImpl::setCollapsible(bool collapsible) { + _collapsible = collapsible; + refresh(); +} + +void PanelImpl::setCollapsed(bool collapsed) { + _collapsed = collapsed; + syncChildren(); +} + +void PanelImpl::setOnToggle(std::function onToggle) { + _onToggle = std::move(onToggle); +} + +std::string PanelImpl::heading() const { + if (!_collapsible) { + return _title; + } + return (_collapsed ? "▸ " : "▾ ") + _title; +} + +void PanelImpl::childAttached(WidgetBase& child) { + child.setStructuralVisible(!_collapsed); +} + +void PanelImpl::syncChildren() { + for (auto* child : children()) { + child->setStructuralVisible(!_collapsed); + } + refresh(); +} + +::core::tui::Size PanelImpl::naturalSize() const { + if (_collapsed) { + return {.width = displayWidth(heading()), .height = 1}; + } + auto const inner = stackNaturalSize(children(), ui::Axis::Vertical, 0); + return {.width = std::max(inner.width + (2 * _padding) + 2, displayWidth(heading()) + 4), + .height = inner.height + (2 * _padding) + 2}; +} + +void PanelImpl::paint(::core::tui::Canvas& canvas) { + auto const& theme = canvas.theme(); + auto const& style = hasFocus() ? theme.buttonFocused : theme.textNormal; + if (_collapsed) { + canvas.putString(0, 0, heading(), style); + return; + } + canvas.drawBox(canvas.area(), ::core::tui::BorderStyle::Single, style, heading()); + auto const inside = ::core::tui::Rect{.x = 1, .y = 1, .width = std::max(0, canvas.width() - 2), + .height = std::max(0, canvas.height() - 2)}; + arrangeStack(children(), layout::pad(inside, _padding), StackSpec{.axis = ui::Axis::Vertical}); +} + +::core::tui::EventResult PanelImpl::key(::core::tui::KeyEvent const& key) { + if (!_collapsible || !isActivation(key)) { + return ::core::tui::EventResult::Ignored; + } + activate(); + return ::core::tui::EventResult::Handled; +} + +void PanelImpl::activate() { + if (!_collapsible || !enabled()) { + return; + } + _collapsed = !_collapsed; + syncChildren(); + if (_onToggle) { + _onToggle(_collapsed); + } +} + +ScrollImpl::ScrollImpl(Context& context, ui::Axis axis) : TuiContainer{context}, _axis{axis} { + adopt(std::make_unique(*this)); +} + +::core::tui::Size ScrollImpl::naturalSize() const { + return stackNaturalSize(children(), _axis, 0); +} + +StackImpl* ScrollImpl::scrolledStack() const { + auto const shown = shownChildren(); + if (shown.empty()) { + return nullptr; + } + auto* const stack = dynamic_cast(shown.front()); + return stack != nullptr && stack->axis() == _axis ? stack : nullptr; +} + +std::size_t ScrollImpl::skipFor(StackImpl const& stack, int viewport) const { + auto const items = stack.shownChildren(); + if (items.empty()) { + return 0; + } + std::size_t skip = std::min(_skip, items.size() - 1); + if (auto const focused = indexHoldingFocus(items, *context().screen)) { + if (*focused < skip) { + skip = *focused; + } + while (skip < *focused && extentOf(items, skip, *focused, stack) > viewport) { + ++skip; + } + } + return skip; +} + +void ScrollImpl::paint(::core::tui::Canvas& canvas) { + arrangeStack(children(), canvas.area(), StackSpec{.axis = _axis}); + if (auto* const stack = scrolledStack()) { + _skip = skipFor(*stack, _axis == ui::Axis::Vertical ? canvas.height() : canvas.width()); + stack->setSkip(_skip); + } +} + +bool ScrollImpl::wheel(int delta) { + if (delta < 0) { + _skip = _skip > 0 ? _skip - 1 : 0; + } else { + ++_skip; + } + refresh(); + return true; +} +``` + +`ScrollImpl::paint` writes `_skip`, so `paint` is not `const` — as declared. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/tui --target morph_tui_tests && ./build/tui/tests/tui/morph_tui_tests "[containers]"` +Expected: PASS, 6 test cases. + +Mutation check: in `ScrollImpl::skipFor`, delete the `while (skip < *focused …)` loop. Expected: FAIL in "a scroll +keeps the focused child in view" (the second frame still shows `[ 0 ]`…`[ 2 ]`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/container_widgets.hpp src/tui/container_widgets.cpp tests/tui +git commit -m "wip(tui): grid, panel and scroll + +Signed-off-by: Christian Parpart " +``` + +--- +### Task 10: TextInput, DateTimeInput and FilePicker + +**Files:** +- Create: `src/tui/field_widgets.hpp`, `src/tui/field_widgets.cpp` +- Modify: `CMakeLists.txt` — add both to `add_library(morph_tui STATIC …)` +- Modify: `tests/tui/CMakeLists.txt` — add `test_tui_fields.cpp` +- Test: `tests/tui/test_tui_fields.cpp` + +**Interfaces:** +- Consumes: `core::tui::InputField` (`processEvent`, `text`, `setText`, `setMasked`, `setMultiline`, `setPrompt`, + `prompt`, `lineCount`, `render`) and `InputFieldAction` (core-cpp `tui/InputField.hpp`); `ui::TextInputWidget`, + `ui::DateTimeInputWidget`, `ui::FilePickerWidget`, `ui::TextInputMode`, `ui::DateMode`, `ui::FilePickerMode` + (Part 2); `morph::time::DateTime` (`toIso8601`, `fromIso8601`, `now`, `operator+`, `operator-`) and + `morph::time::Timestamp` (`include/morph/util/datetime.hpp:30, 376`). +- Produces: `class FieldOwner`, `class FieldView`; `formatLocal(DateTime, ui::DateMode, int offsetMinutes)`, + `parseLocal(std::string_view, ui::DateMode, int offsetMinutes)`; `TextInputImpl(Context&, ui::TextInputMode)`, + `DateTimeInputImpl(Context&, ui::DateMode, int)`, `FilePickerImpl(Context&, ui::FilePickerMode)`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/tui/test_tui_fields.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tui/field_widgets.hpp" +#include "tui_harness.hpp" + +namespace ui = morph::ui; +using core::tui::EventResult; +using core::tui::KeyCode; +using core::tui::Modifier; +using morph::time::DateTime; +using morph::time::Timestamp; +using morph::tui::detail::DateTimeInputImpl; +using morph::tui::detail::FilePickerImpl; +using morph::tui::detail::formatLocal; +using morph::tui::detail::parseLocal; +using morph::tui::detail::TextInputImpl; +using morph::tui::testing::Harness; +using Rows = std::vector; + +namespace { + +DateTime at(int year, unsigned month, unsigned day, int hour, int minute) { + return DateTime{std::chrono::year{year}, std::chrono::month{month}, std::chrono::day{day}, + std::chrono::hours{hour}, std::chrono::minutes{minute}, std::chrono::seconds{0}}; +} + +} // namespace + +TEST_CASE("tui fields: typing reports each change and Enter submits", "[tui][fields]") { + Harness harness{20, 1}; + auto const input = harness.make(nullptr, ui::TextInputMode::SingleLine); + std::vector changes; + std::vector submits; + input->setOnChange([&](std::string text) { changes.push_back(std::move(text)); }); + input->setOnSubmit([&](std::string text) { submits.push_back(std::move(text)); }); + harness.focus(*input); + static_cast(harness.type("hi")); + static_cast(harness.key(KeyCode::Enter)); + CHECK(changes == std::vector{"h", "hi"}); + CHECK(submits == std::vector{"hi"}); + CHECK(harness.draw() == Rows{"hi"}); +} + +TEST_CASE("tui fields: setText with the field's own text keeps the cursor and fires nothing", "[tui][fields]") { + Harness harness{20, 1}; + auto const input = harness.make(nullptr, ui::TextInputMode::SingleLine); + std::vector changes; + input->setOnChange([&](std::string text) { changes.push_back(std::move(text)); }); + harness.focus(*input); + static_cast(harness.type("abc")); + static_cast(harness.key(KeyCode::Left)); + input->setText("abc"); // a binding echoing what was just typed + CHECK(changes.size() == 3); + static_cast(harness.type("X")); + CHECK(changes.back() == "abXc"); +} + +TEST_CASE("tui fields: setText with new text replaces it and fires nothing", "[tui][fields]") { + Harness harness{20, 1}; + auto const input = harness.make(nullptr, ui::TextInputMode::SingleLine); + int changes = 0; + input->setOnChange([&](std::string const&) { ++changes; }); + input->setText("new"); + CHECK(changes == 0); + CHECK(harness.draw() == Rows{"new"}); +} + +TEST_CASE("tui fields: Tab, Shift+Tab and Esc are left to the frontend and the dialog", "[tui][fields]") { + Harness harness{20, 1}; + auto const input = harness.make(nullptr, ui::TextInputMode::SingleLine); + input->setText("keep"); + harness.focus(*input); + CHECK(harness.key(KeyCode::Tab) == EventResult::Ignored); + CHECK(harness.key(KeyCode::Tab, Modifier::Shift) == EventResult::Ignored); + CHECK(harness.key(KeyCode::Escape) == EventResult::Ignored); + CHECK(harness.draw() == Rows{"keep"}); +} + +TEST_CASE("tui fields: the placeholder shows while the field is empty", "[tui][fields]") { + Harness harness{20, 1}; + auto const input = harness.make(nullptr, ui::TextInputMode::SingleLine); + input->setPlaceholder("name"); + CHECK(harness.draw() == Rows{"name"}); + harness.focus(*input); + static_cast(harness.type("x")); + CHECK(harness.draw() == Rows{"x"}); +} + +TEST_CASE("tui fields: a password field never shows what was typed", "[tui][fields]") { + Harness harness{20, 1}; + auto const input = harness.make(nullptr, ui::TextInputMode::Password); + harness.focus(*input); + static_cast(harness.type("pw")); + auto const rows = harness.draw(); + REQUIRE(rows.size() == 1); + CHECK(rows.front().find("pw") == std::string::npos); + CHECK_FALSE(rows.front().empty()); +} + +TEST_CASE("tui fields: local date-time text in a display zone", "[tui][fields]") { + auto const instant = at(2026, 10, 4, 13, 5); + CHECK(formatLocal(instant, ui::DateMode::DateTime, 0) == "2026-10-04 13:05"); + CHECK(formatLocal(instant, ui::DateMode::DateTime, 120) == "2026-10-04 15:05"); + CHECK(formatLocal(instant, ui::DateMode::Date, 0) == "2026-10-04"); + CHECK(parseLocal("2026-10-04 15:05", ui::DateMode::DateTime, 120) == instant); + CHECK(parseLocal("2026-10-04T13:05", ui::DateMode::DateTime, 0) == instant); + CHECK(parseLocal("2026-10-04", ui::DateMode::Date, 0) == at(2026, 10, 4, 0, 0)); + CHECK_FALSE(parseLocal("2026-13-01", ui::DateMode::Date, 0).has_value()); + CHECK_FALSE(parseLocal("garbage", ui::DateMode::DateTime, 0).has_value()); +} + +TEST_CASE("tui fields: a date-time field steps with the arrow keys and commits on Enter", "[tui][fields]") { + Harness harness{20, 1}; + auto const field = harness.make(nullptr, ui::DateMode::DateTime, 0); + std::vector> changes; + field->setOnChange([&](std::optional value) { changes.push_back(value); }); + field->setValue(Timestamp{at(2026, 10, 4, 13, 5)}); + CHECK(harness.draw() == Rows{"2026-10-04 13:05"}); + CHECK(changes.empty()); + + harness.focus(*field); + static_cast(harness.key(KeyCode::Up)); + CHECK(harness.draw() == Rows{"2026-10-04 13:06"}); + REQUIRE(changes.size() == 1); + CHECK(changes.back()->value == at(2026, 10, 4, 13, 6)); + + static_cast(harness.key(KeyCode::PageDown)); + CHECK(changes.back()->value == at(2026, 10, 3, 13, 6)); + + field->setValue(std::nullopt); + static_cast(harness.type("2026-01-02 03:04")); + static_cast(harness.key(KeyCode::Enter)); + CHECK(changes.back()->value == at(2026, 1, 2, 3, 4)); + + field->setValue(std::nullopt); + static_cast(harness.key(KeyCode::Enter)); + CHECK_FALSE(changes.back().has_value()); +} + +TEST_CASE("tui fields: an unparsable date-time is not committed and is marked", "[tui][fields]") { + Harness harness{20, 1}; + auto const field = harness.make(nullptr, ui::DateMode::Date, 0); + int changes = 0; + field->setOnChange([&](std::optional const&) { ++changes; }); + harness.focus(*field); + static_cast(harness.type("nope")); + static_cast(harness.key(KeyCode::Enter)); + CHECK(changes == 0); + CHECK(harness.draw().front().back() == '!'); +} + +TEST_CASE("tui fields: a file picker is a path field that reports on Enter", "[tui][fields]") { + Harness harness{30, 1}; + auto const picker = harness.make(nullptr, ui::FilePickerMode::Open); + std::vector picked; + picker->setOnPicked([&](std::string path) { picked.push_back(std::move(path)); }); + picker->setPath("/a"); + CHECK(harness.draw() == Rows{"Open: /a"}); + CHECK(picked.empty()); + harness.focus(*picker); + static_cast(harness.type("/b")); + static_cast(harness.key(KeyCode::Enter)); + CHECK(picked == std::vector{"/a/b"}); +} +``` + +Add `test_tui_fields.cpp` to `tests/tui/CMakeLists.txt`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/tui --target morph_tui_tests` +Expected: FAIL — `'tui/field_widgets.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `src/tui/field_widgets.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tui/widget.hpp" + +namespace morph::tui::detail { + +/// What a FieldView asks the widget that owns it. +class FieldOwner { +public: + FieldOwner() = default; + virtual ~FieldOwner() = default; + FieldOwner(FieldOwner const&) = delete; + FieldOwner& operator=(FieldOwner const&) = delete; + FieldOwner(FieldOwner&&) = delete; + FieldOwner& operator=(FieldOwner&&) = delete; + + /// The buffer or the cursor changed. + virtual void edited() = 0; + /// Enter was pressed. + virtual void submitted() = 0; + /// A key the owner handles before the field does; nullopt lets the field have it. + [[nodiscard]] virtual std::optional<::core::tui::EventResult> intercept(::core::tui::KeyEvent const& key); + /// Shown while the field is empty. + [[nodiscard]] virtual std::string_view placeholder() const { return {}; } + /// Whether to mark the field as holding text that does not parse. + [[nodiscard]] virtual bool invalid() const { return false; } +}; + +/// A core::tui::InputField that leaves Tab, Shift+Tab and Esc to the frontend and to dialogs (InputField would +/// clear its buffer on Esc and cycle an agent mode on Shift+Tab), and reports edits and Enter to its owner. +class FieldView final : public Hosted<::core::tui::InputField> { +public: + FieldView(WidgetBase& owner, FieldOwner& fields) : Hosted{owner}, _fields{&fields} {} + [[nodiscard]] ::core::tui::EventResult onEvent(::core::tui::InputEvent const& event) override; + void render(::core::tui::Canvas& canvas) override; + +private: + FieldOwner* _fields; +}; + +/// @p instant as text in the display zone @p offsetMinutes east of UTC: `YYYY-MM-DD` or `YYYY-MM-DD HH:MM`. +[[nodiscard]] std::string formatLocal(morph::time::DateTime instant, ui::DateMode mode, int offsetMinutes); +/// The instant @p text names in the display zone, or nullopt; DateTime mode accepts a space or a `T`. +[[nodiscard]] std::optional parseLocal(std::string_view text, ui::DateMode mode, int offsetMinutes); + +/// TextInput: single-line, multiline (Shift+Enter or Alt+Enter for a new line) or masked. +class TextInputImpl final : public TuiWidget, private FieldOwner { +public: + TextInputImpl(Context& context, ui::TextInputMode mode); + void setText(std::string_view text) override; + void setPlaceholder(std::string_view placeholder) override; + void setOnChange(std::function onChange) override; + void setOnSubmit(std::function onSubmit) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return true; } + [[nodiscard]] std::string probeText() const override; + +private: + void edited() override; + void submitted() override; + [[nodiscard]] std::string_view placeholder() const override { return _placeholder; } + + ui::TextInputMode _mode; + FieldView* _field; + std::string _reported; + std::string _placeholder; + std::function _onChange; + std::function _onSubmit; +}; + +/// DateTimeInput: an ISO-style field in a display zone. Up/Down step by a day (Date) or a minute (DateTime), +/// PageUp/PageDown by a day; Enter commits what was typed, or clears the value when the field is empty. +class DateTimeInputImpl final : public TuiWidget, private FieldOwner { +public: + DateTimeInputImpl(Context& context, ui::DateMode mode, int offsetMinutes); + void setValue(std::optional const& value) override; + void setOnChange(std::function)> onChange) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return true; } + [[nodiscard]] std::string probeText() const override; + +private: + void edited() override; + void submitted() override; + [[nodiscard]] std::optional<::core::tui::EventResult> intercept(::core::tui::KeyEvent const& key) override; + [[nodiscard]] bool invalid() const override { return _invalid; } + void step(std::chrono::minutes delta); + void commit(std::optional value); + + ui::DateMode _mode; + int _offsetMinutes; + FieldView* _field; + bool _invalid = false; + std::function)> _onChange; +}; + +/// FilePicker: a path field (a terminal has no file dialog), prompted `Open: ` or `Save: `; Enter reports it. +class FilePickerImpl final : public TuiWidget, private FieldOwner { +public: + FilePickerImpl(Context& context, ui::FilePickerMode mode); + void setPath(std::string_view path) override; + void setOnPicked(std::function onPicked) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return true; } + [[nodiscard]] std::string probeText() const override; + +private: + void edited() override {} + void submitted() override; + + FieldView* _field; + std::function _onPicked; +}; + +} // namespace morph::tui::detail +``` + +Create `src/tui/field_widgets.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "tui/field_widgets.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace morph::tui::detail { + +using ::core::tui::EventResult; +using ::core::tui::KeyCode; + +namespace { + +constexpr std::chrono::minutes kDay{24 * 60}; + +bool isPlain(::core::tui::KeyEvent const& key) noexcept { + return ::core::tui::withoutLockKeys(key.modifiers) == ::core::tui::Modifier::None; +} + +} // namespace + +std::optional FieldOwner::intercept(::core::tui::KeyEvent const& /*key*/) { + return std::nullopt; +} + +EventResult FieldView::onEvent(::core::tui::InputEvent const& event) { + if (auto const* mouse = std::get_if<::core::tui::MouseEvent>(&event)) { + return owner().pointer(*mouse); + } + if (!owner().enabled()) { + return EventResult::Ignored; + } + if (auto const* key = std::get_if<::core::tui::KeyEvent>(&event)) { + if (key->key == KeyCode::Tab || key->key == KeyCode::Escape) { + return EventResult::Ignored; + } + if (auto const handled = _fields->intercept(*key)) { + return *handled; + } + } + auto const action = processEvent(event); + if (action == ::core::tui::InputFieldAction::Changed) { + invalidate(); + _fields->edited(); + return EventResult::Handled; + } + if (action == ::core::tui::InputFieldAction::Submit) { + _fields->submitted(); + return EventResult::Handled; + } + // None, and the agent-shell actions (Ctrl+T, Ctrl+G, …) that mean nothing here, bubble on. + return EventResult::Ignored; +} + +void FieldView::render(::core::tui::Canvas& canvas) { + InputField::render(canvas); + auto const& theme = canvas.theme(); + if (text().empty() && !_fields->placeholder().empty()) { + canvas.putString(0, displayWidth(prompt()), _fields->placeholder(), theme.inputPlaceholder); + } + if (_fields->invalid() && canvas.width() > 0) { + canvas.put(0, canvas.width() - 1, "!", theme.error); + } +} + +std::string formatLocal(morph::time::DateTime instant, ui::DateMode mode, int offsetMinutes) { + // toIso8601: YYYY-MM-DDTHH:MM:SS.mmmZ + auto const iso = (instant + std::chrono::minutes{offsetMinutes}).toIso8601(); + if (mode == ui::DateMode::Date) { + return iso.substr(0, 10); + } + return iso.substr(0, 10) + " " + iso.substr(11, 5); +} + +std::optional parseLocal(std::string_view text, ui::DateMode mode, int offsetMinutes) { + std::string iso; + if (mode == ui::DateMode::Date) { + if (text.size() != 10) { + return std::nullopt; + } + iso = std::string{text} + "T00:00:00"; + } else { + if (text.size() != 16 || (text.at(10) != ' ' && text.at(10) != 'T')) { + return std::nullopt; + } + iso = std::string{text.substr(0, 10)} + "T" + std::string{text.substr(11, 5)} + ":00"; + } + auto const local = morph::time::DateTime::fromIso8601(iso); + if (!local) { + return std::nullopt; + } + return *local - std::chrono::minutes{offsetMinutes}; +} + +TextInputImpl::TextInputImpl(Context& context, ui::TextInputMode mode) + : TuiWidget{context}, _mode{mode}, _field{&adopt(std::make_unique(*this, static_cast(*this)))} { + _field->setMasked(mode == ui::TextInputMode::Password); + _field->setMultiline(mode == ui::TextInputMode::Multiline); +} + +// The guard is what keeps a controlled input usable: a binding that writes back the text the user just typed +// must not replace the buffer, which would move the cursor to the end. +void TextInputImpl::setText(std::string_view text) { + if (text != _field->text()) { + _field->setText(text); + refresh(); + } + _reported = std::string{text}; +} + +void TextInputImpl::setPlaceholder(std::string_view placeholder) { + _placeholder = std::string{placeholder}; + refresh(); +} + +void TextInputImpl::setOnChange(std::function onChange) { + _onChange = std::move(onChange); +} + +void TextInputImpl::setOnSubmit(std::function onSubmit) { + _onSubmit = std::move(onSubmit); +} + +::core::tui::Size TextInputImpl::naturalSize() const { + int const width = std::max({20, displayWidth(_field->text()) + 1, displayWidth(_placeholder)}); + int const height = _mode == ui::TextInputMode::Multiline ? std::max(3, _field->lineCount()) : 1; + return {.width = width, .height = height}; +} + +std::string TextInputImpl::probeText() const { + return std::string{_field->text()}; +} + +void TextInputImpl::edited() { + std::string current{_field->text()}; + if (current == _reported) { + return; + } + _reported = current; + if (_onChange) { + _onChange(std::move(current)); + } +} + +void TextInputImpl::submitted() { + if (_onSubmit) { + _onSubmit(std::string{_field->text()}); + } +} + +DateTimeInputImpl::DateTimeInputImpl(Context& context, ui::DateMode mode, int offsetMinutes) + : TuiWidget{context}, + _mode{mode}, + _offsetMinutes{offsetMinutes}, + _field{&adopt(std::make_unique(*this, static_cast(*this)))} {} + +void DateTimeInputImpl::setValue(std::optional const& value) { + auto const text = value && value->hasValue() ? formatLocal(**value, _mode, _offsetMinutes) : std::string{}; + if (text != _field->text()) { + _field->setText(text); + } + _invalid = false; + refresh(); +} + +void DateTimeInputImpl::setOnChange(std::function)> onChange) { + _onChange = std::move(onChange); +} + +::core::tui::Size DateTimeInputImpl::naturalSize() const { + return {.width = _mode == ui::DateMode::Date ? 12 : 18, .height = 1}; +} + +std::string DateTimeInputImpl::probeText() const { + return std::string{_field->text()}; +} + +void DateTimeInputImpl::edited() { + _invalid = false; +} + +void DateTimeInputImpl::submitted() { + if (_field->text().empty()) { + commit(std::nullopt); + return; + } + if (auto const parsed = parseLocal(_field->text(), _mode, _offsetMinutes)) { + commit(*parsed); + return; + } + _invalid = true; + refresh(); +} + +std::optional DateTimeInputImpl::intercept(::core::tui::KeyEvent const& key) { + if (!isPlain(key)) { + return std::nullopt; + } + auto const unit = _mode == ui::DateMode::Date ? kDay : std::chrono::minutes{1}; + if (key.key == KeyCode::Up) { + step(unit); + } else if (key.key == KeyCode::Down) { + step(-unit); + } else if (key.key == KeyCode::PageUp) { + step(kDay); + } else if (key.key == KeyCode::PageDown) { + step(-kDay); + } else { + return std::nullopt; + } + return EventResult::Handled; +} + +// An empty field steps from now; text that does not parse is left alone. +void DateTimeInputImpl::step(std::chrono::minutes delta) { + auto const text = _field->text(); + auto const current = text.empty() ? std::optional{morph::time::DateTime::now()} + : parseLocal(text, _mode, _offsetMinutes); + if (!current) { + _invalid = true; + refresh(); + return; + } + commit(*current + delta); +} + +void DateTimeInputImpl::commit(std::optional value) { + _invalid = false; + _field->setText(value ? formatLocal(*value, _mode, _offsetMinutes) : std::string{}); + refresh(); + if (_onChange) { + _onChange(value ? std::optional{morph::time::Timestamp{*value}} : std::nullopt); + } +} + +FilePickerImpl::FilePickerImpl(Context& context, ui::FilePickerMode mode) + : TuiWidget{context}, _field{&adopt(std::make_unique(*this, static_cast(*this)))} { + _field->setPrompt(mode == ui::FilePickerMode::Open ? "Open: " : "Save: "); +} + +void FilePickerImpl::setPath(std::string_view path) { + if (path != _field->text()) { + _field->setText(path); + refresh(); + } +} + +void FilePickerImpl::setOnPicked(std::function onPicked) { + _onPicked = std::move(onPicked); +} + +::core::tui::Size FilePickerImpl::naturalSize() const { + return {.width = std::max(30, displayWidth(_field->prompt()) + displayWidth(_field->text()) + 1), .height = 1}; +} + +std::string FilePickerImpl::probeText() const { + return std::string{_field->text()}; +} + +void FilePickerImpl::submitted() { + if (_onPicked && !_field->text().empty()) { + _onPicked(std::string{_field->text()}); + } +} + +} // namespace morph::tui::detail +``` + +Add `src/tui/field_widgets.cpp` and `src/tui/field_widgets.hpp` to `add_library(morph_tui STATIC …)`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/tui --target morph_tui_tests && ./build/tui/tests/tui/morph_tui_tests "[fields]"` +Expected: PASS, 10 test cases. + +Mutation check: in `TextInputImpl::setText`, drop the `if (text != _field->text())` guard (always call +`_field->setText(text)`). Expected: FAIL in "setText with the field's own text keeps the cursor and fires nothing" +(`abcX` instead of `abXc`). Restore. Then in `FieldView::onEvent` delete the `KeyCode::Escape` test. Expected: FAIL +in "Tab, Shift+Tab and Esc are left to the frontend and the dialog" (InputField clears the buffer on Esc). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/field_widgets.hpp src/tui/field_widgets.cpp tests/tui CMakeLists.txt +git commit -m "wip(tui): text input, date-time input and file picker + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 11: Select, Menu and Tabs + +**Files:** +- Create: `src/tui/list_widgets.hpp`, `src/tui/list_widgets.cpp` +- Modify: `CMakeLists.txt` — add both to `add_library(morph_tui STATIC …)` +- Modify: `tests/tui/CMakeLists.txt` — add `test_tui_lists.cpp` +- Test: `tests/tui/test_tui_lists.cpp` + +**Interfaces:** +- Consumes: `core::tui::List` (`setItems`, `selectedIndex`, `setSelectedIndex`, `items`, `onEvent`), `ListItem` + (core-cpp `tui/List.hpp`); `Screen::showOverlay`, `hideOverlay`; `ui::SelectWidget`, `ui::MenuWidget`, + `ui::TabsWidget`, `ui::SelectOption` (Part 2); `arrangeStack`, `isActivation` (Task 8). +- Produces: `class ListOwner`, `class ListView`; `RadioSelectImpl(Context&)`, `DropdownSelectImpl(Context&)` (with + `isOpen()`), `MenuImpl(Context&)`, `TabsImpl(Context&)`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/tui/test_tui_lists.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "tui/leaf_widgets.hpp" +#include "tui/list_widgets.hpp" +#include "tui_harness.hpp" + +namespace ui = morph::ui; +using core::tui::KeyCode; +using morph::tui::detail::DropdownSelectImpl; +using morph::tui::detail::MenuImpl; +using morph::tui::detail::RadioSelectImpl; +using morph::tui::detail::TabsImpl; +using morph::tui::detail::TextImpl; +using morph::tui::testing::Harness; +using Rows = std::vector; + +namespace { + +ui::Key key(std::int64_t value) { + return ui::Key{value}; +} + +std::vector colours() { + return {{.key = key(1), .label = "Red"}, {.key = key(2), .label = "Green"}}; +} + +} // namespace + +TEST_CASE("tui lists: a radio select marks the selection and selects on Space", "[tui][lists]") { + Harness harness{20, 2}; + auto const select = harness.make(nullptr); + std::vector chosen; + select->setOptions(colours()); + select->setSelected(key(2)); + select->setOnSelect([&](ui::Key picked) { chosen.push_back(std::move(picked)); }); + CHECK(harness.draw() == Rows{" ( ) Red", "▶ (•) Green"}); + harness.focus(*select); + static_cast(harness.key(KeyCode::Up)); + static_cast(harness.type(" ")); + CHECK(chosen == std::vector{key(1)}); + CHECK(harness.draw() == Rows{"▶ (•) Red", " ( ) Green"}); +} + +TEST_CASE("tui lists: a dropdown opens its list as an overlay and closes on a choice", "[tui][lists]") { + Harness harness{20, 4}; + auto const select = harness.make(nullptr); + std::vector chosen; + select->setOptions(colours()); + select->setSelected(key(1)); + select->setOnSelect([&](ui::Key picked) { chosen.push_back(std::move(picked)); }); + CHECK(harness.draw() == Rows{"[Red ▾]"}); + harness.focus(*select); + static_cast(harness.key(KeyCode::Enter)); + CHECK(select->isOpen()); + CHECK(harness.draw() == Rows{"[Red ▾]", "▶ Red", " Green"}); + static_cast(harness.key(KeyCode::Down)); + static_cast(harness.key(KeyCode::Enter)); + CHECK_FALSE(select->isOpen()); + CHECK(chosen == std::vector{key(2)}); + CHECK(harness.focused(*select)); + CHECK(harness.draw() == Rows{"[Green ▾]"}); +} + +TEST_CASE("tui lists: Esc closes a dropdown without choosing", "[tui][lists]") { + Harness harness{20, 4}; + auto const select = harness.make(nullptr); + int chosen = 0; + select->setOptions(colours()); + select->setOnSelect([&](ui::Key const&) { ++chosen; }); + harness.focus(*select); + static_cast(harness.key(KeyCode::Enter)); + static_cast(harness.key(KeyCode::Escape)); + CHECK_FALSE(select->isOpen()); + CHECK(chosen == 0); + CHECK(harness.focused(*select)); +} + +TEST_CASE("tui lists: a menu is one list; Enter activates the highlighted item", "[tui][lists]") { + Harness harness{20, 2}; + auto const menu = harness.make(nullptr); + std::vector activated; + menu->setItems({"Open", "Quit"}); + menu->setOnActivate([&](std::size_t index) { activated.push_back(index); }); + CHECK(harness.draw() == Rows{"▶ Open", " Quit"}); + harness.focus(*menu); + static_cast(harness.key(KeyCode::Down)); + static_cast(harness.key(KeyCode::Enter)); + CHECK(activated == std::vector{1}); +} + +TEST_CASE("tui lists: tabs draw a bar over the pages the mount leaves visible; Right selects the next", + "[tui][lists]") { + Harness harness{20, 3}; + auto const tabs = harness.make(nullptr); + std::vector selected; + tabs->setTabs({"One", "Two"}); + tabs->setSelected(0); + tabs->setOnSelect([&](std::size_t index) { selected.push_back(index); }); + // As the mount does it: one page per tab shown so far, in first-shown order, every page but the selected one + // hidden through its own visible flag. + auto const first = harness.make(tabs.get()); + first->setText("first"); + auto const second = harness.make(tabs.get()); + second->setText("second"); + second->setVisible(false); + CHECK(harness.draw() == Rows{"[One] Two", "first"}); + harness.focus(*tabs); + static_cast(harness.key(KeyCode::Right)); + CHECK(selected == std::vector{1}); + // The bar moves at once; the pages stay as they are until the mount swaps them. + CHECK(harness.draw() == Rows{" One [Two]", "first"}); + first->setVisible(false); + second->setVisible(true); + CHECK(harness.draw() == Rows{" One [Two]", "second"}); + static_cast(harness.key(KeyCode::Right)); + CHECK(selected.size() == 1); +} + +TEST_CASE("tui lists: a tab's page is not picked by its position among the children", "[tui][lists]") { + Harness harness{20, 3}; + auto const tabs = harness.make(nullptr); + tabs->setTabs({"One", "Two"}); + // Tab Two was shown first, so its page is the first child; tab One's page arrived second and is selected. + auto const twoPage = harness.make(tabs.get()); + twoPage->setText("two"); + twoPage->setVisible(false); + auto const onePage = harness.make(tabs.get()); + onePage->setText("one"); + tabs->setSelected(0); + CHECK(harness.draw() == Rows{"[One] Two", "one"}); +} +``` + +Add `test_tui_lists.cpp` to `tests/tui/CMakeLists.txt`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/tui --target morph_tui_tests` +Expected: FAIL — `'tui/list_widgets.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `src/tui/list_widgets.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tui/widget.hpp" + +namespace morph::tui::detail { + +/// What a ListView asks the widget that owns it. +class ListOwner { +public: + ListOwner() = default; + virtual ~ListOwner() = default; + ListOwner(ListOwner const&) = delete; + ListOwner& operator=(ListOwner const&) = delete; + ListOwner(ListOwner&&) = delete; + ListOwner& operator=(ListOwner&&) = delete; + + /// A key the owner handles before the list does; nullopt lets the list have it. + [[nodiscard]] virtual std::optional<::core::tui::EventResult> listKey(::core::tui::KeyEvent const& key) = 0; +}; + +/// A core::tui::List that asks its owner first and leaves Esc to dialogs (List would report it as a cancel). +class ListView final : public Hosted<::core::tui::List> { +public: + ListView(WidgetBase& owner, ListOwner& lists) : Hosted{owner}, _lists{&lists} {} + [[nodiscard]] ::core::tui::EventResult onEvent(::core::tui::InputEvent const& event) override; + +private: + ListOwner* _lists; +}; + +/// Select, radio style: every option on its own row, `(•)` marking the selection; Space or Enter selects. +class RadioSelectImpl final : public TuiWidget, private ListOwner { +public: + explicit RadioSelectImpl(Context& context); + void setOptions(std::vector const& options) override; + void setSelected(std::optional const& selected) override; + void setOnSelect(std::function onSelect) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return true; } + [[nodiscard]] std::string probeText() const override; + void activate() override; + void click(::core::tui::Point cell) override; + +private: + [[nodiscard]] std::optional<::core::tui::EventResult> listKey(::core::tui::KeyEvent const& key) override; + void rebuild(); + void choose(std::size_t index); + + ListView* _list; + std::vector _options; + std::optional _selected; + std::function _onSelect; +}; + +/// Select, dropdown style: one row `[label ▾]`; Enter, Space or Down opens the options as a list overlay below it. +class DropdownSelectImpl final : public TuiWidget { +public: + explicit DropdownSelectImpl(Context& context); + ~DropdownSelectImpl() override; + DropdownSelectImpl(DropdownSelectImpl const&) = delete; + DropdownSelectImpl& operator=(DropdownSelectImpl const&) = delete; + DropdownSelectImpl(DropdownSelectImpl&&) = delete; + DropdownSelectImpl& operator=(DropdownSelectImpl&&) = delete; + + void setOptions(std::vector const& options) override; + void setSelected(std::optional const& selected) override; + void setOnSelect(std::function onSelect) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return true; } + [[nodiscard]] std::string probeText() const override; + void paint(::core::tui::Canvas& canvas) override; + [[nodiscard]] ::core::tui::EventResult key(::core::tui::KeyEvent const& key) override; + void activate() override; + [[nodiscard]] bool isOpen() const noexcept { return _open; } + +private: + class Popup; + void open(); + void close(); + void choose(std::size_t index); + + std::vector _options; + std::optional _selected; + std::function _onSelect; + std::unique_ptr _popup; + bool _open = false; +}; + +/// Menu: one list of item labels; Enter or Space activates the highlighted item. +class MenuImpl final : public TuiWidget, private ListOwner { +public: + explicit MenuImpl(Context& context); + void setItems(std::vector const& items) override; + void setOnActivate(std::function onActivate) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return true; } + [[nodiscard]] std::string probeText() const override; + void activate() override; + void click(::core::tui::Point cell) override; + +private: + [[nodiscard]] std::optional<::core::tui::EventResult> listKey(::core::tui::KeyEvent const& key) override; + + ListView* _list; + std::vector _labels; + std::function _onActivate; +}; + +/// Tabs: a bar (Left/Right, or a click, selects) above the body. The children are the page slots the mount makes, +/// in the order the tabs were first shown; they are stacked, and the mount hides every page but the selected one +/// through its own visible flag, so `setSelected` moves the bar's highlight only. +class TabsImpl final : public TuiContainer { +public: + explicit TabsImpl(Context& context); + void setTabs(std::vector const& tabs) override; + void setSelected(std::size_t index) override; + void setOnSelect(std::function onSelect) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return true; } + [[nodiscard]] std::string probeText() const override; + void paint(::core::tui::Canvas& canvas) override; + [[nodiscard]] ::core::tui::EventResult key(::core::tui::KeyEvent const& key) override; + void click(::core::tui::Point cell) override; + +private: + [[nodiscard]] std::string labelText(std::size_t index) const; + void select(std::size_t index); + + std::vector _labels; + std::size_t _selected = 0; + std::function _onSelect; +}; + +} // namespace morph::tui::detail +``` + +Create `src/tui/list_widgets.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "tui/list_widgets.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace morph::tui::detail { + +using ::core::tui::EventResult; +using ::core::tui::KeyCode; + +namespace { + +std::optional indexOf(std::vector const& options, std::optional const& key) { + if (!key) { + return std::nullopt; + } + auto const found = std::ranges::find_if(options, [&key](ui::SelectOption const& option) { return option.key == *key; }); + if (found == options.end()) { + return std::nullopt; + } + return static_cast(std::distance(options.begin(), found)); +} + +std::string labelOf(std::vector const& options, std::optional const& key) { + auto const index = indexOf(options, key); + return index ? options.at(*index).label : std::string{}; +} + +int widestLabel(std::vector const& options) { + int widest = 0; + for (auto const& option : options) { + widest = std::max(widest, displayWidth(option.label)); + } + return widest; +} + +bool isPlain(::core::tui::KeyEvent const& key) noexcept { + return ::core::tui::withoutLockKeys(key.modifiers) == ::core::tui::Modifier::None; +} + +} // namespace + +EventResult ListView::onEvent(::core::tui::InputEvent const& event) { + if (auto const* mouse = std::get_if<::core::tui::MouseEvent>(&event)) { + return owner().pointer(*mouse); + } + if (!owner().enabled()) { + return EventResult::Ignored; + } + if (auto const* key = std::get_if<::core::tui::KeyEvent>(&event)) { + if (auto const handled = _lists->listKey(*key)) { + return *handled; + } + if (key->key == KeyCode::Escape) { + return EventResult::Ignored; + } + } + return List::onEvent(event); +} + +RadioSelectImpl::RadioSelectImpl(Context& context) + : TuiWidget{context}, _list{&adopt(std::make_unique(*this, static_cast(*this)))} {} + +void RadioSelectImpl::setOptions(std::vector const& options) { + _options = options; + rebuild(); +} + +void RadioSelectImpl::setSelected(std::optional const& selected) { + _selected = selected; + rebuild(); + if (auto const index = indexOf(_options, _selected)) { + _list->setSelectedIndex(*index); + } +} + +void RadioSelectImpl::setOnSelect(std::function onSelect) { + _onSelect = std::move(onSelect); +} + +::core::tui::Size RadioSelectImpl::naturalSize() const { + // "▶ " or " ", then "(•) " or "( ) ", then the label. + return {.width = widestLabel(_options) + 6, .height = std::max(1, static_cast(_options.size()))}; +} + +std::string RadioSelectImpl::probeText() const { + return labelOf(_options, _selected); +} + +void RadioSelectImpl::activate() { + choose(_list->selectedIndex()); +} + +void RadioSelectImpl::click(::core::tui::Point cell) { + if (cell.y >= 0 && static_cast(cell.y) < _options.size()) { + _list->setSelectedIndex(static_cast(cell.y)); + activate(); + } +} + +std::optional RadioSelectImpl::listKey(::core::tui::KeyEvent const& key) { + if (!isActivation(key)) { + return std::nullopt; + } + activate(); + return EventResult::Handled; +} + +// core::tui::List::setItems puts the highlight back on the first row; the row the user was on is kept. +void RadioSelectImpl::rebuild() { + std::vector<::core::tui::ListItem> items; + for (auto const& option : _options) { + items.push_back(::core::tui::ListItem{.label = (_selected == option.key ? "(•) " : "( ) ") + option.label}); + } + auto const highlight = _list->selectedIndex(); + _list->setItems(std::move(items)); + if (!_options.empty()) { + _list->setSelectedIndex(std::min(highlight, _options.size() - 1)); + } + refresh(); +} + +void RadioSelectImpl::choose(std::size_t index) { + if (index >= _options.size()) { + return; + } + auto const picked = _options.at(index).key; + _selected = picked; + rebuild(); + if (_onSelect) { + _onSelect(picked); + } +} + +/// The dropdown's option list, shown as an overlay. It has no parent, so keys stop here. +class DropdownSelectImpl::Popup final : public ::core::tui::List { +public: + explicit Popup(DropdownSelectImpl& owner) : _owner{&owner} {} + + [[nodiscard]] EventResult onEvent(::core::tui::InputEvent const& event) override { + if (std::holds_alternative<::core::tui::MouseEvent>(event)) { + return EventResult::Handled; + } + if (auto const* key = std::get_if<::core::tui::KeyEvent>(&event)) { + if (isActivation(*key)) { + _owner->choose(selectedIndex()); + return EventResult::Handled; + } + if (key->key == KeyCode::Escape) { + _owner->close(); + return EventResult::Handled; + } + if (key->key == KeyCode::Tab) { + _owner->close(); + return EventResult::Ignored; + } + } + return List::onEvent(event); + } + +private: + DropdownSelectImpl* _owner; +}; + +DropdownSelectImpl::DropdownSelectImpl(Context& context) : TuiWidget{context}, _popup{std::make_unique(*this)} { + adopt(std::make_unique(*this)); +} + +// NOLINTNEXTLINE(bugprone-exception-escape): closing hands focus back and hides the overlay; neither may be skipped. +DropdownSelectImpl::~DropdownSelectImpl() { + close(); +} + +void DropdownSelectImpl::setOptions(std::vector const& options) { + _options = options; + refresh(); +} + +void DropdownSelectImpl::setSelected(std::optional const& selected) { + _selected = selected; + refresh(); +} + +void DropdownSelectImpl::setOnSelect(std::function onSelect) { + _onSelect = std::move(onSelect); +} + +::core::tui::Size DropdownSelectImpl::naturalSize() const { + return {.width = widestLabel(_options) + 4, .height = 1}; +} + +std::string DropdownSelectImpl::probeText() const { + return labelOf(_options, _selected); +} + +void DropdownSelectImpl::paint(::core::tui::Canvas& canvas) { + auto const& theme = canvas.theme(); + auto const& style = !enabled() ? theme.buttonDisabled : hasFocus() ? theme.buttonFocused : theme.buttonNormal; + canvas.putString(0, 0, "[" + labelOf(_options, _selected) + " ▾]", style); +} + +EventResult DropdownSelectImpl::key(::core::tui::KeyEvent const& key) { + if (isActivation(key) || (isPlain(key) && key.key == KeyCode::Down)) { + open(); + return EventResult::Handled; + } + return EventResult::Ignored; +} + +void DropdownSelectImpl::activate() { + open(); +} + +void DropdownSelectImpl::open() { + if (_open || _options.empty() || !enabled()) { + return; + } + std::vector<::core::tui::ListItem> items; + for (auto const& option : _options) { + items.push_back(::core::tui::ListItem{.label = option.label}); + } + _popup->setItems(std::move(items)); + if (auto const index = indexOf(_options, _selected)) { + _popup->setSelectedIndex(*index); + } + auto& screen = *context().screen; + auto const bounds = view().screenBounds(); + screen.showOverlay(*_popup, ::core::tui::Point{.x = bounds.x, .y = bounds.y + 1}); + screen.setFocus(_popup.get()); + _open = true; + refresh(); +} + +void DropdownSelectImpl::close() { + if (!_open) { + return; + } + auto& screen = *context().screen; + bool const hadFocus = screen.focusedComponent() == _popup.get(); + screen.hideOverlay(*_popup); + _open = false; + if (hadFocus) { + screen.setFocus(&view()); + } + refresh(); +} + +// Optimistic, like the other choosers: the field shows the choice at once. +void DropdownSelectImpl::choose(std::size_t index) { + if (index >= _options.size()) { + close(); + return; + } + auto const picked = _options.at(index).key; + _selected = picked; + close(); + if (_onSelect) { + _onSelect(picked); + } +} + +MenuImpl::MenuImpl(Context& context) : TuiWidget{context}, _list{&adopt(std::make_unique(*this, static_cast(*this)))} {} + +void MenuImpl::setItems(std::vector const& items) { + _labels = items; + std::vector<::core::tui::ListItem> rows; + for (auto const& label : _labels) { + rows.push_back(::core::tui::ListItem{.label = label}); + } + auto const highlight = _list->selectedIndex(); + _list->setItems(std::move(rows)); + if (!_labels.empty()) { + _list->setSelectedIndex(std::min(highlight, _labels.size() - 1)); + } + refresh(); +} + +void MenuImpl::setOnActivate(std::function onActivate) { + _onActivate = std::move(onActivate); +} + +::core::tui::Size MenuImpl::naturalSize() const { + int widest = 0; + for (auto const& label : _labels) { + widest = std::max(widest, displayWidth(label)); + } + return {.width = widest + 2, .height = std::max(1, static_cast(_labels.size()))}; +} + +std::string MenuImpl::probeText() const { + auto const index = _list->selectedIndex(); + return index < _labels.size() ? _labels.at(index) : std::string{}; +} + +void MenuImpl::activate() { + if (_onActivate && !_labels.empty()) { + _onActivate(_list->selectedIndex()); + } +} + +void MenuImpl::click(::core::tui::Point cell) { + if (cell.y >= 0 && static_cast(cell.y) < _labels.size()) { + _list->setSelectedIndex(static_cast(cell.y)); + activate(); + } +} + +std::optional MenuImpl::listKey(::core::tui::KeyEvent const& key) { + if (!isActivation(key)) { + return std::nullopt; + } + activate(); + return EventResult::Handled; +} + +TabsImpl::TabsImpl(Context& context) : TuiContainer{context} { + adopt(std::make_unique(*this)); +} + +void TabsImpl::setTabs(std::vector const& tabs) { + _labels = tabs; + refresh(); +} + +// The highlight only: which page shows is the mount's, through each page slot's own visible flag. A child's +// position says nothing about its tab, because the pages arrive in the order their tabs were first shown. +void TabsImpl::setSelected(std::size_t index) { + _selected = index; + refresh(); +} + +void TabsImpl::setOnSelect(std::function onSelect) { + _onSelect = std::move(onSelect); +} + +std::string TabsImpl::labelText(std::size_t index) const { + auto const& label = _labels.at(index); + return index == _selected ? "[" + label + "]" : " " + label + " "; +} + +::core::tui::Size TabsImpl::naturalSize() const { + int bar = 0; + for (std::size_t index = 0; index < _labels.size(); ++index) { + bar += displayWidth(labelText(index)); + } + auto const body = stackNaturalSize(children(), ui::Axis::Vertical, 0); + return {.width = std::max(bar, body.width), .height = 1 + body.height}; +} + +std::string TabsImpl::probeText() const { + return _selected < _labels.size() ? _labels.at(_selected) : std::string{}; +} + +void TabsImpl::paint(::core::tui::Canvas& canvas) { + auto const& theme = canvas.theme(); + int column = 0; + for (std::size_t index = 0; index < _labels.size(); ++index) { + auto const& style = index != _selected ? theme.textMuted : hasFocus() ? theme.buttonFocused : theme.textBold; + column += canvas.putString(0, column, labelText(index), style); + } + arrangeStack(children(), + ::core::tui::Rect{.x = 0, .y = 1, .width = canvas.width(), .height = std::max(0, canvas.height() - 1)}, + StackSpec{.axis = ui::Axis::Vertical}); +} + +EventResult TabsImpl::key(::core::tui::KeyEvent const& key) { + if (!isPlain(key)) { + return EventResult::Ignored; + } + if (key.key == KeyCode::Left) { + if (_selected > 0) { + select(_selected - 1); + } + return EventResult::Handled; + } + if (key.key == KeyCode::Right) { + if (_selected + 1 < _labels.size()) { + select(_selected + 1); + } + return EventResult::Handled; + } + return EventResult::Ignored; +} + +void TabsImpl::click(::core::tui::Point cell) { + if (cell.y != 0) { + return; + } + int right = 0; + for (std::size_t index = 0; index < _labels.size(); ++index) { + right += displayWidth(labelText(index)); + if (cell.x < right) { + select(index); + return; + } + } +} + +// Optimistic, like the other choosers: the bar shows the choice at once, and the mount swaps the pages. +void TabsImpl::select(std::size_t index) { + if (index >= _labels.size() || index == _selected) { + return; + } + _selected = index; + refresh(); + if (_onSelect) { + _onSelect(index); + } +} + +} // namespace morph::tui::detail +``` + +Add `src/tui/list_widgets.cpp` and `src/tui/list_widgets.hpp` to `add_library(morph_tui STATIC …)`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/tui --target morph_tui_tests && ./build/tui/tests/tui/morph_tui_tests "[lists]"` +Expected: PASS, 6 test cases. + +Mutation checks, one at a time, each restored before the next: +- In `ListView::onEvent`, delete the `KeyCode::Escape` test, so Esc reaches `List`; then in + `DropdownSelectImpl::Popup::onEvent` delete its `Escape` branch. Expected: FAIL in "Esc closes a dropdown without + choosing" (`isOpen()` stays true). +- In `TabsImpl::setSelected`, hide every child but the one at position `index` (`child->setStructuralVisible(position + == index)` over `children()`). Expected: FAIL in "a tab's page is not picked by its position among the children" + (the second row is empty: the page at position 0 is tab Two's, which the mount hid). + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/list_widgets.hpp src/tui/list_widgets.cpp tests/tui CMakeLists.txt +git commit -m "wip(tui): radio and dropdown select, menu and tabs + +Signed-off-by: Christian Parpart " +``` + +--- +### Task 12: Dialog with its focus trap, Busy and Slider + +**Files:** +- Modify: `src/tui/container_widgets.hpp`, `src/tui/container_widgets.cpp` — append `DialogImpl` +- Modify: `src/tui/leaf_widgets.hpp`, `src/tui/leaf_widgets.cpp` — append `BusyImpl`, `SliderImpl` +- Modify: `src/tui/widget.hpp`, `src/tui/widget.cpp` — `centredIn`, the dialog-aware `fit` and `moveFocus`, and + `WidgetBase::pointer`'s dialog check +- Modify: `tests/tui/CMakeLists.txt` — add `test_tui_dialog.cpp` +- Test: `tests/tui/test_tui_dialog.cpp` + +**Interfaces:** +- Consumes: `Screen::showOverlay`, `hideOverlay`, `positionOverlay`, `viewportArea`, `setFocus`, + `focusedComponent`; `ui::DialogWidget`, `ui::BusyWidget`, `ui::SliderWidget` (Part 2); `Context::openDialogs`, + `activeBusy`, `animationFrame` (Task 8). +- Produces: `DialogImpl(Context&)` (with `isOpen()`); `BusyImpl(Context&)`; `SliderImpl(Context&)`; + `centredIn(Screen const&, Size) -> Point`; `fit` now centres open dialogs; `moveFocus` now cycles inside the + innermost open dialog; a press outside the innermost open dialog is swallowed. + +- [ ] **Step 1: Write the failing test** + +Create `tests/tui/test_tui_dialog.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "tui/container_widgets.hpp" +#include "tui/leaf_widgets.hpp" +#include "tui_harness.hpp" + +namespace ui = morph::ui; +using core::tui::KeyCode; +using morph::tui::detail::BusyImpl; +using morph::tui::detail::ButtonImpl; +using morph::tui::detail::DialogImpl; +using morph::tui::detail::Direction; +using morph::tui::detail::moveFocus; +using morph::tui::detail::SliderImpl; +using morph::tui::detail::StackImpl; +using morph::tui::detail::TextImpl; +using morph::tui::testing::Harness; +using Rows = std::vector; + +namespace { + +/// A column with a "Behind" button, and a dialog titled "Confirm" holding the given buttons. +struct Scene { + explicit Scene(Harness& harness, std::vector const& labels) + : column{harness.make(nullptr, ui::Axis::Vertical)}, + behind{harness.make(column.get())}, + dialog{harness.make(column.get())}, + body{harness.make(dialog.get(), ui::Axis::Vertical)} { + behind->setLabel("Behind"); + dialog->setTitle("Confirm"); + for (auto const& label : labels) { + buttons.push_back(harness.make(body.get())); + buttons.back()->setLabel(label); + } + } + ~Scene() { buttons.clear(); } + Scene(Scene const&) = delete; + Scene& operator=(Scene const&) = delete; + Scene(Scene&&) = delete; + Scene& operator=(Scene&&) = delete; + + std::unique_ptr column; + std::unique_ptr behind; + std::unique_ptr dialog; + std::unique_ptr body; + std::vector> buttons; +}; + +} // namespace + +TEST_CASE("tui dialog: an open dialog is a centred box over the tree, and takes the focus", "[tui][dialog]") { + Harness harness{30, 10}; + Scene scene{harness, {}}; + auto const text = harness.make(scene.body.get()); + text->setText("Sure?"); + scene.buttons.push_back(harness.make(scene.body.get())); + scene.buttons.back()->setLabel("OK"); + scene.dialog->setOpen(true); + CHECK(harness.draw() == Rows{"[ Behind ]", "", "", " ┌─Confirm─┐", " │ Sure? │", + " │ [ OK ] │", " └─────────┘"}); + CHECK(harness.focused(*scene.buttons.back())); + scene.buttons.clear(); +} + +TEST_CASE("tui dialog: Esc anywhere inside calls onDismiss", "[tui][dialog]") { + Harness harness{30, 10}; + Scene scene{harness, {"OK"}}; + int dismissed = 0; + scene.dialog->setOnDismiss([&] { ++dismissed; }); + scene.dialog->setOpen(true); + static_cast(harness.key(KeyCode::Escape)); + CHECK(dismissed == 1); +} + +TEST_CASE("tui dialog: Tab stays inside the open dialog", "[tui][dialog]") { + Harness harness{30, 10}; + Scene scene{harness, {"OK", "Cancel"}}; + scene.dialog->setOpen(true); + static_cast(harness.draw()); + CHECK(harness.focused(*scene.buttons.at(0))); + moveFocus(harness.context(), Direction::Forward); + CHECK(harness.focused(*scene.buttons.at(1))); + moveFocus(harness.context(), Direction::Forward); + CHECK(harness.focused(*scene.buttons.at(0))); + moveFocus(harness.context(), Direction::Backward); + CHECK(harness.focused(*scene.buttons.at(1))); +} + +TEST_CASE("tui dialog: closing hands the focus back to where it was", "[tui][dialog]") { + Harness harness{30, 10}; + Scene scene{harness, {"OK"}}; + harness.focus(*scene.behind); + scene.dialog->setOpen(true); + CHECK(harness.focused(*scene.buttons.at(0))); + scene.dialog->setOpen(false); + CHECK(harness.focused(*scene.behind)); + CHECK(harness.draw() == Rows{"[ Behind ]"}); +} + +// The mount's order: a Dialog's content is destroyed before `setOpen(false)`, and destroying the focused button +// clears the screen's focus on the way. +TEST_CASE("tui dialog: closing after its content is gone still hands the focus back", "[tui][dialog]") { + Harness harness{30, 10}; + Scene scene{harness, {"OK"}}; + harness.focus(*scene.behind); + scene.dialog->setOpen(true); + REQUIRE(harness.focused(*scene.buttons.at(0))); + scene.buttons.clear(); + REQUIRE(harness.screen().focusedComponent() == nullptr); + scene.dialog->setOpen(false); + CHECK(harness.focused(*scene.behind)); +} + +TEST_CASE("tui dialog: a click behind an open dialog does nothing", "[tui][dialog]") { + Harness harness{30, 10}; + Scene scene{harness, {"OK"}}; + int behindClicks = 0; + scene.behind->setOnClick([&] { ++behindClicks; }); + scene.dialog->setOpen(true); + static_cast(harness.draw()); + static_cast(harness.click({.x = 2, .y = 0})); + CHECK(behindClicks == 0); +} + +TEST_CASE("tui busy: spins while active, one frame per animation step", "[tui][busy]") { + Harness harness{20, 1}; + auto const busy = harness.make(nullptr); + busy->setLabel("Loading"); + CHECK(harness.draw().empty()); + busy->setActive(true); + CHECK(harness.context().activeBusy == 1); + CHECK(harness.draw() == Rows{"| Loading"}); + ++harness.context().animationFrame; + CHECK(harness.draw() == Rows{"/ Loading"}); + busy->setActive(false); + CHECK(harness.context().activeBusy == 0); +} + +TEST_CASE("tui slider: Left and Right move by step, Home and End to the ends", "[tui][slider]") { + Harness harness{24, 1}; + auto const slider = harness.make(nullptr); + std::vector changes; + slider->setRange(0, 100, 10); + slider->setValue(50); + slider->setOnChange([&](std::int64_t value) { changes.push_back(value); }); + CHECK(harness.draw() == Rows{"[=========|---------] 50"}); + harness.focus(*slider); + static_cast(harness.key(KeyCode::Right)); + CHECK(changes == std::vector{60}); + CHECK(harness.draw() == Rows{"[==========|--------] 60"}); + static_cast(harness.key(KeyCode::Home)); + CHECK(harness.draw() == Rows{"[|-------------------] 0"}); + static_cast(harness.key(KeyCode::Left)); + CHECK(changes == std::vector{60, 0}); +} +``` + +Add `test_tui_dialog.cpp` to `tests/tui/CMakeLists.txt`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/tui --target morph_tui_tests` +Expected: FAIL — `no type named 'DialogImpl' in namespace 'morph::tui::detail'`. + +- [ ] **Step 3: Make focus and fitting dialog-aware** + +In `src/tui/widget.hpp`, add after `isWithin`: + +```cpp +/// The top-left corner that centres an overlay of @p size on @p screen's viewport. +[[nodiscard]] ::core::tui::Point centredIn(::core::tui::Screen const& screen, ::core::tui::Size size); +``` + +and change the `fit` and `moveFocus` comments to "Sizes every root widget to the viewport and centres every open +dialog." and "Moves keyboard focus to the next or previous focusable component — inside the innermost open dialog +when one is open, because `Screen`'s own walk does not know overlays." Add +`[[nodiscard]] bool blockedByDialog() const;` +to `WidgetBase`'s private section. + +In `src/tui/widget.cpp`, replace `fit` and `moveFocus` with: + +```cpp +::core::tui::Point centredIn(::core::tui::Screen const& screen, ::core::tui::Size size) { + auto const area = screen.viewportArea(); + return {.x = std::max(0, (area.width - size.width) / 2), .y = std::max(0, (area.height - size.height) / 2)}; +} + +void fit(Context& context) { + auto& screen = *context.screen; + auto const area = screen.viewportArea(); + for (auto* root : context.roots) { + root->view().setArea(area); + } + for (auto* frame : context.openDialogs) { + screen.positionOverlay(*frame, centredIn(screen, frame->preferredSize())); + } +} + +void moveFocus(Context& context, Direction direction) { + auto& screen = *context.screen; + if (context.openDialogs.empty()) { + if (direction == Direction::Forward) { + screen.focusNext(); + } else { + screen.focusPrev(); + } + return; + } + auto& frame = *context.openDialogs.back(); + std::vector<::core::tui::Component*> candidates; + collectFocusable(frame, candidates); + if (candidates.empty()) { + screen.setFocus(&frame); + return; + } + auto const count = static_cast(candidates.size()); + auto const current = std::ranges::find(candidates, screen.focusedComponent()); + std::ptrdiff_t index = direction == Direction::Forward ? 0 : count - 1; + if (current != candidates.end()) { + auto const position = std::distance(candidates.begin(), current); + index = direction == Direction::Forward ? (position + 1) % count : (position + count - 1) % count; + } + screen.setFocus(*std::next(candidates.begin(), index)); +} +``` + +Add, after `WidgetBase::refresh`: + +```cpp +bool WidgetBase::blockedByDialog() const { + return !_context->openDialogs.empty() && !isWithin(_view.get(), *_context->openDialogs.back()); +} +``` + +and make it the first statement of `WidgetBase::pointer`: + +```cpp + if (blockedByDialog()) { + return EventResult::Handled; + } +``` + +- [ ] **Step 4: Implement Dialog, Busy and Slider** + +Append to `src/tui/container_widgets.hpp` (inside the namespace; add `#include ` and +`#include `): + +```cpp +/// Dialog: an overlay box centred on the screen, holding the dialog's children. Opening it moves the focus inside +/// and Tab then cycles there; Esc calls onDismiss; closing hands the focus back. Its place in the tree is an empty +/// anchor. +class DialogImpl final : public TuiContainer { +public: + explicit DialogImpl(Context& context); + ~DialogImpl() override; + DialogImpl(DialogImpl const&) = delete; + DialogImpl& operator=(DialogImpl const&) = delete; + DialogImpl(DialogImpl&&) = delete; + DialogImpl& operator=(DialogImpl&&) = delete; + + void setOpen(bool open) override; + void setTitle(std::string_view title) override; + void setOnDismiss(ui::Action onDismiss) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override { return {.width = 0, .height = 0}; } + [[nodiscard]] std::string probeText() const override { return _title; } + [[nodiscard]] ::core::tui::Component& host() override; + [[nodiscard]] bool isOpen() const noexcept { return _open; } + +private: + class Frame; + [[nodiscard]] ::core::tui::Size frameSize() const; + void paintFrame(::core::tui::Canvas& canvas); + [[nodiscard]] ::core::tui::EventResult frameEvent(::core::tui::InputEvent const& event); + void open(); + void close(); + + std::unique_ptr _frame; + std::string _title; + ui::Action _onDismiss; + bool _open = false; + ::core::tui::Component* _restoreFocus = nullptr; +}; +``` + +Append to `src/tui/container_widgets.cpp` (inside the namespace; add `#include ` and +`#include `): + +```cpp +/// The dialog's overlay: it paints the box and owns the children's views. Focusable, so Esc reaches it even when +/// the dialog holds nothing focusable. +class DialogImpl::Frame final : public ::core::tui::Component { +public: + explicit Frame(DialogImpl& owner) : _owner{&owner} {} + void render(::core::tui::Canvas& canvas) override { _owner->paintFrame(canvas); } + [[nodiscard]] ::core::tui::Size preferredSize() const override { return _owner->frameSize(); } + [[nodiscard]] ::core::tui::EventResult onEvent(::core::tui::InputEvent const& event) override { + return _owner->frameEvent(event); + } + [[nodiscard]] bool focusable() const override { return true; } + +private: + DialogImpl* _owner; +}; + +DialogImpl::DialogImpl(Context& context) : TuiContainer{context}, _frame{std::make_unique(*this)} { + adopt(std::make_unique(*this)); +} + +// NOLINTNEXTLINE(bugprone-exception-escape): closing hides the overlay and hands focus back; neither may be skipped. +DialogImpl::~DialogImpl() { + close(); +} + +void DialogImpl::setOpen(bool open) { + if (open) { + this->open(); + } else { + close(); + } +} + +void DialogImpl::setTitle(std::string_view title) { + _title = std::string{title}; + refresh(); +} + +void DialogImpl::setOnDismiss(ui::Action onDismiss) { + _onDismiss = std::move(onDismiss); +} + +::core::tui::Component& DialogImpl::host() { + return *_frame; +} + +::core::tui::Size DialogImpl::frameSize() const { + auto const inner = stackNaturalSize(children(), ui::Axis::Vertical, 0); + auto const area = context().screen->viewportArea(); + int const width = std::max(inner.width + 4, displayWidth(_title) + 4); + return {.width = std::min(width, area.width), .height = std::min(inner.height + 2, area.height)}; +} + +void DialogImpl::paintFrame(::core::tui::Canvas& canvas) { + auto const& theme = canvas.theme(); + canvas.fill(canvas.area(), ' ', theme.dialogBackground); + canvas.drawBox(canvas.area(), ::core::tui::BorderStyle::Single, theme.dialogBorder, _title); + arrangeStack(children(), + ::core::tui::Rect{.x = 2, .y = 1, .width = std::max(0, canvas.width() - 4), + .height = std::max(0, canvas.height() - 2)}, + StackSpec{.axis = ui::Axis::Vertical}); +} + +::core::tui::EventResult DialogImpl::frameEvent(::core::tui::InputEvent const& event) { + if (auto const* key = std::get_if<::core::tui::KeyEvent>(&event); key != nullptr && key->key == ::core::tui::KeyCode::Escape) { + if (_onDismiss) { + _onDismiss(); + } + return ::core::tui::EventResult::Handled; + } + // A press on the box's own blank cells goes nowhere, and in particular not to the tree behind it. + if (std::holds_alternative<::core::tui::MouseEvent>(event)) { + return ::core::tui::EventResult::Handled; + } + return ::core::tui::EventResult::Ignored; +} + +void DialogImpl::open() { + if (_open) { + return; + } + auto& screen = *context().screen; + _restoreFocus = screen.focusedComponent(); + _open = true; + context().openDialogs.push_back(_frame.get()); + screen.showOverlay(*_frame, centredIn(screen, frameSize())); + std::vector<::core::tui::Component*> focusable; + collectFocusable(*_frame, focusable); + screen.setFocus(focusable.empty() ? static_cast<::core::tui::Component*>(_frame.get()) : focusable.front()); + refresh(); +} + +// The component focused before opening gets the focus back only while a widget still owns it: the tree behind +// may have been remounted in the meantime. No focus at all counts as focus inside: the mount destroys a dialog's +// content before it closes the dialog, and the focused content cleared the focus as it went. +void DialogImpl::close() { + if (!_open) { + return; + } + auto& screen = *context().screen; + auto const* const focused = screen.focusedComponent(); + bool const focusInside = focused == nullptr || isWithin(focused, *_frame); + screen.hideOverlay(*_frame); + std::erase(context().openDialogs, _frame.get()); + _open = false; + if (focusInside) { + screen.setFocus(context().ownerOf(_restoreFocus) != nullptr ? _restoreFocus : nullptr); + } + _restoreFocus = nullptr; + refresh(); +} +``` + +Append to `src/tui/leaf_widgets.hpp` (inside the namespace; add `#include `): + +```cpp +/// Busy: a spinner and a label while active, nothing otherwise. The frontend advances the frame on a timer while +/// any Busy is active. +class BusyImpl final : public TuiWidget { +public: + explicit BusyImpl(Context& context); + ~BusyImpl() override; + BusyImpl(BusyImpl const&) = delete; + BusyImpl& operator=(BusyImpl const&) = delete; + BusyImpl(BusyImpl&&) = delete; + BusyImpl& operator=(BusyImpl&&) = delete; + + void setActive(bool active) override; + void setLabel(std::string_view label) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] std::string probeText() const override { return _label; } + void paint(::core::tui::Canvas& canvas) override; + +private: + bool _active = false; + std::string _label; +}; + +/// Slider: `[====|----] value`; Left/Right move by step, Home/End to the ends. +class SliderImpl final : public TuiWidget { +public: + explicit SliderImpl(Context& context); + void setRange(std::int64_t minimum, std::int64_t maximum, std::int64_t step) override; + void setValue(std::int64_t value) override; + void setOnChange(std::function onChange) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return true; } + [[nodiscard]] std::string probeText() const override; + void paint(::core::tui::Canvas& canvas) override; + [[nodiscard]] ::core::tui::EventResult key(::core::tui::KeyEvent const& key) override; + +private: + void moveTo(std::int64_t value); + + std::int64_t _minimum = 0; + std::int64_t _maximum = 100; + std::int64_t _step = 1; + std::int64_t _value = 0; + std::function _onChange; +}; +``` + +Append to `src/tui/leaf_widgets.cpp` (inside the namespace; add `#include `, `#include `, +`#include ` and `#include `): + +```cpp +namespace { + +// ASCII frames: every terminal draws them, and a frame is one cell wide. +constexpr std::array kSpinner{"|", "/", "-", "\\"}; + +} // namespace + +BusyImpl::BusyImpl(Context& context) : TuiWidget{context} { + adopt(std::make_unique(*this)); +} + +BusyImpl::~BusyImpl() { + if (_active) { + --context().activeBusy; + } +} + +void BusyImpl::setActive(bool active) { + if (active == _active) { + return; + } + _active = active; + if (active) { + ++context().activeBusy; + } else { + --context().activeBusy; + } + refresh(); +} + +void BusyImpl::setLabel(std::string_view label) { + _label = std::string{label}; + refresh(); +} + +::core::tui::Size BusyImpl::naturalSize() const { + if (!_active) { + return {.width = 0, .height = 0}; + } + return {.width = 2 + displayWidth(_label), .height = 1}; +} + +void BusyImpl::paint(::core::tui::Canvas& canvas) { + if (!_active) { + return; + } + auto const frame = kSpinner.at(context().animationFrame % kSpinner.size()); + canvas.putString(0, 0, std::string{frame} + " " + _label, canvas.theme().textAccent); +} + +SliderImpl::SliderImpl(Context& context) : TuiWidget{context} { + adopt(std::make_unique(*this)); +} + +void SliderImpl::setRange(std::int64_t minimum, std::int64_t maximum, std::int64_t step) { + _minimum = minimum; + _maximum = std::max(minimum, maximum); + _step = std::max(1, step); + _value = std::clamp(_value, _minimum, _maximum); + refresh(); +} + +void SliderImpl::setValue(std::int64_t value) { + _value = std::clamp(value, _minimum, _maximum); + refresh(); +} + +void SliderImpl::setOnChange(std::function onChange) { + _onChange = std::move(onChange); +} + +::core::tui::Size SliderImpl::naturalSize() const { + return {.width = 24, .height = 1}; +} + +std::string SliderImpl::probeText() const { + return std::to_string(_value); +} + +void SliderImpl::paint(::core::tui::Canvas& canvas) { + auto const label = std::to_string(_value); + int const track = std::max(3, canvas.width() - static_cast(label.size()) - 3); + auto const span = _maximum - _minimum; + int const knob = span > 0 ? static_cast((_value - _minimum) * (track - 1) / span) : 0; + std::string bar = "["; + for (int cell = 0; cell < track; ++cell) { + bar += cell < knob ? "=" : cell == knob ? "|" : "-"; + } + bar += "] " + label; + auto const& theme = canvas.theme(); + canvas.putString(0, 0, bar, hasFocus() ? theme.buttonFocused : theme.textNormal); +} + +::core::tui::EventResult SliderImpl::key(::core::tui::KeyEvent const& key) { + if (::core::tui::withoutLockKeys(key.modifiers) != ::core::tui::Modifier::None) { + return ::core::tui::EventResult::Ignored; + } + if (key.key == ::core::tui::KeyCode::Left) { + moveTo(_value - _step); + } else if (key.key == ::core::tui::KeyCode::Right) { + moveTo(_value + _step); + } else if (key.key == ::core::tui::KeyCode::Home) { + moveTo(_minimum); + } else if (key.key == ::core::tui::KeyCode::End) { + moveTo(_maximum); + } else { + return ::core::tui::EventResult::Ignored; + } + return ::core::tui::EventResult::Handled; +} + +void SliderImpl::moveTo(std::int64_t value) { + auto const next = std::clamp(value, _minimum, _maximum); + if (next == _value) { + return; + } + _value = next; + refresh(); + if (_onChange) { + _onChange(next); + } +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/tui --target morph_tui_tests && ./build/tui/tests/tui/morph_tui_tests "[dialog],[busy],[slider]" +``` +Expected: PASS, 8 test cases; and `"[tui]"` as a whole still passes. + +Mutation checks, one at a time, each restored before the next: +- In `moveFocus`, delete the `if (context.openDialogs.empty())` test so the screen's walk always runs. Expected: + FAIL in "Tab stays inside the open dialog" (the focus lands on "Behind", which the screen's walk reaches and the + dialog's buttons are not part of). +- In `DialogImpl::close`, drop `focused == nullptr ||`. Expected: FAIL in "closing after its content is gone still + hands the focus back" (nothing is focused). + +- [ ] **Step 6: Commit** + +```bash +git add src/tui tests/tui +git commit -m "wip(tui): dialog with a focus trap, busy spinner and slider + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 13: Table + +**Files:** +- Create: `src/tui/table_widget.hpp`, `src/tui/table_widget.cpp` +- Modify: `CMakeLists.txt` — add both to `add_library(morph_tui STATIC …)` +- Modify: `tests/tui/CMakeLists.txt` — add `test_tui_table.cpp` +- Test: `tests/tui/test_tui_table.cpp` + +**Interfaces:** +- Consumes: `ui::TableWidget`, `ui::TableColumn{label, width}`, `ui::SelectionMode` (Part 2); `StackImpl:: + setColumnLayout` (Task 8); `layout::distribute` (Task 7). +- Produces: `TableImpl(Context&)`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/tui/test_tui_table.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "tui/container_widgets.hpp" +#include "tui/leaf_widgets.hpp" +#include "tui/table_widget.hpp" +#include "tui_harness.hpp" + +namespace ui = morph::ui; +using core::tui::KeyCode; +using morph::tui::detail::StackImpl; +using morph::tui::detail::TableImpl; +using morph::tui::detail::TextImpl; +using morph::tui::testing::Harness; +using Rows = std::vector; + +namespace { + +ui::Key key(std::int64_t value) { + return ui::Key{value}; +} + +/// A table of fruit: Name (content width) and Qty (fixed 5), rows keyed 1 and 2. +struct Fruit { + explicit Fruit(Harness& harness, ui::SelectionMode mode) : table{harness.make(nullptr)} { + table->setColumns({{.label = "Name", .width = ui::Sizing::content()}, {.label = "Qty", .width = ui::Sizing::fixed(5)}}); + table->setSelectionMode(mode); + addRow(harness, key(1), "apple", "3"); + addRow(harness, key(2), "kiwi", "12"); + } + ~Fruit() { + cells.clear(); + rows.clear(); + } + Fruit(Fruit const&) = delete; + Fruit& operator=(Fruit const&) = delete; + Fruit(Fruit&&) = delete; + Fruit& operator=(Fruit&&) = delete; + + void addRow(Harness& harness, ui::Key const& rowKey, std::string const& name, std::string const& quantity) { + // As the mount builds a row: a horizontal stack, its cells, then the row's key. + rows.push_back(harness.make(table.get(), ui::Axis::Horizontal)); + for (auto const& text : {name, quantity}) { + cells.push_back(harness.make(rows.back().get())); + cells.back()->setText(text); + } + table->setRowKey(*rows.back(), rowKey); + } + + std::unique_ptr table; + std::vector> rows; + std::vector> cells; +}; + +} // namespace + +TEST_CASE("tui table: a header row above keyed rows, in solved column widths", "[tui][table]") { + Harness harness{30, 4}; + Fruit const fruit{harness, ui::SelectionMode::None}; + CHECK(harness.draw() == Rows{" Name Qty", " apple 3", " kiwi 12"}); +} + +TEST_CASE("tui table: Space toggles rows in Multiple mode, Enter activates", "[tui][table]") { + Harness harness{30, 4}; + Fruit fruit{harness, ui::SelectionMode::Multiple}; + std::vector> selections; + std::vector activated; + fruit.table->setOnSelectionChange([&](std::vector keys) { selections.push_back(std::move(keys)); }); + fruit.table->setOnActivate([&](ui::Key activatedKey) { activated.push_back(std::move(activatedKey)); }); + harness.focus(*fruit.table); + CHECK(harness.draw().at(1) == " >apple 3"); + static_cast(harness.type(" ")); + CHECK(selections.back() == std::vector{key(1)}); + CHECK(harness.draw().at(1) == "*>apple 3"); + static_cast(harness.key(KeyCode::Down)); + static_cast(harness.type(" ")); + CHECK(selections.back() == std::vector{key(1), key(2)}); + static_cast(harness.key(KeyCode::Enter)); + CHECK(activated == std::vector{key(2)}); + CHECK(selections.size() == 2); +} + +TEST_CASE("tui table: Single mode selects one row; setSelection marks rows without reporting", "[tui][table]") { + Harness harness{30, 4}; + Fruit fruit{harness, ui::SelectionMode::Single}; + std::vector> selections; + fruit.table->setOnSelectionChange([&](std::vector keys) { selections.push_back(std::move(keys)); }); + fruit.table->setSelection({key(2)}); + CHECK(harness.draw() == Rows{" Name Qty", " apple 3", "* kiwi 12"}); + CHECK(selections.empty()); + harness.focus(*fruit.table); + static_cast(harness.key(KeyCode::Enter)); + CHECK(selections.back() == std::vector{key(1)}); +} + +TEST_CASE("tui table: moving a row moves its key with it", "[tui][table]") { + Harness harness{30, 4}; + Fruit fruit{harness, ui::SelectionMode::Single}; + std::vector activated; + fruit.table->setOnActivate([&](ui::Key activatedKey) { activated.push_back(std::move(activatedKey)); }); + fruit.table->moveChild(*fruit.rows.at(1), 0); + CHECK(harness.draw() == Rows{" Name Qty", " kiwi 12", " apple 3"}); + harness.focus(*fruit.table); + static_cast(harness.key(KeyCode::Enter)); + CHECK(activated == std::vector{key(2)}); +} +``` + +Add `test_tui_table.cpp` to `tests/tui/CMakeLists.txt`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/tui --target morph_tui_tests` +Expected: FAIL — `'tui/table_widget.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `src/tui/table_widget.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include + +#include "tui/widget.hpp" + +namespace morph::tui::detail { + +/// Table: a header row above keyed rows. Each row is a horizontal stack of cells laid out in the column widths the +/// solver gives the table, one cell gap apart. A two-cell gutter marks selected rows `*` and, while the table has +/// focus, the cursor row `>`. Up/Down move the cursor; Space selects (Single) or toggles (Multiple); Enter selects +/// in Single mode and activates the cursor row. +class TableImpl final : public TuiContainer { +public: + explicit TableImpl(Context& context); + void setColumns(std::vector const& columns) override; + void setSelectionMode(ui::SelectionMode mode) override; + void setRowKey(ui::Widget& row, ui::Key const& key) override; + void setSelection(std::vector const& selection) override; + void setOnSelectionChange(std::function)> onSelectionChange) override; + void setOnActivate(std::function onActivate) override; + [[nodiscard]] ::core::tui::Size naturalSize() const override; + [[nodiscard]] bool wantsFocus() const override { return true; } + void paint(::core::tui::Canvas& canvas) override; + [[nodiscard]] ::core::tui::EventResult key(::core::tui::KeyEvent const& key) override; + void click(::core::tui::Point cell) override; + +protected: + void childForgotten(WidgetBase& child) override; + +private: + [[nodiscard]] std::vector naturalWidths() const; + [[nodiscard]] std::optional keyAt(std::size_t row) const; + [[nodiscard]] bool isSelected(ui::Key const& key) const; + void selectOnly(std::size_t row); + void toggle(std::size_t row); + void activateRow(std::size_t row); + void report(); + + std::vector _columns; + ui::SelectionMode _mode = ui::SelectionMode::None; + std::unordered_map _keys; + std::vector _selection; + std::size_t _cursor = 0; + std::function)> _onSelectionChange; + std::function _onActivate; +}; + +} // namespace morph::tui::detail +``` + +Create `src/tui/table_widget.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "tui/table_widget.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tui/container_widgets.hpp" +#include "tui/layout.hpp" + +namespace morph::tui::detail { + +using ::core::tui::EventResult; +using ::core::tui::KeyCode; + +namespace { + +constexpr int kGutter = 2; +constexpr int kColumnGap = 1; + +} // namespace + +TableImpl::TableImpl(Context& context) : TuiContainer{context} { + adopt(std::make_unique(*this)); +} + +void TableImpl::setColumns(std::vector const& columns) { + _columns = columns; + refresh(); +} + +void TableImpl::setSelectionMode(ui::SelectionMode mode) { + _mode = mode; + refresh(); +} + +void TableImpl::setRowKey(ui::Widget& row, ui::Key const& key) { + _keys.insert_or_assign(&WidgetBase::of(row), key); + refresh(); +} + +void TableImpl::setSelection(std::vector const& selection) { + _selection = selection; + refresh(); +} + +void TableImpl::setOnSelectionChange(std::function)> onSelectionChange) { + _onSelectionChange = std::move(onSelectionChange); +} + +void TableImpl::setOnActivate(std::function onActivate) { + _onActivate = std::move(onActivate); +} + +void TableImpl::childForgotten(WidgetBase& child) { + _keys.erase(&child); + auto const count = shownChildren().size(); + _cursor = count == 0 ? 0 : std::min(_cursor, count - 1); +} + +// Each column is as wide as its label or its widest cell. +std::vector TableImpl::naturalWidths() const { + std::vector widths; + auto const rows = shownChildren(); + for (std::size_t column = 0; column < _columns.size(); ++column) { + int width = displayWidth(_columns.at(column).label); + for (auto const* row : rows) { + auto const* const stack = dynamic_cast(row); + if (stack == nullptr) { + continue; + } + auto const cells = stack->shownChildren(); + if (column < cells.size()) { + width = std::max(width, cells.at(column)->naturalSize().width); + } + } + widths.push_back(width); + } + return widths; +} + +::core::tui::Size TableImpl::naturalSize() const { + auto const widths = naturalWidths(); + int const width = kGutter + std::accumulate(widths.begin(), widths.end(), 0) + + (kColumnGap * std::max(0, static_cast(widths.size()) - 1)); + int height = 1; + for (auto const* row : shownChildren()) { + height += std::max(1, row->naturalSize().height); + } + return {.width = width, .height = height}; +} + +std::optional TableImpl::keyAt(std::size_t row) const { + auto const rows = shownChildren(); + if (row >= rows.size()) { + return std::nullopt; + } + auto const found = _keys.find(rows.at(row)); + return found == _keys.end() ? std::nullopt : std::optional{found->second}; +} + +bool TableImpl::isSelected(ui::Key const& key) const { + return std::ranges::find(_selection, key) != _selection.end(); +} + +void TableImpl::paint(::core::tui::Canvas& canvas) { + auto const& theme = canvas.theme(); + auto const natural = naturalWidths(); + std::vector items; + auto width = natural.begin(); + for (auto const& column : _columns) { + items.push_back(layout::Item{.sizing = column.width, .natural = *width}); + ++width; + } + auto const widths = + layout::distribute(items, layout::Track{.length = std::max(0, canvas.width() - kGutter), .gap = kColumnGap}); + + int x = kGutter; + auto columnWidth = widths.begin(); + for (auto const& column : _columns) { + canvas.putString(0, x, column.label, theme.textBold); + x += *columnWidth + kColumnGap; + ++columnWidth; + } + + int y = 1; + std::size_t index = 0; + for (auto* row : children()) { + if (!row->shown()) { + row->view().setArea({}); + continue; + } + if (auto* const stack = dynamic_cast(row)) { + stack->setColumnLayout(widths, kColumnGap); + } + int const height = std::max(1, row->naturalSize().height); + row->view().setArea({.x = kGutter, .y = y, .width = std::max(0, canvas.width() - kGutter), .height = height}); + auto const rowKey = keyAt(index); + bool const cursor = hasFocus() && index == _cursor; + std::string gutter{rowKey && isSelected(*rowKey) ? "*" : " "}; + gutter += cursor ? ">" : " "; + canvas.putString(y, 0, gutter, cursor ? theme.listItemSelected : theme.textNormal); + y += height; + ++index; + } +} + +EventResult TableImpl::key(::core::tui::KeyEvent const& key) { + if (::core::tui::withoutLockKeys(key.modifiers) != ::core::tui::Modifier::None) { + return EventResult::Ignored; + } + auto const count = shownChildren().size(); + if (key.key == KeyCode::Up) { + _cursor = _cursor > 0 ? _cursor - 1 : 0; + } else if (key.key == KeyCode::Down) { + _cursor = count == 0 ? 0 : std::min(_cursor + 1, count - 1); + } else if (key.key == KeyCode::Home) { + _cursor = 0; + } else if (key.key == KeyCode::End) { + _cursor = count == 0 ? 0 : count - 1; + } else if (key.codepoint == U' ') { + if (_mode == ui::SelectionMode::Single) { + selectOnly(_cursor); + } else if (_mode == ui::SelectionMode::Multiple) { + toggle(_cursor); + } + } else if (key.key == KeyCode::Enter) { + if (_mode == ui::SelectionMode::Single) { + selectOnly(_cursor); + } + activateRow(_cursor); + } else { + return EventResult::Ignored; + } + refresh(); + return EventResult::Handled; +} + +// A click on a row moves the cursor there and selects it (Single) or toggles it (Multiple). +void TableImpl::click(::core::tui::Point cell) { + int y = 1; + std::size_t index = 0; + for (auto const* row : shownChildren()) { + int const height = std::max(1, row->naturalSize().height); + if (cell.y >= y && cell.y < y + height) { + _cursor = index; + if (_mode == ui::SelectionMode::Single) { + selectOnly(index); + } else if (_mode == ui::SelectionMode::Multiple) { + toggle(index); + } + refresh(); + return; + } + y += height; + ++index; + } +} + +void TableImpl::selectOnly(std::size_t row) { + if (auto const rowKey = keyAt(row)) { + _selection = {*rowKey}; + report(); + } +} + +void TableImpl::toggle(std::size_t row) { + auto const rowKey = keyAt(row); + if (!rowKey) { + return; + } + if (auto const found = std::ranges::find(_selection, *rowKey); found != _selection.end()) { + _selection.erase(found); + } else { + _selection.push_back(*rowKey); + } + report(); +} + +void TableImpl::activateRow(std::size_t row) { + if (auto const rowKey = keyAt(row); rowKey && _onActivate) { + _onActivate(*rowKey); + } +} + +void TableImpl::report() { + if (_onSelectionChange) { + _onSelectionChange(_selection); + } +} + +} // namespace morph::tui::detail +``` + +Add `src/tui/table_widget.cpp` and `src/tui/table_widget.hpp` to `add_library(morph_tui STATIC …)`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/tui --target morph_tui_tests && ./build/tui/tests/tui/morph_tui_tests "[table]"` +Expected: PASS, 4 test cases. + +Mutation check: in `TableImpl::paint`, delete the `stack->setColumnLayout(widths, kColumnGap);` call. Expected: FAIL +in "a header row above keyed rows" (the row's cells pack together: ` apple3`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add src/tui/table_widget.hpp src/tui/table_widget.cpp tests/tui CMakeLists.txt +git commit -m "wip(tui): table with keyed rows and single or multiple selection + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 14: `tui::Backend` + +**Files:** +- Create: `include/morph/tui/backend.hpp`, `src/tui/backend.cpp` +- Modify: `CMakeLists.txt` — `src/tui/backend.cpp` in `add_library(morph_tui STATIC …)`; + `include/morph/tui/backend.hpp` in its `FILE_SET HEADERS` +- Modify: `tests/tui/CMakeLists.txt` — add `test_tui_backend.cpp` +- Test: `tests/tui/test_tui_backend.cpp` + +**Interfaces:** +- Consumes: `ui::IViewBackend` and its 19 factory signatures (Part 2, `include/morph/ui/backend.hpp`); every + widget of Tasks 8–13; `detail::attach`, `fit`, `moveFocus` (Tasks 8, 12). +- Produces: `morph::tui::Backend(core::tui::Screen&)` implementing every `ui::IViewBackend` factory, plus + `fit()`, `focusNext()`, `focusPrev()`, `focusFirst()`, `animating()`, `advanceAnimation()` — additions to the + interface contract, in Part 3's own namespace. + +- [ ] **Step 1: Write the failing test** + +Create `tests/tui/test_tui_backend.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tui/widget.hpp" +#include "tui_harness.hpp" + +namespace ui = morph::ui; +using morph::tui::Backend; +using morph::tui::detail::ContainerBase; +using morph::tui::testing::ResizableOutput; +using morph::tui::testing::rowsOf; + +namespace { + +/// A terminal, a screen over it and a backend over that. +struct Stage { + explicit Stage(std::unique_ptr output) + : terminal{std::move(output)}, screen{terminal}, backend{screen} {} + core::tui::Terminal terminal; + core::tui::Screen screen; + Backend backend; +}; + +} // namespace + +TEST_CASE("tui::Backend: every factory appends one widget to its parent", "[tui][backend]") { + Stage stage{std::make_unique(40, 12)}; + auto& backend = stage.backend; + auto const column = backend.createStack(nullptr, ui::Axis::Vertical); + std::vector> made; + made.push_back(backend.createText(column.get())); + made.push_back(backend.createButton(column.get())); + made.push_back(backend.createTextInput(column.get(), ui::TextInputMode::Multiline)); + made.push_back(backend.createCheckbox(column.get())); + made.push_back(backend.createSelect(column.get(), ui::SelectStyle::Radio)); + made.push_back(backend.createSelect(column.get(), ui::SelectStyle::Dropdown)); + made.push_back(backend.createMenu(column.get())); + made.push_back(backend.createStack(column.get(), ui::Axis::Horizontal)); + made.push_back(backend.createGrid(column.get())); + made.push_back(backend.createSpacer(column.get())); + made.push_back(backend.createPanel(column.get())); + made.push_back(backend.createScroll(column.get(), ui::Axis::Vertical)); + made.push_back(backend.createSlot(column.get())); + made.push_back(backend.createTabs(column.get())); + made.push_back(backend.createDialog(column.get())); + made.push_back(backend.createBusy(column.get())); + made.push_back(backend.createTable(column.get())); + made.push_back(backend.createDateTimeInput(column.get(), ui::DateMode::Date, 0)); + made.push_back(backend.createSlider(column.get())); + made.push_back(backend.createFilePicker(column.get(), ui::FilePickerMode::Save)); + auto const& container = ContainerBase::of(*column); + CHECK(container.children().size() == made.size()); + std::size_t index = 0; + for (auto const* child : container.children()) { + CHECK(&child->asWidget() == made.at(index).get()); + ++index; + } + backend.fit(); + stage.screen.draw(); + made.clear(); + CHECK(ContainerBase::of(*column).children().empty()); +} + +TEST_CASE("tui::Backend: fit re-fits the root to the screen after a resize", "[tui][backend]") { + auto output = std::make_unique(core::tui::Size{.width = 12, .height = 4}); + auto* const terminalOutput = output.get(); + Stage stage{std::move(output)}; + auto const panel = stage.backend.createPanel(nullptr); + panel->setTitle("Box"); + stage.backend.fit(); + stage.screen.draw(); + CHECK(rowsOf(stage.screen).back() == "└──────────┘"); + + terminalOutput->resize({.width = 16, .height = 5}); + static_cast(stage.screen.dispatchEvent(core::tui::ResizeEvent{.columns = 16, .rows = 5})); + stage.backend.fit(); + stage.screen.draw(); + auto const rows = rowsOf(stage.screen); + CHECK(rows.size() == 5); + CHECK(rows.back() == "└──────────────┘"); +} + +TEST_CASE("tui::Backend: animating while a Busy spins; advanceAnimation moves its frame", "[tui][backend]") { + Stage stage{std::make_unique(20, 1)}; + auto const busy = stage.backend.createBusy(nullptr); + busy->setLabel("Wait"); + CHECK_FALSE(stage.backend.animating()); + busy->setActive(true); + CHECK(stage.backend.animating()); + stage.backend.fit(); + stage.screen.draw(); + CHECK(rowsOf(stage.screen) == std::vector{"| Wait"}); + stage.backend.advanceAnimation(); + stage.screen.draw(); + CHECK(rowsOf(stage.screen) == std::vector{"/ Wait"}); +} + +TEST_CASE("tui::Backend: focusFirst focuses the first focusable widget only when nothing has focus", + "[tui][backend]") { + Stage stage{std::make_unique(20, 3)}; + auto const column = stage.backend.createStack(nullptr, ui::Axis::Vertical); + auto const label = stage.backend.createText(column.get()); + auto const first = stage.backend.createButton(column.get()); + auto const second = stage.backend.createButton(column.get()); + stage.backend.focusFirst(); + CHECK(stage.screen.focusedComponent() == &morph::tui::detail::WidgetBase::of(*first).view()); + stage.backend.focusNext(); + stage.backend.focusFirst(); + CHECK(stage.screen.focusedComponent() == &morph::tui::detail::WidgetBase::of(*second).view()); + stage.backend.focusPrev(); + CHECK(stage.screen.focusedComponent() == &morph::tui::detail::WidgetBase::of(*first).view()); +} + +TEST_CASE("tui::Backend: focusFirst moves into an open dialog whose content arrived after it opened", + "[tui][backend]") { + Stage stage{std::make_unique(30, 10)}; + auto const column = stage.backend.createStack(nullptr, ui::Axis::Vertical); + auto const dialog = stage.backend.createDialog(column.get()); + dialog->setOpen(true); // nothing inside yet: the frame holds the focus + auto const body = stage.backend.createStack(dialog.get(), ui::Axis::Vertical); + auto const okButton = stage.backend.createButton(body.get()); + stage.backend.focusFirst(); + CHECK(stage.screen.focusedComponent() == &morph::tui::detail::WidgetBase::of(*okButton).view()); +} +``` + +Add `test_tui_backend.cpp` to `tests/tui/CMakeLists.txt`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/tui --target morph_tui_tests` +Expected: FAIL — `'morph/tui/backend.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `include/morph/tui/backend.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include + +#include "../attributes.hpp" +#include "../ui/backend.hpp" + +/// @file +/// @brief `morph::tui::Backend`: the terminal implementation of `ui::IViewBackend`. +/// +/// Specified in `docs/spec/tui/frontend.md`, "Widgets". + +namespace morph::tui { + +/// @brief Builds every widget of a mounted view tree as a `core::tui::Component` on one `Screen`. +/// +/// Containers arrange their children in cells when the screen renders them; a Dialog and an open Dropdown are +/// screen overlays; drag-and-drop runs on the mouse with pointer capture. The factories, documented on +/// `ui::IViewBackend`, run on the screen's thread. Widgets must be destroyed before the backend, and the backend +/// before the screen. +class Backend final : public ui::IViewBackend { +public: + /// @param screen Where every widget renders. Borrowed: it must outlive the backend. + explicit Backend(::core::tui::Screen& screen MORPH_LIFETIMEBOUND); + /// @brief Destroys the backend; every widget it made must already be gone. + ~Backend() override; + Backend(Backend const&) = delete; + Backend& operator=(Backend const&) = delete; + Backend(Backend&&) = delete; + Backend& operator=(Backend&&) = delete; + + [[nodiscard]] std::unique_ptr createText(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createButton(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createTextInput(ui::ContainerWidget* parent, + ui::TextInputMode mode) override; + [[nodiscard]] std::unique_ptr createCheckbox(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createSelect(ui::ContainerWidget* parent, + ui::SelectStyle style) override; + [[nodiscard]] std::unique_ptr createMenu(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createStack(ui::ContainerWidget* parent, ui::Axis axis) override; + [[nodiscard]] std::unique_ptr createGrid(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createSpacer(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createPanel(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createScroll(ui::ContainerWidget* parent, ui::Axis axis) override; + [[nodiscard]] std::unique_ptr createSlot(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createTabs(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createDialog(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createBusy(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createTable(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createDateTimeInput(ui::ContainerWidget* parent, + ui::DateMode mode, + int offsetMinutes) override; + [[nodiscard]] std::unique_ptr createSlider(ui::ContainerWidget* parent) override; + [[nodiscard]] std::unique_ptr createFilePicker(ui::ContainerWidget* parent, + ui::FilePickerMode mode) override; + + /// @brief Sizes every root widget to the screen's viewport and centres every open dialog; call before each + /// `Screen::draw()`, which is how a resize reaches the tree. + void fit(); + + /// @brief Moves keyboard focus to the next focusable widget — inside the innermost open dialog when one is + /// open, because `Screen::focusNext` does not know overlays. + void focusNext(); + + /// @brief Moves keyboard focus to the previous focusable widget, with `focusNext`'s dialog rule. + void focusPrev(); + + /// @brief Focuses the first focusable widget when nothing has focus yet, or when the focus is on an open + /// dialog's frame (the dialog held nothing focusable when it opened, and has since gained something). + void focusFirst(); + + /// @brief Whether any Busy widget is spinning, so the frontend should keep advancing the animation. + /// @return True while at least one Busy is active. + [[nodiscard]] bool animating() const noexcept; + + /// @brief Moves every spinning Busy to its next frame; the next draw shows it. + void advanceAnimation() noexcept; + +private: + struct Impl; + std::unique_ptr _impl; +}; + +} // namespace morph::tui +``` + +Create `src/tui/backend.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "tui/container_widgets.hpp" +#include "tui/context.hpp" +#include "tui/field_widgets.hpp" +#include "tui/leaf_widgets.hpp" +#include "tui/list_widgets.hpp" +#include "tui/table_widget.hpp" +#include "tui/widget.hpp" + +namespace morph::tui { + +struct Backend::Impl { + explicit Impl(::core::tui::Screen& screen) : context{screen} {} + detail::Context context; +}; + +namespace { + +template +std::unique_ptr make(detail::Context& context, ui::ContainerWidget* parent, Args&&... args) { + auto widget = std::make_unique(context, std::forward(args)...); + detail::attach(context, parent, *widget); + return widget; +} + +} // namespace + +Backend::Backend(::core::tui::Screen& screen) : _impl{std::make_unique(screen)} {} + +Backend::~Backend() = default; + +std::unique_ptr Backend::createText(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createButton(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createTextInput(ui::ContainerWidget* parent, ui::TextInputMode mode) { + return make(_impl->context, parent, mode); +} + +std::unique_ptr Backend::createCheckbox(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createSelect(ui::ContainerWidget* parent, ui::SelectStyle style) { + if (style == ui::SelectStyle::Radio) { + return make(_impl->context, parent); + } + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createMenu(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createStack(ui::ContainerWidget* parent, ui::Axis axis) { + return make(_impl->context, parent, axis); +} + +std::unique_ptr Backend::createGrid(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createSpacer(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createPanel(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createScroll(ui::ContainerWidget* parent, ui::Axis axis) { + return make(_impl->context, parent, axis); +} + +std::unique_ptr Backend::createSlot(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createTabs(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createDialog(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createBusy(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createTable(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createDateTimeInput(ui::ContainerWidget* parent, ui::DateMode mode, + int offsetMinutes) { + return make(_impl->context, parent, mode, offsetMinutes); +} + +std::unique_ptr Backend::createSlider(ui::ContainerWidget* parent) { + return make(_impl->context, parent); +} + +std::unique_ptr Backend::createFilePicker(ui::ContainerWidget* parent, ui::FilePickerMode mode) { + return make(_impl->context, parent, mode); +} + +void Backend::fit() { + detail::fit(_impl->context); +} + +void Backend::focusNext() { + detail::moveFocus(_impl->context, detail::Direction::Forward); +} + +void Backend::focusPrev() { + detail::moveFocus(_impl->context, detail::Direction::Backward); +} + +// Also when the focus is on an open dialog's frame: a dialog that held nothing focusable when it opened (a Switch +// inside it showing no case, say) could focus nothing but its frame, and focusable content has arrived since. +void Backend::focusFirst() { + auto const& context = _impl->context; + auto const* const focused = context.screen->focusedComponent(); + bool const onFrame = !context.openDialogs.empty() && focused == context.openDialogs.back(); + if (focused == nullptr || onFrame) { + focusNext(); + } +} + +bool Backend::animating() const noexcept { + return _impl->context.activeBusy > 0; +} + +void Backend::advanceAnimation() noexcept { + ++_impl->context.animationFrame; +} + +} // namespace morph::tui +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/tui --target morph_tui_tests morph_verify_interface_header_sets && ./build/tui/tests/tui/morph_tui_tests "[backend]" +``` +Expected: PASS, 5 test cases; `backend.hpp` compiles standalone. + +Mutation check: in `Backend::focusFirst`, drop the `== nullptr` test so it always calls `focusNext()`. Expected: +FAIL in "focusFirst focuses the first focusable widget only when nothing has focus" (the second `focusFirst` moves +the focus to the next widget, which wraps to the first). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/tui/backend.hpp src/tui/backend.cpp tests/tui CMakeLists.txt +git commit -m "wip(tui): tui::Backend, every ui::IViewBackend factory + +Signed-off-by: Christian Parpart " +``` + +--- +### Task 15: Drag-and-drop + +**Files:** +- Create: `src/tui/drag.hpp`, `src/tui/drag.cpp` +- Modify: `src/tui/context.hpp` — forward-declare `class DragController;`, add the member + `std::unique_ptr drag;` as the **last** member of `Context` (and `#include `) +- Modify: `src/tui/context.cpp` — the constructor and `forget` (below) +- Modify: `src/tui/widget.cpp` — replace `WidgetBase::pointer` (below); `#include "tui/drag.hpp"` +- Modify: `CMakeLists.txt` — add both to `add_library(morph_tui STATIC …)` +- Modify: `tests/tui/CMakeLists.txt` — add `test_tui_drag.cpp` +- Test: `tests/tui/test_tui_drag.cpp` + +**Interfaces:** +- Consumes: `Screen::componentAt(int row, int col) const` (public since core-cpp 0.7), `Screen::releasePointer()`, + implicit pointer capture (spec 0 §2), `Screen::showOverlay`/`hideOverlay`/`isOverlayVisible`; + `core::tui::VtParser::feed` (core-cpp `tui/VtParser.hpp`, used by the test harness); `WidgetBase::dragKey()`, + `accepts`, `drop`, `isDropTarget` (Task 8). +- Produces: `DragController(Context&)` with `press(WidgetBase&, ui::Key, Point)`, `move(Point)`, + `release(Point) -> bool`, `forget(WidgetBase const&)`, `source()`, `target()`, `dragging()`; `Context::drag`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/tui/test_tui_drag.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// Drag-and-drop driven by SGR mouse reports, decoded by core-cpp's parser: +// CSI < b ; x ; y M is a press (b = 0) or, with bit 32 set, a motion with the +// button held; a final m is the release. Coordinates are 1-based. + +#include +#include +#include +#include +#include +#include + +#include "tui/container_widgets.hpp" +#include "tui/drag.hpp" +#include "tui/leaf_widgets.hpp" +#include "tui/widget.hpp" +#include "tui_harness.hpp" + +namespace ui = morph::ui; +using morph::tui::detail::ButtonImpl; +using morph::tui::detail::StackImpl; +using morph::tui::detail::TextImpl; +using morph::tui::detail::WidgetBase; +using morph::tui::testing::Harness; + +namespace { + +ui::Key key(std::int64_t value) { + return ui::Key{value}; +} + +/// Two 10-cell columns two cells apart; "task" in the first carries key 7. +struct Board { + explicit Board(Harness& harness) + : board{harness.make(nullptr, ui::Axis::Horizontal)}, + todo{harness.make(board.get(), ui::Axis::Vertical)}, + done{harness.make(board.get(), ui::Axis::Vertical)}, + card{harness.make(todo.get())} { + board->setGap(2); + todo->setLayout({.width = ui::Sizing::fixed(10), .height = {}}); + done->setLayout({.width = ui::Sizing::fixed(10), .height = {}}); + card->setText("task"); + card->setDragKey(key(7)); + } + + std::unique_ptr board; + std::unique_ptr todo; + std::unique_ptr done; + std::unique_ptr card; +}; + +bool contains(std::vector const& rows, std::string const& text) { + for (auto const& row : rows) { + if (row.find(text) != std::string::npos) { + return true; + } + } + return false; +} + +} // namespace + +TEST_CASE("tui drag: a card dragged onto an accepting column is dropped there", "[tui][drag]") { + Harness harness{30, 6}; + Board board{harness}; + std::vector dropped; + // An accept-everything predicate, as the mount passes for a node without `accepts`. + board.done->setDropHandler([](ui::Key const&) { return true; }, + [&](ui::Key dropKey) { dropped.push_back(std::move(dropKey)); }); + static_cast(harness.draw()); + + harness.sgr("\x1b[<0;1;1M"); // press on the card + harness.sgr("\x1b[<32;14;1M"); // move over the second column, button held + auto const during = harness.draw(); + CHECK(contains(during, "[task]")); + CHECK(contains(during, "╔")); + CHECK(harness.context().drag->target() == &WidgetBase::of(*board.done)); + + harness.sgr("\x1b[<0;14;1m"); // release + CHECK(dropped == std::vector{key(7)}); + auto const after = harness.draw(); + CHECK_FALSE(contains(after, "[task]")); + CHECK_FALSE(contains(after, "╔")); +} + +TEST_CASE("tui drag: a target whose accepts refuses the key is not highlighted and gets no drop", "[tui][drag]") { + Harness harness{30, 6}; + Board board{harness}; + int dropped = 0; + board.done->setDropHandler([](ui::Key const&) { return false; }, [&](ui::Key const&) { ++dropped; }); + static_cast(harness.draw()); + harness.sgr("\x1b[<0;1;1M"); + harness.sgr("\x1b[<32;14;1M"); + CHECK(harness.context().drag->target() == nullptr); + harness.sgr("\x1b[<0;14;1m"); + CHECK(dropped == 0); +} + +TEST_CASE("tui drag: a press and release without motion is a click, not a drag", "[tui][drag]") { + Harness harness{30, 2}; + auto const button = harness.make(nullptr); + int clicks = 0; + button->setLabel("Card"); + button->setDragKey(key(3)); + button->setOnClick([&] { ++clicks; }); + static_cast(harness.draw()); + harness.sgr("\x1b[<0;2;1M"); + harness.sgr("\x1b[<0;2;1m"); + CHECK(clicks == 1); + CHECK_FALSE(harness.context().drag->dragging()); +} + +TEST_CASE("tui drag: destroying the drag source mid-drag ends the gesture", "[tui][drag]") { + Harness harness{30, 6}; + Board board{harness}; + int dropped = 0; + board.done->setDropHandler([](ui::Key const&) { return true; }, [&](ui::Key const&) { ++dropped; }); + static_cast(harness.draw()); + harness.sgr("\x1b[<0;1;1M"); + harness.sgr("\x1b[<32;14;1M"); + REQUIRE(harness.context().drag->dragging()); + + board.card.reset(); // the card was moved elsewhere and remounted + CHECK(harness.context().drag->source() == nullptr); + CHECK_FALSE(harness.context().drag->dragging()); + auto const rows = harness.draw(); + CHECK_FALSE(contains(rows, "[task]")); + CHECK_FALSE(contains(rows, "╔")); + harness.sgr("\x1b[<0;14;1m"); + CHECK(dropped == 0); +} +``` + +Add `test_tui_drag.cpp` to `tests/tui/CMakeLists.txt`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/tui --target morph_tui_tests` +Expected: FAIL — `'tui/drag.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `src/tui/drag.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include + +#include "tui/context.hpp" + +namespace morph::tui::detail { + +class WidgetBase; + +/// The one drag gesture a backend can have at a time. A press on a widget with a drag key arms it; the first +/// motion a cell away starts the drag and shows a label beside the pointer; the drop target is the first widget, +/// from the one under the pointer up through its parents, that accepts the key, and it is outlined; the release +/// drops the key there. Pointer capture (core::tui) sends the whole gesture to the source. +class DragController { +public: + explicit DragController(Context& context); + ~DragController(); + DragController(DragController const&) = delete; + DragController& operator=(DragController const&) = delete; + DragController(DragController&&) = delete; + DragController& operator=(DragController&&) = delete; + + /// A press on @p source, at viewport cell @p point. + void press(WidgetBase& source, ui::Key key, ::core::tui::Point point); + /// The pointer moved to @p point with the button held. + void move(::core::tui::Point point); + /// The button was released at @p point; true when the gesture was a drag, so the release is not a click. + [[nodiscard]] bool release(::core::tui::Point point); + /// A widget is being destroyed: a source ends the gesture and releases the pointer; a target stops being one. + void forget(WidgetBase const& widget); + + [[nodiscard]] WidgetBase const* source() const noexcept { return _source; } + [[nodiscard]] WidgetBase const* target() const noexcept { return _target; } + [[nodiscard]] bool dragging() const noexcept { return _dragging; } + +private: + class Label; + class Highlight; + + [[nodiscard]] WidgetBase* findTarget(::core::tui::Point point) const; + void setTarget(WidgetBase* target); + void showLabel(::core::tui::Point point); + void end(); + + Context* _context; + WidgetBase* _source = nullptr; + std::optional _key; + ::core::tui::Point _pressedAt{}; + ::core::tui::Point _labelAt{}; + bool _dragging = false; + WidgetBase* _target = nullptr; + std::unique_ptr`, `formView`, collections, wizards and +the app shell — header-only under `include/morph/forms/engine/`, carrying every behaviour the QML renderer's +QuickTest suite pins as headless Catch2 tests, plus `math::parseDecimal` and `app::ViewScreen`. + +**Architecture:** `FormModel::fromSchema` reads the schema JSON `schemaJson()` emits (glaze's u64 DOM, so +integers keep every digit) into an immutable field model with parsed `x-rules`. `FormSession` holds a +`reactive::Signal` per draft and derives encodings, errors, three-valued rule verdicts and readiness as +`Computed`s; submission is a `reactive::Mutation` over a `Submitter`, Choice options a `reactive::Query` per Choice +over a `ChoiceFetcher`. `formView` turns a session into `ui::Node`s bound to those signals, so one form renders on +every frontend; collections, wizards and the app shell compose sessions and views the same way. + +**Tech Stack:** C++23, header-only; glaze (`glz::generic_u64`); `morph::reactive` (Part 1); `morph::ui` and +`ui::testing::RecordingBackend` (Part 2); `morph::math::Rational`, `morph::render` locale formatting; the bridge +(`BridgeHandler`, `ActionExecuteRegistry`); Catch2 v3. + +**Spec:** `docs/superpowers/specs/2026-10-04-forms-engine-design.md` (spec 2), §1–§10 and §12; §11 (retiring the QML +renderer) is Part 10's — this part removes nothing. + +This is **Part 5 of 11** of the declarative-UI program. Read the master plan +`docs/superpowers/plans/2026-10-04-declarative-ui-tui.md` first: it fixes the branch, the commit layout and the +`wip(...)` commit convention every task below follows, and this part ends by squashing its `wip(forms)` commits +into one, directly after the `qt_quick:` commit. It consumes Part 1 (`morph::reactive`) and Part 2 (`morph::ui`, +`RecordingBackend`) exactly as the interface contract +(`docs/superpowers/plans/2026-10-04-declarative-ui-tui-interfaces.md`) states them. + +## Global Constraints + +- Part 1's Global Constraints apply unchanged (SPDX line, `#pragma once`, naming, comments, Doxygen on every + symbol including `detail`, `-Weverything -Werror`, clang-tidy, sign-off). +- The engine is header-only in the base `morph` target's `FILE_SET HEADERS` (`engine/detail/text.hpp` in + `morph_detail_headers`); no new CMake option, no new dependency, no compiled source. +- `form_session.hpp` and everything it includes stay free of `morph/ui`: a controller that only needs + `FormSession` must not pull rendering in (spec §9). Only `overrides.hpp`, `form_view.hpp`, `collection.hpp`, + `wizard.hpp` and `app_shell.hpp` include `morph/ui`. +- No value passes through a `double`: integers compare and encode as digits, decimals and quantities as + `Rational`s or by digit cross-multiplication. Floating `std::to_chars` appears only where a JSON double is + printed in fixed notation (`detail::engine::numberText`). +- No `std::ranges::contains` (missing from the Emscripten libc++ the wasm job uses); use + `detail::engine::contains`. +- No unchecked `operator[]` in a header (`cppcoreguidelines-pro-bounds-avoid-unchecked-container-access`): + containers use `.at()`, `try_emplace` or iteration, the glaze DOM `get_object().insert_or_assign`, and a + `std::span` (no `at()` before C++26) `detail::engine::elementAt` (Task 4). +- Every engine test carries the tag `[forms-engine]`; test files are `tests/test_forms_engine_*.cpp`. +- The rule and instance-bounds corpora move to `tests/data/` (Task 9) and both the Catch2 and the QML suites + read them there; the copies under `src/qt/forms/tests/data/` stay, unread, until Part 10 removes the + directory. +- The `RecordingBackend` vocabulary the view tests rely on — widget kinds (`"TextInput"`, `"Button"`, …) and + recorded props (`"text"`, `"label"`, `"enabled"`, …) — is Part 2's (`ui/testing/recording_backend.hpp`, its Task + 2); the kinds are named once, in `tests/forms_engine_view_support.hpp`. +- Lifetime rule, stated in every session class's brief: a session outlives every view mounted on it; tests + destroy `ui::Mounted` before the session. +- Code blocks are not hand-wrapped to the 119-column limit everywhere: the pre-commit `clang-format` hook + formats each file on `git commit`; when it rewrites one, `git add` it and commit again. +- Nothing under `src/qt/forms` changes behaviour; only its CMake data-path definitions and comments move with the + corpora. + +## Review Focus + +1. **A prefill that leaves the form ready** must not submit, and the next user edit must (Task 6 test + "FormSession: prefilling a complete payload never submits; the next user edit does"). +2. **A Choice reply for a superseded parent value** must be dropped, and a selection prefilled before its options + arrive must survive the refetch (Task 8 test "choice: a reply for a superseded parent value is dropped, and a + prefilled selection survives its refetch"). +3. **An id beyond 2^53** must stay exact through encode, body, a rule comparison and prefill (Task 6 test + "FormSession: an id beyond 2^53 is exact through encode, body, a rule and prefill"). +4. **A rule kind the engine does not know** must never block readiness, while a False conjunct beside it still + does (Task 3 test "RuleExpr: an unknown kind never blocks, and a false conjunct still does"). +5. **A collection row removed while its view is mounted** must be torn down after its bindings, not before + (Task 12 test "formView: a row removed while its view is mounted is torn down after its bindings"). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `include/morph/util/rational.hpp` | `math::parseDecimal` — locale entry text to an exact `Rational` | +| `include/morph/forms/engine/detail/text.hpp` | JSON quoting, exact decimal-text arithmetic, wall-clock/instant text (detail) | +| `include/morph/forms/engine/rules.hpp` | `SchemaError`, `Tri`, `Scalar`, `RuleValue`, `RuleExpr`, gating and presentation verdicts | +| `include/morph/forms/engine/field_model.hpp` | `FieldKind`, `SubmitMode`, `FieldSpec`, `FieldGroupSpec`, `FormModel::fromSchema`, `FormModel::forAction` | +| `include/morph/forms/engine/values.hpp` | `FieldError`, `WireValue`, `ValueContext`, `encodeScalar`, `decodeScalar`, `convertDraft`, `parseOptions` | +| `include/morph/forms/engine/form_session.hpp` | `Submitter`, `ChoiceFetcher`, `FormSessionOptions`, `FieldState`, `RowState`, `FormSession` | +| `include/morph/core/bridge.hpp` | `ActionExecuteRegistry::modelsServing`, `makeHandler` | +| `include/morph/forms/engine/bridge_submitter.hpp` | `bridgeSubmitter`, `bridgeChoiceFetcher` | +| `include/morph/forms/engine/handler_submitter.hpp` | `handlerSubmitter`, `handlerChoiceFetcher` — forms submitting through the application's own `BridgeHandler`s | +| `include/morph/forms/engine/typed_form.hpp` | `Form` | +| `include/morph/forms/engine/overrides.hpp` | `FieldView`, `Overrides` | +| `include/morph/forms/engine/form_view.hpp` | `FormViewOptions`, `formView` | +| `include/morph/forms/engine/collection.hpp` | `CollectionModel`, `CollectionSession`, `CollectionViewOptions`, `collectionView` | +| `include/morph/forms/engine/wizard.hpp` | `WizardModel`, `WizardSession`, `wizardView` | +| `include/morph/forms/app.hpp` | `app::ViewScreen` | +| `include/morph/forms/engine/app_shell.hpp` | `AppShellModel`, `AppShellSources`, `AppShellSession`, `appShellView` | +| `tests/forms_engine_support.hpp` | `FakeServer` (settleable submitter/fetcher) and the session `Harness` | +| `tests/forms_engine_view_support.hpp` | The node outline and the `RecordingBackend` vocabulary view tests use | +| `tests/test_rational_parse_decimal.cpp`, `tests/test_render_locale_format.cpp` | `parseDecimal` | +| `tests/test_forms_engine_{text,rules,model,values,session,nested,choice}.cpp` | Model, rules, values, session | +| `tests/test_forms_engine_{bridge,handler_submitter,typed,view,collection,wizard,app_shell}.cpp` | Submitters, typed facade, rendering, composites | +| `tests/data/rule_corpus.json`, `tests/data/instance_bounds.json` | The shared corpora, now read from one place | +| `tests/test_forms_{rule_agreement,instance_constraints,conformance_corpus}.cpp` | The corpora driven through the engine | +| `docs/spec/forms/engine.md` | The authoritative engine spec | +| `docs/spec/forms/{forms,views,workflows_navigation,widget_hints}.md`, `docs/spec/util/rational.md`, `docs/spec/core/bridge.md` | Engine sections added beside the existing ones | +| `CMakeLists.txt`, `tests/CMakeLists.txt`, `src/qt/forms/CMakeLists.txt` | Header, test and corpus-path registration | +| `docs/spec/README.md`, `docs/ARCHITECTURE.md`, `CHANGELOG.md`, `docs/spec/pinned_facts.toml` | Maps, changelog, the pinned nesting depth | + +## Build and test commands (used by every task) + +```bash +cmake -S . -B build/reactive -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_EXAMPLES=OFF # once +cmake --build build/reactive --target morph_tests +./build/reactive/tests/morph_tests "[forms-engine]" +``` + +Configuring prints `morph: warnings: ... strict=ON`; if it does not, stop and say so (CONTRIBUTING, "Warnings +are errors"). The QML suite, which Task 9 points at the moved corpora, builds in `build/all` (master plan, "Build +directories"; it still configures `MORPH_BUILD_FORMS_QML=ON`): `cmake --build build/all && ctest --test-dir +build/all -R forms --output-on-failure`. + +## QuickTest parity map + +Spec 2 §10: every claim the 35 `src/qt/forms/tests/tst_*.qml` files make is carried by a Catch2 test of this part +(`tst_main.cpp` is the QuickTest entry point, not a test file). Each file is followed by the test file(s) and the +exact TEST_CASE names that carry it; Task 17 Step 2 checks that every name below exists. What is deliberately +not carried is listed in the next section. + +- `tst_collectionview.qml` → `test_forms_engine_collection.cpp` + - `CollectionSession: the list loads on construction; cells are exact` + - `CollectionSession: opening a row prefills only its bound fields and fires nothing` + - `CollectionSession: an edit after opening fires, closes the editor and reloads the list` + - `CollectionSession: a confirmed row action fires its bound body and reloads; a cancelled one fires nothing` + - `CollectionSession: a collection action fires with an empty body` + - `CollectionSession: nothing typed into one row's editor carries over to the next opening` + - `collectionView: a table of the visible columns, the actions, the editor and confirm dialogs` +- `tst_conformance.qml` → `test_forms_conformance_corpus.cpp`, `test_forms_engine_model.cpp` + - `Conformance corpus through the engine: x-order and the required gate` + - `Conformance corpus through the engine: exact Quantity payloads and unit switching` + - `Conformance corpus through the engine: Choice, Timestamp and the shared $def` + - `FormModel: key order is irrelevant, x-order is the layout, required and layout read either way` +- `tst_conformance_negative.qml` → `test_forms_conformance_corpus.cpp`, `test_forms_engine_session.cpp` + - `Conformance corpus through the engine: x-order and the required gate` + - `Conformance corpus through the engine: exact Quantity payloads and unit switching` + - `FormSession: readiness gates on required fields, precision and date syntax; the body is byte-exact` +- `tst_conformance_accessibility.qml` → `test_forms_engine_view.cpp` + - `formView: labels fall back to the wire key, follow x-order and come from translations` + - `formView: explicit mode adds a Submit button bound to readiness; the cell shows help and a live *` +- `tst_dynamicform.qml` → `test_forms_engine_model.cpp`, `test_forms_engine_text.cpp`, + `test_forms_engine_values.cpp`, `test_forms_engine_session.cpp`, `test_forms_engine_choice.cpp`, + `test_forms_engine_view.cpp` + - `FormModel: kinds, units, the Choice descriptor and flags come from the schema` + - `FormModel: widget hints become Multiline, Slider and a radio Choice` + - `FormModel: x-layout buckets fields by x-section in x-order, with a trailing implicit group` + - `FormModel: a dependent Choice names its parents` + - `engine text: digit multiplication is exact` + - `engine text: compareDecimal orders canonical decimals at any magnitude` + - `values: unit conversion is exact and rounds half away from zero` + - `values: a Quantity keeps the unreduced scaled digits the QML renderer sent` + - `values: option rows are the reply array or its first array member, ids exact` + - `FormSession: readiness gates on required fields, precision and date syntax; the body is byte-exact` + - `FormSession: reset clears every draft and does not submit a form that stays ready` + - `FormSession: field() resolves paths and refuses unknown ones` + - `choice: a dependent Choice refetches with its parents' literals when a parent changes` + - `formView: sections, tab runs, accordions and the implicit group, each on its grid` + - `formView: switching tabs keeps what was typed and submits nothing` +- `tst_DynamicFormArrayField.qml` → `test_forms_engine_session.cpp`, `test_forms_engine_values.cpp`, + `test_forms_engine_view.cpp` + - `FormSession: an array field encodes a JSON array; blank optional is omitted, a required one gates` + - `values: booleans, enums, choices, dates and arrays encode to their wire shapes` + - `formView: each kind draws its control` +- `tst_DynamicFormBlankAs.qml` → `test_forms_engine_session.cpp` + - `FormSession: x-blankAs empty submits "" once the field was filled, and only then` + - `FormSession: a stored payload round-trips, absent members start blank, the unit resets` +- `tst_DynamicFormBooleanAndAnyOf.qml` → `test_forms_engine_session.cpp`, `test_forms_engine_view.cpp` + - `FormSession: booleans are bare literals; a required one answers false, an optional one is omitted` + - `FormSession: an integer through anyOf keeps every digit and refuses text` + - `formView: each kind draws its control` +- `tst_DynamicFormChoicelessController.qml` → `test_forms_engine_choice.cpp` + - `choice: a form with no Choice needs no fetcher; one with a Choice gets its options` +- `tst_DynamicFormChrome.qml` → `test_forms_engine_view.cpp` + - `Overrides: an override receives the field and drives the session like the built-in control` + - `formView: explicit mode adds a Submit button bound to readiness; the cell shows help and a live *` + - `formView: the mounted Submit button follows readiness and submits on click` +- `tst_DynamicFormDisplayUnit.qml` → `test_forms_engine_model.cpp`, `test_forms_engine_values.cpp`, + `test_forms_engine_view.cpp` + - `FormModel: a plain number reads its display unit and decimals; a declared precision wins` + - `values: placeholders follow the kind and the declared precision` + - `values: a plain number is a JSON number of the digits typed, gated by its bounds` + - `formView: each kind draws its control` + - `Overrides: field beats widget beats unit beats kind; a miss draws the built-in control` +- `tst_DynamicFormEnumChoice.qml` → `test_forms_engine_model.cpp`, `test_forms_engine_session.cpp`, + `test_forms_engine_view.cpp` + - `FormModel: a closed set is an Enum in every spelling the emitter or a hand writes` + - `FormSession: an enum starts unselected, encodes the chosen literal and refuses anything else` + - `formView: each kind draws its control` +- `tst_DynamicFormExactBounds.qml` → `test_forms_engine_values.cpp`, `test_forms_engine_session.cpp` + - `values: an integer keeps every digit and prefers the exact bound` + - `FormSession: an integer through anyOf keeps every digit and refuses text` +- `tst_DynamicFormFieldBounds.qml` → `test_forms_engine_values.cpp` + - `values: Quantity bounds, instance bounds and multipleOf are checked exactly in the canonical unit` + - `values: an integer keeps every digit and prefers the exact bound` +- `tst_DynamicFormGridColumns.qml` → `test_forms_engine_view.cpp` + - `formView: a host grid widens every group's grid; spans clamp to it` + - `formView: sections, tab runs, accordions and the implicit group, each on its grid` +- `tst_DynamicFormInstanceBounds.qml` → `test_forms_instance_constraints.cpp`, `test_forms_engine_model.cpp` + - `Every corpus row's engine verdict is the one the corpus records` + - `The decorated schema drives the engine's entry precision` + - `FormModel: an instance-decorated schema reads its precision and exact range` +- `tst_DynamicFormNestedAggregate.qml` → `test_forms_engine_model.cpp`, `test_forms_engine_nested.cpp` + - `FormModel: nested objects and collections are described recursively, and a cycle stops` + - `nested: a self-referential type stops where it repeats, and a value loaded there is unrepresentable` + - `nested: an acyclic aggregate is drawn and encoded; an optional one may be left out` +- `tst_DynamicFormObjectArraySlot.qml` → `test_forms_engine_nested.cpp`, `test_forms_engine_view.cpp` + - `nested: collection rows encode with each member's encoder; an empty required collection is []` + - `nested: loaded cells read as their text; a reset empties the rows` + - `nested: a member that does not encode, or a blank required member, keeps the form unready` + - `formView: an object is a panel of member cells; a collection adds and removes rows` + - `formView: a row removed while its view is mounted is torn down after its bindings` +- `tst_DynamicFormObjectSlot.qml` → `test_forms_engine_nested.cpp`, `test_forms_engine_view.cpp` + - `nested: an object encodes member by member with each member's own encoder` + - `nested: two levels deep, blank optional leaves and objects are left out` + - `nested: members read in the display locale` + - `nested: a prefill decodes into the members and round-trips` + - `nested: rows inside an object reuse the row encoder` + - `nested: a required boolean member of a filled object answers false` + - `nested: a member that does not encode, or a blank required member, keeps the form unready` + - `formView: an object is a panel of member cells; a collection adds and removes rows` +- `tst_DynamicFormPlainNumber.qml` → `test_forms_engine_values.cpp`, `test_forms_engine_model.cpp` + - `values: a plain number is a JSON number of the digits typed, gated by its bounds` + - `values: a Quantity keeps the unreduced scaled digits the QML renderer sent` + - `FormModel: a plain number reads its display unit and decimals; a declared precision wins` +- `tst_DynamicFormPrefill.qml` → `test_forms_engine_session.cpp`, `test_forms_engine_values.cpp`, + `test_forms_engine_choice.cpp`, `test_forms_engine_view.cpp` + - `FormSession: prefilling a complete payload never submits; the next user edit does` + - `FormSession: a stored payload round-trips, absent members start blank, the unit resets` + - `values: decoding is the inverse of encoding, per kind` + - `choice: a prefilled selection is shown once its options arrive` + - `formView: mounted controls drive the session` +- `tst_DynamicFormReactive.qml` → `test_forms_engine_session.cpp`, `test_forms_engine_choice.cpp` + - `FormSession: automatic mode submits once when the body becomes valid, and again only when it changes` + - `choice: only an independent Choice fetches on construction, with an empty body` + - `choice: a dependent Choice refetches with its parents' literals when a parent changes` + - `choice: a selection the new options no longer back is cleared, without submitting` +- `tst_DynamicFormReseedSubmit.qml` → `test_forms_engine_view.cpp`, `test_forms_engine_session.cpp` + - `formView: switching tabs keeps what was typed and submits nothing` + - `FormSession: reset clears every draft and does not submit a form that stays ready` +- `tst_DynamicFormRuleAgreement.qml` → `test_forms_rule_agreement.cpp`, `test_forms_engine_rules.cpp` + - `the forms engine reaches the compiled verdicts on the emitted schema` + - `RuleExpr: equals compares booleans as booleans and big integers on digits` +- `tst_DynamicFormRuleCorpus.qml` → `test_forms_engine_rules.cpp` + - `rule corpus: the engine reads every corpus schema` + - `rule corpus: every row's engine verdicts are the ones the corpus records` + - `rule corpus: the engine and allRulesSatisfied agree on every row` +- `tst_DynamicFormRules.qml` → `test_forms_engine_session.cpp`, `test_forms_engine_rules.cpp`, + `test_forms_engine_view.cpp` + - `FormSession: x-rules gate and present live` + - `FormSession: compound when-clauses require, release and negate` + - `RuleExpr: membership counts definite operands and is Unknown only when the count is open` + - `formView: visibility and read-only follow the rules on the mounted cells` +- `tst_DynamicFormSchemaAsVariant.qml` → `test_forms_engine_model.cpp`, `test_forms_engine_session.cpp` + - `FormModel: key order is irrelevant, x-order is the layout, required and layout read either way` + - `FormSession: an integer through anyOf keeps every digit and refuses text` +- `tst_DynamicFormSubmitMode.qml` → `test_forms_engine_session.cpp`, `test_forms_engine_view.cpp` + - `FormSession: explicit mode submits only on submit(), and only while ready` + - `formView: explicit mode adds a Submit button bound to readiness; the cell shows help and a live *` + - `formView: the mounted Submit button follows readiness and submits on click` +- `tst_DynamicFormUnknownRuleKind.qml` → `test_forms_engine_rules.cpp` + - `RuleExpr: an unknown kind never blocks, and a false conjunct still does` + - `RuleExpr: and, or and not are Kleene over Unknown` + - `RuleExpr: presentation verdicts combine every rule naming the field` +- `tst_i18n.qml` → `test_forms_engine_session.cpp`, `test_forms_engine_view.cpp`, `test_forms_engine_values.cpp`, + `test_rational_parse_decimal.cpp`, `test_render_locale_format.cpp`, `test_forms_engine_text.cpp` + - `FormSession: labels resolve through the translations; numbers read in the session locale` + - `formView: labels fall back to the wire key, follow x-order and come from translations` + - `values: an error message resolves through the translation provider` + - `parseDecimal: locale grouping, separators and signs are read through the control edge` + - `parseDecimal agrees with normalizeLocaleNumber on the multi-unit corpus` + - `parseDecimal reads every measured digit set` + - `engine text: wall-clock and instant conversion` +- `tst_LargeIdPrecision.qml` → `test_forms_engine_collection.cpp`, `test_forms_engine_choice.cpp`, + `test_forms_engine_session.cpp` + - `CollectionSession: ids beyond 2^53 stay distinct in keys, cells and bodies` + - `choice: option values and labels keep ids beyond 2^53 distinct` + - `FormSession: an id beyond 2^53 is exact through encode, body, a rule and prefill` +- `tst_slot_registry.qml` → `test_forms_engine_view.cpp` + - `Overrides: field beats widget beats unit beats kind; a miss draws the built-in control` + - `Overrides: an override receives the field and drives the session like the built-in control` +- `tst_SlotRegistryByKind.qml` → `test_forms_engine_model.cpp`, `test_forms_engine_view.cpp` + - `FormModel: every renderer kind is named` + - `Overrides: field beats widget beats unit beats kind; a miss draws the built-in control` + - `Overrides: an override receives the field and drives the session like the built-in control` +- `tst_ViewChrome.qml` → `test_forms_engine_collection.cpp`, `test_forms_engine_wizard.cpp` + - `collectionView: a table of the visible columns, the actions, the editor and confirm dialogs` + - `collectionView: master-detail puts the editor beside the table` + - `wizardView: the title with the position, the step's form, and Back/Next bound to the session` +- `tst_wizardview.qml` → `test_forms_engine_wizard.cpp` + - `WizardSession: Next waits for the step's successful submission; Back always returns` + - `WizardSession: a failed submission leaves the step not done` + - `WizardSession: entering a step applies its prefill as JSON values, the reply winning` + +## What the parity map does not carry + +These QML claims have no engine test, each for a stated reason: + +- **Qt's own locale facts** (`tst_i18n.qml`: the `qtReports…`/`qtExposes…` cases and "no locale separator needs + more than one code unit") measure `Qt.locale`, not the engine. The measured locales they produced are the + corpus `test_render_locale_format.cpp` already pins, and `parseDecimal` is run over it. +- **QML-only mechanics**: the `Connections` warning of `tst_DynamicFormChoicelessController.qml`, "an assigned + schema is not a plain JS object" (`tst_DynamicFormSchemaAsVariant.qml`), the `previewLine` (the engine's preview + is `body()`), and the QML renderer's internal indexes (`dependentsIsReverseOfDependsOn`, + `fieldByNameIndexesByName`; `FormModel::find` and `FieldSpec::choice.dependsOn` replace them). +- **The chrome and slot registries** (`tst_DynamicFormChrome.qml`, `tst_ViewChrome.qml`, the "slot registered after + the form is built" and "byField is scoped to its own action" cases): spec 2 §7 replaces registries with + composition — `Overrides` belong to one `formView` call, and chrome is built from the session's signals. What + the chrome tests proved about the form (an overridden control still drives it, Submit goes through its own + gate) is carried by the Overrides and Submit tests above. +- **Focus order and keyboard operability** (`tst_conformance_accessibility.qml`) are properties of a frontend's + widgets (Parts 3 and 4), not of the node tree the engine emits; what the engine owns — cells in `x-order`, a + label falling back to the wire key, the required marker — the label and Submit tests above carry. +- **The broken renderers of `tst_conformance_negative.qml`** proved the conformance harness can fail. Here every + task's Step 4 mutation check does that job: it breaks the engine on purpose and names the test that must fail + (Task 9 raises `atLeastOneOf`'s threshold, and the corpus cases fail). + +## Contract additions and spec deviations + +Every public name beyond the Part 5 contract, and every place this plan departs from spec 2, in one list (each is +also in its task's Interfaces block and in `docs/spec/forms/engine.md`): + +- **Added to `FormSessionOptions`:** `accepts` — an extra readiness predicate over the body; `Form` joins + `validate()` through it. +- **Added to `FormSession`:** `runtime()`, `fields()`, `assign(path, json)`, `groupCollapsed(i)`, + `tabSelection(i)`, `groupTitle(i)`, `submitMode()`, `options()`. **Added to `FormModel`:** `find(name)`. + **Added types:** `RowState`, `FieldError`, `WireValue`, `ValueContext`, `RuleValue`, `Scalar`, `DecimalText`, + `UnitOption`, `ChoiceOption`, `ChoiceSpec`, `kMaxNestingDepth`; `FormViewOptions::flatGridColumns`; and the full + declarations of the contract's forward-declared classes (Tasks 4, 6–8, 12–15). +- **Added to `bridge::ActionExecuteRegistry`** (`include/morph/core/bridge.hpp`): `HandlerFactory`, + `modelsServing(actionId)`, `makeHandler(modelId, Bridge&, IExecutor*)`. Spec 2 §6 routes through "the + bridge's JSON entry"; the bridge has no action-type-keyed JSON entry (`executeJson` is a `BridgeHandler` + member), so `bridgeSubmitter` routes the action type to the one model registering it (Task 10). +- **Composite constructors take a `SchemaLookup`** (action type → schema JSON): `CollectionSession`, + `WizardSession`; `AppShellSession` takes `AppShellSources{actions, wizards, views}` and builds each screen's + session itself. `WizardModel::fromSchema(wizardId, wizardJson)` and `AppShellModel::fromSchema(appId, appJson)` + take the registered id (the i18n key stem); `AppScreen::kind` is the enum `ScreenKind`. +- **Gating is "not False"**, not spec 2 §3's "every gating rule True": Unknown arises only from a rule kind the + engine does not know (an Invalid operand already blocks through its field's error), and blocking on it would + lock an older client out of a newer server's forms — the QML renderer's `tst_DynamicFormUnknownRuleKind.qml` + behaviour, kept. +- **Schema keywords:** the emitter writes `x-optionValue` / `x-optionLabel` (spec 2 §2 says `x-optionsValue` / + `x-optionsLabel`); the engine reads what is emitted. +- **QML parity kept where spec 2 is silent:** a form ready as constructed submits once in automatic mode; Quantity + literals are unreduced (`{"num":2650500,"den":1000,"dp":3}`). +- **QML behaviour changed** (listed in `engine.md`, "Differences from the QML renderer"): a required + ObjectArray with no rows encodes `[]`; Array items encode by item type rather than always as strings; Quantity + bounds hold whatever the entry unit; a required checkbox's blank draft encodes `false`; every + `visibleWhen`/`requiredWhen` naming a field counts, not only the first. + +--- + +### Task 1: `morph::math::parseDecimal` + +**Files:** +- Modify: `include/morph/util/rational.hpp` — includes (add ``, ``, `` + after ``), and the new function directly before the closing + `} // namespace morph::math` that follows `Rational::toDouble`'s out-of-line definition +- Modify: `tests/CMakeLists.txt` — add `test_rational_parse_decimal.cpp` after `test_rational.cpp` +- Modify: `tests/test_render_locale_format.cpp` — append the locale-corpus rows driven through `parseDecimal` +- Modify: `docs/spec/util/rational.md` — a "Parsing decimal entry text" section before "## API reference" +- Test: `tests/test_rational_parse_decimal.cpp` + +**Interfaces:** +- Consumes: `render::NumericLocale`, `render::normalizeLocaleNumber` (`include/morph/render/locale_format.hpp:97`, + `:585`); `Rational`, `Numerator`, `Denominator`, `DecimalPlaces`, `kMaxDecimalPlaces`, `detail::powerOfTen` + (`include/morph/util/rational.hpp:375`, `:140`, `:154`, `:121`, `:167`, `:1331`). +- Produces (the interface contract's Part 5 entry, exactly): + `morph::math::parseDecimal(std::string_view text, render::NumericLocale const& locale, + std::optional maxDecimalPlaces = std::nullopt) -> std::optional`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_rational_parse_decimal.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +namespace { + +using morph::math::DecimalPlaces; +using morph::math::Denominator; +using morph::math::Numerator; +using morph::math::parseDecimal; +using morph::math::Rational; +using morph::render::NumericLocale; + +constexpr NumericLocale kCLocale{}; +constexpr NumericLocale kGerman{.decimalSeparator = ",", .groupSeparator = "."}; + +[[nodiscard]] Rational exact(std::int64_t num, std::int64_t den) { + return Rational{Numerator{num}, Denominator{den}, DecimalPlaces{0}}; +} + +} // namespace + +TEST_CASE("parseDecimal: plain text is the exact value it spells", "[rational][parse-decimal]") { + auto const value = parseDecimal("2650.5", kCLocale); + REQUIRE(value.has_value()); + CHECK(*value == exact(26505, 10)); + CHECK(value->decimalPlaces.value == 1); + CHECK(parseDecimal("-0.25", kCLocale) == exact(-1, 4)); + CHECK(parseDecimal("007.50", kCLocale) == exact(75, 10)); + CHECK(parseDecimal("0", kCLocale) == exact(0, 1)); +} + +TEST_CASE("parseDecimal: locale grouping, separators and signs are read through the control edge", + "[rational][parse-decimal]") { + CHECK(parseDecimal("1.050,25", kGerman) == exact(105025, 100)); + CHECK(parseDecimal("+1.050,25", kGerman) == exact(105025, 100)); + CHECK(parseDecimal("1.5", kGerman) == std::nullopt); // a group that is too short, not 15 + constexpr std::string_view kMinus = "\xE2\x88\x92"; // U+2212 + NumericLocale const basque{.decimalSeparator = ",", .groupSeparator = ".", .negativeSign = kMinus}; + CHECK(parseDecimal("\xE2\x88\x92" + "5", + basque) == exact(-5, 1)); + NumericLocale const arabic{.zeroDigit = "\xD9\xA0"}; // U+0660 + CHECK(parseDecimal("\xD9\xA5", arabic) == exact(5, 1)); +} + +TEST_CASE("parseDecimal: a declared precision rejects more places and tags the value", "[rational][parse-decimal]") { + CHECK(parseDecimal("12.55", kCLocale, 1U) == std::nullopt); + auto const typed = parseDecimal("12.5", kCLocale, 1U); + REQUIRE(typed.has_value()); + CHECK(*typed == exact(125, 10)); + CHECK(typed->decimalPlaces.value == 1); + auto const whole = parseDecimal("12", kCLocale, 3U); + REQUIRE(whole.has_value()); + CHECK(whole->decimalPlaces.value == 3); + auto const wide = parseDecimal("1.5", kCLocale, 30U); + REQUIRE(wide.has_value()); + CHECK(wide->decimalPlaces.value == morph::math::kMaxDecimalPlaces); +} + +TEST_CASE("parseDecimal: malformed shapes are refused rather than guessed", "[rational][parse-decimal]") { + for (std::string_view const text : {"", "-", "5.", ".5", "-.5", "1e5", "abc", "1-2", "3,5", " 5", "5 ", "--1"}) { + INFO("accepted '" << text << "'"); + CHECK_FALSE(parseDecimal(text, kCLocale).has_value()); + } +} + +TEST_CASE("parseDecimal: a value outside the int64 envelope is refused", "[rational][parse-decimal]") { + CHECK(parseDecimal("9223372036854775807", kCLocale) == exact(9223372036854775807, 1)); + CHECK(parseDecimal("-9223372036854775807", kCLocale) == exact(-9223372036854775807, 1)); + CHECK_FALSE(parseDecimal("9223372036854775808", kCLocale).has_value()); + CHECK_FALSE(parseDecimal("92233720368547758.08", kCLocale).has_value()); + CHECK_FALSE(parseDecimal("0.1234567890123456789", kCLocale).has_value()); // 19 places + CHECK(parseDecimal("0.123456789012345678", kCLocale).has_value()); // 18 places +} +``` + +Append to `tests/test_render_locale_format.cpp` (it already defines `kDigitSets`, `kGroup2`, `kDecimal2`, +`kGroup4`, `kDecimal4`, `kArDecimal`, `kArGroup`, `kArZero`, `kArNegative` and `joined` in anonymous namespaces +above; add `#include ` to its includes): + +```cpp +// ──── The same corpora, through the parser the forms engine uses ───────────── +// +// parseDecimal is normalizeLocaleNumber plus the canonical-shape check and the +// int64 envelope, so every row above that normalises must parse to the exact +// value of its canonical text, and every row that does not must not parse. + +namespace { +[[nodiscard]] std::optional canonicalValue(std::string_view canonical) { + return morph::math::parseDecimal(canonical, morph::render::NumericLocale{}); +} +} // namespace + +TEST_CASE("parseDecimal agrees with normalizeLocaleNumber on the multi-unit corpus", "[render][locale][parse-decimal]") { + morph::render::NumericLocale const two{.decimalSeparator = kDecimal2, .groupSeparator = kGroup2}; + morph::render::NumericLocale const four{.decimalSeparator = kDecimal4, .groupSeparator = kGroup4}; + struct Row { + std::string text; + morph::render::NumericLocale locale; + }; + std::vector const rows{ + {joined({"1", kGroup2, "050", kDecimal2, "25"}), two}, + {joined({"-1", kGroup2, "050", kDecimal2, "25"}), two}, + {joined({"+1", kGroup2, "050", kDecimal2, "25"}), two}, + {joined({"1", kGroup4, "050", kDecimal4, "25"}), four}, + {joined({"1", kGroup2, "5"}), two}, + {joined({"1", kDecimal2, "0", kDecimal2, "5"}), two}, + {joined({kDecimal2, "-5"}), two}, + }; + for (auto const& row : rows) { + auto const normalised = morph::render::normalizeLocaleNumber(row.text, row.locale); + auto const parsed = morph::math::parseDecimal(row.text, row.locale); + INFO("row '" << row.text << "'"); + REQUIRE(parsed.has_value() == normalised.has_value()); + if (normalised.has_value()) { + CHECK(*parsed == *canonicalValue(*normalised)); + } + } +} + +TEST_CASE("parseDecimal reads every measured digit set", "[render][locale][parse-decimal]") { + for (auto const& set : kDigitSets) { + INFO(set.name); + auto const five = morph::math::parseDecimal(set.five, {.zeroDigit = set.zero}); + REQUIRE(five.has_value()); + CHECK(*five == *canonicalValue("5")); + morph::render::NumericLocale const locale{.decimalSeparator = kArDecimal, + .groupSeparator = kArGroup, + .negativeSign = kArNegative, + .zeroDigit = set.zero}; + auto const display = morph::render::formatCanonicalNumber("-1050.25", locale); + CHECK(morph::math::parseDecimal(display, locale) == canonicalValue("-1050.25")); + } + morph::render::NumericLocale const arabic{.zeroDigit = kArZero}; + CHECK_FALSE(morph::math::parseDecimal("\xD9\xA5" + "5", + arabic) + .has_value()); // two digit families in one entry +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: compile error `no member named 'parseDecimal' in namespace 'morph::math'`. + +- [ ] **Step 3: Implement** + +In `include/morph/util/rational.hpp`, add to the includes `#include `, `#include ` and +`#include ` (after `#include `), and directly +before the `} // namespace morph::math` that closes the block holding `Rational::toDouble`'s definition: + +```cpp +/// @brief Parses locale-formatted decimal entry text into the exact value it spells. +/// +/// The text goes through `render::normalizeLocaleNumber` first, so the locale's separators, +/// signs and digits are accepted and its grouping is validated; the canonical text must then +/// be `-?[0-9]+(\.[0-9]+)?` — no exponent, no bare separator, no surrounding space. The value +/// must fit `Rational`: at most `kMaxDecimalPlaces` fraction digits, and the digits read as one +/// integer within `int64_t`. Nothing is rounded: text finer than @p maxDecimalPlaces is refused. +/// @param text The entry, e.g. `"1.050,25"`. +/// @param locale The locale facts the entry was typed in. +/// @param maxDecimalPlaces The declared precision, when there is one. The result carries it as +/// its precision tag (clamped to `kMaxDecimalPlaces`); without one the +/// tag is the number of fraction digits typed. +/// @return The exact value, or `std::nullopt` for malformed, over-precise or unrepresentable text. +[[nodiscard]] inline std::optional parseDecimal(std::string_view text, render::NumericLocale const& locale, + std::optional maxDecimalPlaces = std::nullopt) { + std::optional const canonical = render::normalizeLocaleNumber(text, locale); + if (!canonical.has_value()) { + return std::nullopt; + } + std::string_view rest{*canonical}; + bool const negative = rest.starts_with('-'); + if (negative) { + rest.remove_prefix(1); + } + auto const dot = rest.find('.'); + std::string_view const whole = rest.substr(0, dot); + std::string_view const fraction = dot == std::string_view::npos ? std::string_view{} : rest.substr(dot + 1); + auto const allDigits = [](std::string_view part) { + return !part.empty() && std::ranges::all_of(part, [](char chr) { return chr >= '0' && chr <= '9'; }); + }; + if (!allDigits(whole) || (dot != std::string_view::npos && !allDigits(fraction))) { + return std::nullopt; + } + auto const places = static_cast(fraction.size()); + if (places > kMaxDecimalPlaces || (maxDecimalPlaces.has_value() && places > *maxDecimalPlaces)) { + return std::nullopt; + } + constexpr auto kLimit = static_cast(std::numeric_limits::max()); + std::uint64_t magnitude = 0; + for (std::string_view const part : {whole, fraction}) { + for (char const chr : part) { + auto const digit = static_cast(chr - '0'); + if (magnitude > (kLimit - digit) / 10U) { + return std::nullopt; + } + magnitude = (magnitude * 10U) + digit; + } + } + auto const signedMagnitude = static_cast(magnitude); + std::uint32_t const tag = std::min(maxDecimalPlaces.value_or(places), kMaxDecimalPlaces); + return Rational{Numerator{negative ? -signedMagnitude : signedMagnitude}, Denominator{detail::powerOfTen(places)}, + DecimalPlaces{tag}}; +} +``` + +In `docs/spec/util/rational.md`, before `## API reference`, add: + +```markdown +## Parsing decimal entry text — `parseDecimal` + +`parseDecimal(text, locale, maxDecimalPlaces = nullopt) -> std::optional` is the one +decimal parser morph ships; the forms engine and any application share it. + +1. `render::normalizeLocaleNumber(text, locale)` reads the locale's separators, signs and digit + set and validates its grouping (`docs/spec/forms/forms.md`, "Locale data formatting"). +2. The canonical text must be `-?[0-9]+(\.[0-9]+)?`: `"5."`, `".5"` and `"1e5"` are refused. +3. More fraction digits than `maxDecimalPlaces` are refused, never rounded; more than + `kMaxDecimalPlaces` are refused whatever was declared. +4. The digits, read as one integer, must fit `int64_t`; the result is that integer over + `10^places`, canonicalised, tagged with `maxDecimalPlaces` (clamped to `kMaxDecimalPlaces`) or, + without one, with the number of places typed. + +Rejection is `std::nullopt` in every case: the caller is an entry edge, and "this text is not a +number this field can hold" is an ordinary answer there, not an error. +``` + +and add a row to the "Conversion helpers" table — or, if that section is prose, one line at its end: +`` `parseDecimal` (above) is the inverse direction: entry text to an exact value. `` + +In `tests/CMakeLists.txt`, add `test_rational_parse_decimal.cpp` after `test_rational.cpp`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[parse-decimal]"` +Expected: PASS, 7 test cases. + +Mutation check: delete `|| (maxDecimalPlaces.has_value() && places > *maxDecimalPlaces)` from the guard. Expected: +FAIL in "a declared precision rejects more places and tags the value" (`parseDecimal("12.55", kCLocale, 1U)` is engaged). +Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/util/rational.hpp tests/test_rational_parse_decimal.cpp tests/test_render_locale_format.cpp \ + tests/CMakeLists.txt docs/spec/util/rational.md +git commit -m "wip(forms): parseDecimal, the exact decimal entry parser + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 2: Exact text helpers (`forms/engine/detail/text.hpp`) + +The engine never routes a value through a `double`. These helpers are the arithmetic that makes that possible: +JSON quoting that matches the body bytes the QML renderer produced, a canonical-decimal comparison that works at any +magnitude, digit multiplication for comparing a `Rational` against a decimal bound, decimal rendering of a +`Rational`, and ISO-8601 wall-clock conversion for the display zone. + +**Files:** +- Create: `include/morph/forms/engine/detail/text.hpp` +- Modify: `CMakeLists.txt` — in the `FILE_SET morph_detail_headers` block, after + `include/morph/forms/detail/schema_name.hpp`, add `include/morph/forms/engine/detail/text.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_text.cpp` after `test_forms_conformance_corpus.cpp` +- Test: `tests/test_forms_engine_text.cpp` + +**Interfaces:** +- Consumes: `glz::generic_u64`, `glz::read_json`, `glz::write_json`; `forms::detail::findMember` + (`include/morph/forms/forms.hpp:2482`); `math::Rational`, `math::roundToDecimalPlaces`, `math::RoundingMode`, + `math::detail::powerOfTen`, `math::detail::absU64` (`include/morph/util/rational.hpp:1029`, `:190`, `:1331`, + `:368`). +- Produces, in `morph::forms::detail::engine` (later tasks use exactly these): + `Json` (= `glz::generic_u64`); `trim(std::string_view) -> std::string_view`; `quote(std::string_view) -> + std::string`; `parseJson(std::string_view) -> std::optional`; `toJson(Json const&) -> std::string`; + `emptyObject() -> Json`; `member(Json const&, std::string_view) -> Json const*`; + `stringAt(Json const&, std::string_view) -> std::string const*`; `numberText(Json const&) -> + std::optional`; `displayText(Json const&) -> std::string`; `isCanonicalDecimal(std::string_view)`, + `isCanonicalInteger(std::string_view)`; `stripLeadingZeros(std::string_view) -> std::string`; + `compareDecimal(std::string_view, std::string_view) -> std::strong_ordering`; `multiplyDigits(std::string_view, + std::string_view) -> std::string`; `compareRationalToDecimal(math::Rational const&, std::string_view) -> + std::strong_ordering`; `decimalText(math::Rational const&, std::uint32_t places) -> std::string`; + `parseWallClock(std::string_view) -> std::optional`; `parseInstant(std::string_view) + -> std::optional`; `formatWallClock(std::chrono::sys_seconds) -> std::string`; + `formatUtc(std::chrono::sys_seconds) -> std::string`; `isIsoDate(std::string_view) -> bool`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_text.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +namespace engine = morph::forms::detail::engine; +using morph::math::DecimalPlaces; +using morph::math::Denominator; +using morph::math::Numerator; +using morph::math::Rational; + +TEST_CASE("engine text: quote matches JSON.stringify byte for byte", "[forms-engine][text]") { + CHECK(engine::quote("plain") == R"("plain")"); + CHECK(engine::quote("a\"b\\c") == R"("a\"b\\c")"); + CHECK(engine::quote("tab\tnl\ncr\r") == R"("tab\tnl\ncr\r")"); + CHECK(engine::quote(std::string{"\x01"}) == R"("\u0001")"); + CHECK(engine::quote("kg/m\xC2\xB3") == "\"kg/m\xC2\xB3\""); // non-ASCII passes through +} + +TEST_CASE("engine text: numbers keep every digit, and a double is written in fixed notation", + "[forms-engine][text]") { + auto const dom = engine::parseJson(R"({"big":18446744073709551615,"neg":-9223372036854775808,"small":1e-7,)" + R"("max":1.7976931348623157e+308,"half":0.5,"text":"x"})"); + REQUIRE(dom.has_value()); + CHECK(engine::numberText(*engine::member(*dom, "big")) == "18446744073709551615"); + CHECK(engine::numberText(*engine::member(*dom, "neg")) == "-9223372036854775808"); + CHECK(engine::numberText(*engine::member(*dom, "small")) == "0.0000001"); + CHECK(engine::numberText(*engine::member(*dom, "half")) == "0.5"); + CHECK(engine::numberText(*engine::member(*dom, "max"))->size() == 309); + CHECK_FALSE(engine::numberText(*engine::member(*dom, "text")).has_value()); + CHECK(engine::toJson(*engine::member(*dom, "big")) == "18446744073709551615"); + CHECK(engine::displayText(*engine::member(*dom, "text")) == "x"); + CHECK_FALSE(engine::parseJson("not json").has_value()); +} + +TEST_CASE("engine text: compareDecimal orders canonical decimals at any magnitude", "[forms-engine][text]") { + using std::strong_ordering; + CHECK(engine::compareDecimal("9223372036854775808", "9223372036854775807") == strong_ordering::greater); + CHECK(engine::compareDecimal("-9223372036854775809", "-9223372036854775808") == strong_ordering::less); + CHECK(engine::compareDecimal("0009223372036854775807", "9223372036854775807") == strong_ordering::equal); + CHECK(engine::compareDecimal("0.25", "0.5") == strong_ordering::less); + CHECK(engine::compareDecimal("2.50", "2.5") == strong_ordering::equal); + CHECK(engine::compareDecimal("-0", "0") == strong_ordering::equal); + CHECK(engine::compareDecimal("-40.5", "-41") == strong_ordering::greater); +} + +TEST_CASE("engine text: digit multiplication is exact", "[forms-engine][text]") { + CHECK(engine::multiplyDigits("123", "45") == "5535"); + CHECK(engine::multiplyDigits("0", "999") == "0"); + CHECK(engine::multiplyDigits("9223372036854775807", "1000") == "9223372036854775807000"); +} + +TEST_CASE("engine text: a Rational compares exactly against a decimal", "[forms-engine][text]") { + Rational const third{Numerator{1}, Denominator{3}, DecimalPlaces{2}}; + CHECK(engine::compareRationalToDecimal(third, "0.3333") == std::strong_ordering::greater); + CHECK(engine::compareRationalToDecimal(third, "0.3334") == std::strong_ordering::less); + Rational const big{Numerator{9223372036854775807}, Denominator{1}, DecimalPlaces{0}}; + CHECK(engine::compareRationalToDecimal(big, std::string(309, '9')) == std::strong_ordering::less); + CHECK(engine::compareRationalToDecimal(-big, "-100000000000000000000") == std::strong_ordering::greater); +} + +TEST_CASE("engine text: decimalText rounds half away from zero at the requested places", "[forms-engine][text]") { + CHECK(engine::decimalText(Rational{Numerator{5}, Denominator{4}, DecimalPlaces{2}}, 2) == "1.25"); + CHECK(engine::decimalText(Rational{Numerator{-1}, Denominator{3}, DecimalPlaces{2}}, 2) == "-0.33"); + CHECK(engine::decimalText(Rational{Numerator{245050}, Denominator{100}, DecimalPlaces{2}}, 2) == "2450.50"); + CHECK(engine::decimalText(Rational{Numerator{15}, Denominator{10}, DecimalPlaces{1}}, 1) == "1.5"); + CHECK(engine::decimalText(Rational{Numerator{-1}, Denominator{1000}, DecimalPlaces{3}}, 2) == "0.00"); + CHECK(engine::decimalText(Rational{Numerator{7}, Denominator{1}, DecimalPlaces{0}}, 0) == "7"); +} + +TEST_CASE("engine text: wall-clock and instant conversion", "[forms-engine][text]") { + using namespace std::chrono_literals; + auto const wall = engine::parseWallClock("2026-07-05T14:30"); + REQUIRE(wall.has_value()); + CHECK(engine::formatUtc(*wall) == "2026-07-05T14:30:00Z"); + CHECK(engine::formatWallClock(*wall + 2h) == "2026-07-05T16:30:00"); + CHECK(engine::parseWallClock("2026-07-05T14:30:15").has_value()); + CHECK_FALSE(engine::parseWallClock("not-a-date").has_value()); + CHECK_FALSE(engine::parseWallClock("2026-02-30T10:00").has_value()); + CHECK_FALSE(engine::parseWallClock("2026-07-05T24:00").has_value()); + auto const zoned = engine::parseInstant("2026-01-01T00:30:00+01:00"); + REQUIRE(zoned.has_value()); + CHECK(engine::formatWallClock(*zoned) == "2025-12-31T23:30:00"); + CHECK(engine::parseInstant("2026-07-20T09:00:00Z").has_value()); + CHECK(engine::parseInstant("2026-07-20T09:00:00.123Z").has_value()); + CHECK(engine::parseInstant("2026-07-20T09:00:00").has_value()); + CHECK(engine::isIsoDate("2026-02-28")); + CHECK_FALSE(engine::isIsoDate("2026-02-30")); + CHECK_FALSE(engine::isIsoDate("2026-2-3")); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: `fatal error: 'morph/forms/engine/detail/text.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `include/morph/forms/engine/detail/text.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/detail/text.hpp +/// @brief Exact text helpers the forms engine shares: JSON quoting and reading, canonical +/// decimal comparison and digit arithmetic, decimal rendering of a `Rational`, and +/// ISO-8601 wall-clock conversion. +/// +/// Specified in `docs/spec/forms/engine.md`. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../../util/rational.hpp" +#include "../../forms.hpp" + +/// @brief Implementation details of the forms engine (`include/morph/forms/engine/`). +namespace morph::forms::detail::engine { + +/// @brief The JSON DOM the engine reads: integers keep every digit, as `uint64_t` or `int64_t`. +using Json = glz::generic_u64; + +/// @brief Strips ASCII whitespace from both ends. +/// @param text The text. +/// @return A view of @p text without leading or trailing spaces, tabs or line breaks. +[[nodiscard]] inline std::string_view trim(std::string_view text) noexcept { + constexpr std::string_view kSpace = " \t\r\n\f\v"; + auto const first = text.find_first_not_of(kSpace); + if (first == std::string_view::npos) { + return {}; + } + auto const last = text.find_last_not_of(kSpace); + return text.substr(first, last - first + 1); +} + +/// @brief Quotes @p text as a JSON string exactly as `JSON.stringify` does: `"` and `\` escaped, +/// the five short control escapes, any other control character as `\u00xx`, everything +/// else verbatim. The body bytes the engine produces depend on this spelling. +/// @param text UTF-8 text. +/// @return The quoted literal. +[[nodiscard]] inline std::string quote(std::string_view text) { + constexpr std::string_view kHex = "0123456789abcdef"; + std::string out; + out.reserve(text.size() + 2); + out += '"'; + for (char const chr : text) { + auto const code = static_cast(chr); + switch (chr) { + case '"': + out += "\\\""; + break; + case '\\': + out += "\\\\"; + break; + case '\b': + out += "\\b"; + break; + case '\f': + out += "\\f"; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + if (code < 0x20U) { + out += "\\u00"; + out += kHex.at(code >> 4U); + out += kHex.at(code & 0x0FU); + } else { + out += chr; + } + break; + } + } + out += '"'; + return out; +} + +/// @brief Parses JSON text into the u64-mode DOM. +/// @param text JSON text. +/// @return The DOM, or `std::nullopt` when @p text is not JSON. +[[nodiscard]] inline std::optional parseJson(std::string_view text) { + // glaze reads a null-terminated buffer; a view into the middle of a larger string is not one. + std::string const buffer{text}; + Json dom{}; + if (glz::read_json(dom, buffer)) { + return std::nullopt; + } + return dom; +} + +/// @brief Writes a DOM value as compact JSON; integers keep every digit. +/// @param value The value. +/// @return Its JSON text. +[[nodiscard]] inline std::string toJson(Json const& value) { return glz::write_json(value).value_or(std::string{}); } + +/// @brief A DOM node holding an empty JSON object. +/// @return `{}`. +[[nodiscard]] inline Json emptyObject() { + Json node{}; + node.data.emplace(); + return node; +} + +/// @brief The member @p key of an object node, without inserting anything. +/// @param node The node; a non-object has no members. +/// @param key The member name. +/// @return The member, or null. +[[nodiscard]] inline Json const* member(Json const& node, std::string_view key) { + return ::morph::forms::detail::findMember(node, key); +} + +/// @brief The string member @p key of an object node. +/// @param node The node. +/// @param key The member name. +/// @return The string, or null when absent or not a string. +[[nodiscard]] inline std::string const* stringAt(Json const& node, std::string_view key) { + Json const* const found = member(node, key); + return found == nullptr ? nullptr : found->get_if(); +} + +/// @brief A JSON number as canonical decimal text, `-?[0-9]+(\.[0-9]+)?`. +/// +/// Integers keep every digit; a double is written in fixed notation with the shortest digits +/// that read back as the same double, so `1e-7` is `"0.0000001"` and no text has an exponent. +/// @param value The value. +/// @return The text, or `std::nullopt` when @p value is not a finite number. +[[nodiscard]] inline std::optional numberText(Json const& value) { + if (auto const* const unsignedValue = value.get_if()) { + return std::to_string(*unsignedValue); + } + if (auto const* const signedValue = value.get_if()) { + return std::to_string(*signedValue); + } + if (auto const* const doubleValue = value.get_if()) { + if (!std::isfinite(*doubleValue)) { + return std::nullopt; + } + // 309 integer digits for DBL_MAX, a sign, and the longest shortest fraction. + std::array buffer{}; + auto const result = + std::to_chars(buffer.data(), buffer.data() + buffer.size(), *doubleValue, std::chars_format::fixed); + if (result.ec != std::errc{}) { + return std::nullopt; + } + std::string text{buffer.data(), result.ptr}; + if (text == "-0") { + text = "0"; + } + return text; + } + return std::nullopt; +} + +/// @brief A scalar as display text: a string as itself, a number as `numberText`, a boolean +/// as `true`/`false`, null as empty, anything else as compact JSON. +/// @param value The value. +/// @return The text. +[[nodiscard]] inline std::string displayText(Json const& value) { + if (auto const* const text = value.get_if()) { + return *text; + } + if (auto number = numberText(value)) { + return *std::move(number); + } + if (auto const* const flag = value.get_if()) { + return *flag ? "true" : "false"; + } + if (value.is_null()) { + return {}; + } + return toJson(value); +} + +/// @brief Whether @p text is `-?[0-9]+(\.[0-9]+)?`. +/// @param text The text. +/// @return `true` for canonical decimal text. +[[nodiscard]] inline bool isCanonicalDecimal(std::string_view text) noexcept { + if (text.starts_with('-')) { + text.remove_prefix(1); + } + auto const dot = text.find('.'); + auto const digits = [](std::string_view part) { + return !part.empty() && std::ranges::all_of(part, [](char chr) { return chr >= '0' && chr <= '9'; }); + }; + if (dot == std::string_view::npos) { + return digits(text); + } + return digits(text.substr(0, dot)) && digits(text.substr(dot + 1)); +} + +/// @brief Whether @p text is `-?[0-9]+`. +/// @param text The text. +/// @return `true` for canonical integer text. +[[nodiscard]] inline bool isCanonicalInteger(std::string_view text) noexcept { + return isCanonicalDecimal(text) && text.find('.') == std::string_view::npos; +} + +/// @brief Drops a leading run of zeros that JSON forbids: `"007.50"` → `"7.50"`, `"-00"` → `"-0"`. +/// @param canonical Canonical decimal text. +/// @return The text with at most one leading zero before the point. +[[nodiscard]] inline std::string stripLeadingZeros(std::string_view canonical) { + std::string out; + if (canonical.starts_with('-')) { + out += '-'; + canonical.remove_prefix(1); + } + while (canonical.size() > 1 && canonical.front() == '0' && canonical.at(1) != '.') { + canonical.remove_prefix(1); + } + out += canonical; + return out; +} + +/// @brief Orders two canonical decimals exactly, at any magnitude; `-0` equals `0`. +/// @param lhs Canonical decimal text (leading zeros allowed). +/// @param rhs Canonical decimal text (leading zeros allowed). +/// @return The ordering of the two values. +[[nodiscard]] inline std::strong_ordering compareDecimal(std::string_view lhs, std::string_view rhs) noexcept { + struct Parts { + bool negative = false; + std::string_view whole; + std::string_view fraction; + }; + auto const split = [](std::string_view text) { + Parts parts{}; + parts.negative = text.starts_with('-'); + if (parts.negative) { + text.remove_prefix(1); + } + auto const dot = text.find('.'); + parts.whole = text.substr(0, dot); + parts.fraction = dot == std::string_view::npos ? std::string_view{} : text.substr(dot + 1); + while (parts.whole.size() > 1 && parts.whole.front() == '0') { + parts.whole.remove_prefix(1); + } + while (!parts.fraction.empty() && parts.fraction.back() == '0') { + parts.fraction.remove_suffix(1); + } + if ((parts.whole.empty() || parts.whole == "0") && parts.fraction.empty()) { + parts.negative = false; + } + return parts; + }; + auto const magnitude = [](Parts const& left, Parts const& right) { + if (left.whole.size() != right.whole.size()) { + return left.whole.size() <=> right.whole.size(); + } + if (auto const order = left.whole <=> right.whole; order != 0) { + return order; + } + return left.fraction <=> right.fraction; + }; + Parts const left = split(lhs); + Parts const right = split(rhs); + if (left.negative != right.negative) { + return left.negative ? std::strong_ordering::less : std::strong_ordering::greater; + } + auto const order = magnitude(left, right); + if (!left.negative) { + return order; + } + return 0 <=> order; +} + +/// @brief Multiplies two non-negative digit strings exactly. +/// @param lhs Decimal digits. +/// @param rhs Decimal digits. +/// @return The product's digits, without leading zeros. +[[nodiscard]] inline std::string multiplyDigits(std::string_view lhs, std::string_view rhs) { + std::vector product(lhs.size() + rhs.size(), 0U); + for (std::size_t i = lhs.size(); i-- > 0;) { + for (std::size_t j = rhs.size(); j-- > 0;) { + auto const left = static_cast(lhs.at(i) - '0'); + auto const right = static_cast(rhs.at(j) - '0'); + std::uint32_t& low = product.at(i + j + 1); + std::uint32_t const sum = (left * right) + low; + low = sum % 10U; + product.at(i + j) += sum / 10U; + } + } + std::string out; + for (std::uint32_t const digit : product) { + if (out.empty() && digit == 0U) { + continue; + } + out += static_cast('0' + digit); + } + return out.empty() ? std::string{"0"} : out; +} + +/// @brief Orders an exact rational against a canonical decimal, exactly, at any magnitude. +/// +/// Cross-multiplies on digits: `|num| * 10^places` against `|digits| * den`, so neither side is +/// ever narrowed to a `Rational` or a `double`. +/// @param value The rational. +/// @param decimal Canonical decimal text. +/// @return The ordering of @p value against @p decimal. +[[nodiscard]] inline std::strong_ordering compareRationalToDecimal(math::Rational const& value, + std::string_view decimal) { + bool const decimalNegative = decimal.starts_with('-'); + std::string_view magnitude = decimalNegative ? decimal.substr(1) : decimal; + auto const dot = magnitude.find('.'); + std::string digits{magnitude.substr(0, dot)}; + std::size_t places = 0; + if (dot != std::string_view::npos) { + digits += magnitude.substr(dot + 1); + places = magnitude.size() - dot - 1; + } + bool const decimalZero = std::ranges::all_of(digits, [](char chr) { return chr == '0'; }); + int const decimalSign = decimalZero ? 0 : (decimalNegative ? -1 : 1); + int const valueSign = value.numerator == 0 ? 0 : (value.numerator < 0 ? -1 : 1); + if (valueSign != decimalSign) { + return valueSign <=> decimalSign; + } + if (valueSign == 0) { + return std::strong_ordering::equal; + } + std::string const scaledValue = std::to_string(math::detail::absU64(value.numerator)) + std::string(places, '0'); + std::string const scaledDecimal = multiplyDigits(digits, std::to_string(value.denominator)); + auto const order = compareDecimal(scaledValue, scaledDecimal); + return valueSign > 0 ? order : 0 <=> order; +} + +/// @brief Renders @p value as canonical decimal text, rounded half away from zero to @p places. +/// @param value The value. +/// @param places Fraction digits; clamped to `math::kMaxDecimalPlaces`. +/// @return E.g. `"-0.33"` for `-1/3` at two places; a value that rounds to zero has no sign. +[[nodiscard]] inline std::string decimalText(math::Rational const& value, std::uint32_t places) { + std::uint32_t const wanted = std::min(places, math::kMaxDecimalPlaces); + math::Rational const rounded = + math::roundToDecimalPlaces(value, math::DecimalPlaces{wanted}, math::RoundingMode::HalfAwayFromZero); + std::int64_t const scale = math::detail::powerOfTen(wanted); + std::string digits = multiplyDigits(std::to_string(math::detail::absU64(rounded.numerator)), + std::to_string(scale / rounded.denominator)); + if (digits.size() <= wanted) { + digits.insert(0, wanted + 1 - digits.size(), '0'); + } + std::string out = rounded.numerator < 0 ? "-" : ""; + out += std::string_view{digits}.substr(0, digits.size() - wanted); + if (wanted > 0) { + out += '.'; + out += std::string_view{digits}.substr(digits.size() - wanted); + } + return out; +} + +/// @brief Reads `count` ASCII digits at @p offset of @p text. +/// @param text The text. +/// @param offset Where the digits start. +/// @param count How many digits. +/// @return The value, or `std::nullopt` when a character there is not a digit. +[[nodiscard]] inline std::optional digitsAt(std::string_view text, std::size_t offset, std::size_t count) { + if (offset + count > text.size()) { + return std::nullopt; + } + int value = 0; + for (std::size_t i = offset; i < offset + count; ++i) { + char const chr = text.at(i); + if (chr < '0' || chr > '9') { + return std::nullopt; + } + value = (value * 10) + (chr - '0'); + } + return value; +} + +/// @brief Reads `YYYY-MM-DDTHH:MM[:SS]` as a calendar instant, validating every component. +/// +/// The result is the reading as if it were UTC; the caller shifts it by its display zone. +/// @param text The wall-clock text. +/// @return The instant, or `std::nullopt` for malformed or impossible text. +[[nodiscard]] inline std::optional parseWallClock(std::string_view text) { + if (text.size() != 16 && text.size() != 19) { + return std::nullopt; + } + if (text.at(4) != '-' || text.at(7) != '-' || text.at(10) != 'T' || text.at(13) != ':' || + (text.size() == 19 && text.at(16) != ':')) { + return std::nullopt; + } + auto const year = digitsAt(text, 0, 4); + auto const month = digitsAt(text, 5, 2); + auto const day = digitsAt(text, 8, 2); + auto const hour = digitsAt(text, 11, 2); + auto const minute = digitsAt(text, 14, 2); + auto const second = text.size() == 19 ? digitsAt(text, 17, 2) : std::optional{0}; + if (!year || !month || !day || !hour || !minute || !second || *hour > 23 || *minute > 59 || *second > 59) { + return std::nullopt; + } + std::chrono::year_month_day const date{std::chrono::year{*year}, + std::chrono::month{static_cast(*month)}, + std::chrono::day{static_cast(*day)}}; + if (!date.ok()) { + return std::nullopt; + } + return std::chrono::sys_days{date} + std::chrono::hours{*hour} + std::chrono::minutes{*minute} + + std::chrono::seconds{*second}; +} + +/// @brief Reads an ISO-8601 instant: `YYYY-MM-DDTHH:MM[:SS[.fraction]][Z|±HH:MM|±HHMM]`. +/// +/// No zone designator reads as UTC, which is what a `Timestamp` serialises to; a fraction is +/// dropped, since every engine display is to the second. +/// @param text The instant text. +/// @return The UTC instant, or `std::nullopt` when malformed. +[[nodiscard]] inline std::optional parseInstant(std::string_view text) { + std::size_t clockEnd = 19; + if (text.size() < 19 || text.at(16) != ':') { + clockEnd = 16; + } + auto local = parseWallClock(text.substr(0, clockEnd)); + if (!local) { + return std::nullopt; + } + std::string_view rest = text.substr(clockEnd); + if (rest.starts_with('.')) { + rest.remove_prefix(1); + while (!rest.empty() && rest.front() >= '0' && rest.front() <= '9') { + rest.remove_prefix(1); + } + } + if (rest.empty() || rest == "Z") { + return local; + } + if (rest.front() != '+' && rest.front() != '-') { + return std::nullopt; + } + int const sign = rest.front() == '-' ? -1 : 1; + rest.remove_prefix(1); + std::string compact{rest}; + if (compact.size() == 5 && compact.at(2) == ':') { + compact.erase(2, 1); + } + auto const hours = digitsAt(compact, 0, 2); + auto const minutes = digitsAt(compact, 2, 2); + if (compact.size() != 4 || !hours || !minutes) { + return std::nullopt; + } + return *local - (sign * (std::chrono::hours{*hours} + std::chrono::minutes{*minutes})); +} + +/// @brief Writes an instant as `YYYY-MM-DDTHH:MM:SS`. +/// @param instant The instant. +/// @return The wall-clock text. +[[nodiscard]] inline std::string formatWallClock(std::chrono::sys_seconds instant) { + auto const days = std::chrono::floor(instant); + std::chrono::year_month_day const date{days}; + std::chrono::hh_mm_ss const clock{instant - days}; + return std::format("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", static_cast(date.year()), + static_cast(date.month()), static_cast(date.day()), clock.hours().count(), + clock.minutes().count(), clock.seconds().count()); +} + +/// @brief Writes an instant as ISO-8601 UTC, `YYYY-MM-DDTHH:MM:SSZ`. +/// @param instant The instant. +/// @return The text. +[[nodiscard]] inline std::string formatUtc(std::chrono::sys_seconds instant) { return formatWallClock(instant) + "Z"; } + +/// @brief Whether @p text is a valid calendar date `YYYY-MM-DD`. +/// @param text The text. +/// @return `true` for a well-formed, existing date. +[[nodiscard]] inline bool isIsoDate(std::string_view text) { + if (text.size() != 10 || text.at(4) != '-' || text.at(7) != '-') { + return false; + } + auto const year = digitsAt(text, 0, 4); + auto const month = digitsAt(text, 5, 2); + auto const day = digitsAt(text, 8, 2); + if (!year || !month || !day) { + return false; + } + return std::chrono::year_month_day{std::chrono::year{*year}, std::chrono::month{static_cast(*month)}, + std::chrono::day{static_cast(*day)}} + .ok(); +} + +} // namespace morph::forms::detail::engine +``` + +Add `#include ` to the include list (used by `multiplyDigits`). Register the header and the test file as +listed under **Files**. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][text]"` +Expected: PASS, 7 test cases. + +Mutation check: in `compareDecimal`, delete the `while (!parts.fraction.empty() && parts.fraction.back() == '0')` +loop. Expected: FAIL in "compareDecimal orders canonical decimals at any magnitude" (`"2.50"` vs `"2.5"` is no longer +equal). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/detail/text.hpp tests/test_forms_engine_text.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): exact text helpers for the forms engine + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 3: `RuleExpr` — `x-rules` parsed, evaluated three-valued and exactly + +**Files:** +- Create: `include/morph/forms/engine/rules.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS` block, after `include/morph/forms/instance_constraints.hpp`, add + `include/morph/forms/engine/rules.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_rules.cpp` after `test_forms_engine_text.cpp` +- Test: `tests/test_forms_engine_rules.cpp` + +**Interfaces:** +- Consumes: Task 2's `detail::engine::{Json, member, stringAt, numberText, isCanonicalInteger, compareDecimal, + compareRationalToDecimal}`; `forms::detail::RuleKind`, `forms::detail::ruleKindName` + (`include/morph/forms/forms.hpp:675`, `:696`) — only in the test, to prove every kind the emitter can write + parses. +- Produces (`morph::forms`): `SchemaError{path, message}`; `Tri{False, True, Unknown}`, `triNot(Tri)`, + `triFromBool(bool)`; `DecimalText{canonical}`; `Scalar = std::variant`; `compareScalars(Scalar const&, Scalar const&) -> std::optional`; + `RuleValue{State{Blank, Invalid, Valid} state; Scalar scalar}`; `RuleLookup = + std::function`; `RuleExpr` with `Kind`, `parse(Json const&, std::string const& path) + -> std::expected`, `kind()`, `kindName()`, `fields()`, `when()`, `condition()`, + `conditions()`, `literal()`, `gates()`, `holds(RuleLookup const&) -> Tri`, `test(RuleLookup const&) -> Tri`; + `gatingVerdict`, `visibleVerdict`, `readonlyVerdict`, `requiredVerdict` (each `(std::span, …, + RuleLookup const&) -> Tri`). + +The three-valued reading, which the corpus rows in Task 9 pin end to end: + +| Node | Blank operand | Invalid operand (typed, does not encode) | Valid operands | +|---|---|---|---| +| `engaged` / `notEngaged` | False / True | Unknown | True / False | +| `equals` | False | Unknown | exact equality; mismatched types are False | +| `greater` … `lessOrEqual` | True (vacuous) | Unknown | exact ordering; incomparable types are Unknown | +| membership kinds | counts as not engaged | Unknown unless the definite count already decides | counted | +| `requiredWhen` (as a rule) | — | — | True unless `when` is True, then `engaged(field)` | +| `visibleWhen` / `readonlyWhen` (as a rule) | — | — | True: they never gate | +| unrecognised kind | — | — | Unknown | + +`and`/`or`/`not` are Kleene. A gating verdict blocks readiness only when it is **False**: the only Unknown that can +reach readiness comes from a kind this engine does not know (an operand that does not encode already blocks through +its own field error), and such a rule is the server's to judge — blocking on it would make every additive extension +of the vocabulary a breaking change for every deployed client. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_rules.cpp` (Task 9 appends the corpus cases to this file): + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using morph::forms::DecimalText; +using morph::forms::RuleExpr; +using morph::forms::RuleLookup; +using morph::forms::RuleValue; +using morph::forms::Scalar; +using morph::forms::Tri; +namespace engine = morph::forms::detail::engine; + +[[nodiscard]] RuleExpr rule(std::string_view json) { + auto const dom = engine::parseJson(json); + REQUIRE(dom.has_value()); + auto parsed = RuleExpr::parse(*dom, "x-rules[0]"); + REQUIRE(parsed.has_value()); + return *std::move(parsed); +} + +[[nodiscard]] RuleValue valid(Scalar scalar) { return RuleValue{RuleValue::State::Valid, std::move(scalar)}; } +[[nodiscard]] RuleValue invalid() { return RuleValue{RuleValue::State::Invalid, {}}; } + +/// The fields a rule sees; an absent name is blank. +struct Fields { + std::map> values{}; + + [[nodiscard]] RuleLookup lookup() const { + return [this](std::string_view name) { + auto const found = values.find(name); + return found == values.end() ? RuleValue{} : found->second; + }; + } +}; + +} // namespace + +TEST_CASE("RuleExpr: every kind the emitter writes parses to a known kind", "[forms-engine][rules]") { + for (std::uint8_t ordinal = 0; ordinal < 16; ++ordinal) { + auto const name = morph::forms::detail::ruleKindName(static_cast(ordinal)); + REQUIRE_FALSE(name.empty()); + std::string json = R"({"kind":")" + std::string{name} + R"(","fields":["a","b"],)"; + json += R"("when":{"kind":"engaged","fields":["a"]},"condition":{"kind":"engaged","fields":["a"]},)"; + json += R"("conditions":[{"kind":"engaged","fields":["a"]}],"value":1})"; + RuleExpr const parsed = rule(json); + INFO(name); + CHECK(parsed.kind() != RuleExpr::Kind::Unknown); + CHECK(parsed.kindName() == name); + } +} + +TEST_CASE("RuleExpr: a malformed rule is a SchemaError naming where it is", "[forms-engine][rules]") { + auto const reject = [](std::string_view json) { + auto const dom = engine::parseJson(json); + REQUIRE(dom.has_value()); + return RuleExpr::parse(*dom, "x-rules[3]"); + }; + auto const noKind = reject(R"({"fields":["a"]})"); + REQUIRE_FALSE(noKind.has_value()); + CHECK(noKind.error().path == "x-rules[3]"); + CHECK_FALSE(reject(R"({"kind":"engaged","fields":"a"})").has_value()); + CHECK_FALSE(reject(R"({"kind":"requiredWhen","fields":["a"]})").has_value()); + CHECK_FALSE(reject(R"({"kind":"greater","fields":["a"]})").has_value()); + auto const nested = reject(R"({"kind":"and","conditions":[{"kind":"engaged"}]})"); + REQUIRE_FALSE(nested.has_value()); + CHECK(nested.error().path == "x-rules[3].conditions[0]"); + CHECK(reject(R"({"kind":"quantumEntangled"})").has_value()); // unknown is not malformed +} + +TEST_CASE("RuleExpr: and, or and not are Kleene over Unknown", "[forms-engine][rules]") { + Fields const fields{{{"yes", valid(std::string{"x"})}, {"bad", invalid()}}}; + auto const lookup = fields.lookup(); + auto const verdict = [&](std::string_view json) { return rule(json).test(lookup); }; + CHECK(verdict(R"({"kind":"and","conditions":[{"kind":"engaged","fields":["no"]},{"kind":"engaged","fields":["bad"]}]})") == Tri::False); + CHECK(verdict(R"({"kind":"and","conditions":[{"kind":"engaged","fields":["yes"]},{"kind":"engaged","fields":["bad"]}]})") == Tri::Unknown); + CHECK(verdict(R"({"kind":"or","conditions":[{"kind":"engaged","fields":["yes"]},{"kind":"engaged","fields":["bad"]}]})") == Tri::True); + CHECK(verdict(R"({"kind":"or","conditions":[{"kind":"engaged","fields":["no"]},{"kind":"engaged","fields":["bad"]}]})") == Tri::Unknown); + CHECK(verdict(R"({"kind":"not","condition":{"kind":"engaged","fields":["bad"]}})") == Tri::Unknown); + CHECK(verdict(R"({"kind":"not","condition":{"kind":"engaged","fields":["no"]}})") == Tri::True); +} + +// Review Focus 4. +TEST_CASE("RuleExpr: an unknown kind never blocks, and a false conjunct still does", "[forms-engine][rules]") { + Fields fields{}; + auto const lookup = fields.lookup(); + std::vector const unknownTop{rule(R"({"kind":"quantumEntangled","fields":["email","note"]})")}; + CHECK(morph::forms::gatingVerdict(unknownTop, lookup) == Tri::Unknown); + + std::vector const insideNot{ + rule(R"({"kind":"requiredWhen","fields":["note"],"when":{"kind":"not","condition":{"kind":"quantumEntangled","fields":["email"]}}})")}; + CHECK(morph::forms::gatingVerdict(insideNot, lookup) == Tri::True); + CHECK(morph::forms::requiredVerdict(insideNot, "note", lookup) == Tri::Unknown); + + std::vector const falseConjunct{rule( + R"({"kind":"and","conditions":[{"kind":"engaged","fields":["email"]},{"kind":"quantumEntangled","fields":["note"]}]})")}; + CHECK(morph::forms::gatingVerdict(falseConjunct, lookup) == Tri::False); + fields.values.emplace("email", valid(std::string{"a@b"})); + CHECK(morph::forms::gatingVerdict(falseConjunct, lookup) == Tri::Unknown); + + std::vector const presentation{ + rule(R"({"kind":"visibleWhen","fields":["note"],"when":{"kind":"quantumEntangled","fields":["email"]}})"), + rule(R"({"kind":"readonlyWhen","fields":["note"],"when":{"kind":"quantumEntangled","fields":["email"]}})")}; + CHECK(morph::forms::visibleVerdict(presentation, "note", lookup) == Tri::Unknown); // presents as visible + CHECK(morph::forms::readonlyVerdict(presentation, "note", lookup) == Tri::Unknown); // presents as editable + CHECK(morph::forms::gatingVerdict(presentation, lookup) == Tri::True); +} + +TEST_CASE("RuleExpr: comparisons are vacuous on a blank operand and exact on valid ones", "[forms-engine][rules]") { + using morph::math::DecimalPlaces; + using morph::math::Denominator; + using morph::math::Numerator; + using morph::math::Rational; + Fields fields{}; + RuleExpr const greater = rule(R"({"kind":"greater","fields":["high","low"]})"); + CHECK(greater.test(fields.lookup()) == Tri::True); + fields.values["high"] = valid(Rational{Numerator{2}, Denominator{1}, DecimalPlaces{2}}); + CHECK(greater.test(fields.lookup()) == Tri::True); + fields.values["low"] = valid(Rational{Numerator{200}, Denominator{100}, DecimalPlaces{2}}); + CHECK(greater.test(fields.lookup()) == Tri::False); + fields.values["low"] = valid(Rational{Numerator{199}, Denominator{100}, DecimalPlaces{2}}); + CHECK(greater.test(fields.lookup()) == Tri::True); + fields.values["low"] = invalid(); + CHECK(greater.test(fields.lookup()) == Tri::Unknown); + fields.values["low"] = valid(std::string{"text"}); + CHECK(greater.test(fields.lookup()) == Tri::Unknown); // a number and a string do not order + fields.values["high"] = valid(DecimalText{"9223372036854775808"}); + fields.values["low"] = valid(DecimalText{"9223372036854775807"}); + CHECK(greater.test(fields.lookup()) == Tri::True); +} + +TEST_CASE("RuleExpr: equals compares booleans as booleans and big integers on digits", "[forms-engine][rules]") { + Fields fields{}; + RuleExpr const flag = rule(R"({"kind":"equals","fields":["flag"],"value":true})"); + CHECK(flag.holds(fields.lookup()) == Tri::False); + fields.values["flag"] = valid(true); + CHECK(flag.holds(fields.lookup()) == Tri::True); + fields.values["flag"] = valid(std::string{"true"}); + CHECK(flag.holds(fields.lookup()) == Tri::False); // a string is not the boolean + + RuleExpr const big = + rule(R"({"kind":"equals","fields":["id"],"value":9007199254740993,"valueText":"9007199254740993"})"); + fields.values["id"] = valid(DecimalText{"9007199254740992"}); + CHECK(big.holds(fields.lookup()) == Tri::False); + fields.values["id"] = valid(DecimalText{"9007199254740993"}); + CHECK(big.holds(fields.lookup()) == Tri::True); + + RuleExpr const exactValue = rule(R"({"kind":"equals","fields":["amount"],"value":{"num":5,"den":2}})"); + fields.values["amount"] = valid(DecimalText{"2.50"}); + CHECK(exactValue.holds(fields.lookup()) == Tri::True); +} + +TEST_CASE("RuleExpr: membership counts definite operands and is Unknown only when the count is open", + "[forms-engine][rules]") { + Fields fields{}; + RuleExpr const one = rule(R"({"kind":"exactlyOneOf","fields":["a","b","c"]})"); + RuleExpr const least = rule(R"({"kind":"atLeastOneOf","fields":["a","b","c"]})"); + RuleExpr const most = rule(R"({"kind":"mutuallyExclusive","fields":["a","b","c"]})"); + CHECK(one.test(fields.lookup()) == Tri::False); + CHECK(least.test(fields.lookup()) == Tri::False); + CHECK(most.test(fields.lookup()) == Tri::True); + fields.values["a"] = invalid(); + CHECK(one.test(fields.lookup()) == Tri::Unknown); + CHECK(least.test(fields.lookup()) == Tri::Unknown); + CHECK(most.test(fields.lookup()) == Tri::True); + fields.values["b"] = valid(std::string{"x"}); + fields.values["c"] = valid(std::string{"y"}); + CHECK(one.test(fields.lookup()) == Tri::False); + CHECK(least.test(fields.lookup()) == Tri::True); + CHECK(most.test(fields.lookup()) == Tri::False); +} + +TEST_CASE("RuleExpr: presentation verdicts combine every rule naming the field", "[forms-engine][rules]") { + Fields fields{}; + std::vector const rules{ + rule(R"({"kind":"visibleWhen","fields":["discount"],"when":{"kind":"engaged","fields":["promo"]}})"), + rule(R"({"kind":"requiredWhen","fields":["discount"],"when":{"kind":"engaged","fields":["promo"]}})"), + rule(R"({"kind":"readonlyWhen","fields":["total"],"when":{"kind":"engaged","fields":["promo"]}})")}; + CHECK(morph::forms::visibleVerdict(rules, "discount", fields.lookup()) == Tri::False); + CHECK(morph::forms::visibleVerdict(rules, "total", fields.lookup()) == Tri::True); + CHECK(morph::forms::requiredVerdict(rules, "discount", fields.lookup()) == Tri::False); + CHECK(morph::forms::readonlyVerdict(rules, "total", fields.lookup()) == Tri::False); + fields.values["promo"] = valid(DecimalText{"5"}); + CHECK(morph::forms::visibleVerdict(rules, "discount", fields.lookup()) == Tri::True); + CHECK(morph::forms::requiredVerdict(rules, "discount", fields.lookup()) == Tri::True); + CHECK(morph::forms::readonlyVerdict(rules, "total", fields.lookup()) == Tri::True); + CHECK(morph::forms::gatingVerdict(rules, fields.lookup()) == Tri::False); // discount now required, blank +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: `fatal error: 'morph/forms/engine/rules.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `include/morph/forms/engine/rules.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/rules.hpp +/// @brief `x-rules` as a parsed `RuleExpr` tree: three-valued, exact evaluation over a form's +/// encoded values, and the presentation queries the view binds to. +/// +/// Specified in `docs/spec/forms/engine.md`, "Rules". + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../util/rational.hpp" +#include "detail/text.hpp" + +namespace morph::forms { + +/// @brief Why a schema document could not be read. +struct SchemaError { + /// @brief Where in the document: empty for the root, else e.g. `properties.mass` or `x-rules[2].when`. + std::string path{}; + /// @brief What is wrong there. + std::string message{}; + /// @brief Member-wise equality. + /// @param other The other error. + /// @return `true` when path and message match. + bool operator==(SchemaError const& other) const = default; +}; + +/// @brief A three-valued truth value: a rule over a half-typed form can be undecidable. +enum class Tri : std::uint8_t { + False, ///< Definitely does not hold. + True, ///< Definitely holds. + Unknown ///< Cannot be decided: an operand does not encode, or the kind is not one this engine knows. +}; + +/// @brief Kleene negation. +/// @param value The operand. +/// @return `True` for `False`, `False` for `True`, `Unknown` for `Unknown`. +[[nodiscard]] constexpr Tri triNot(Tri value) noexcept { + switch (value) { + case Tri::False: + return Tri::True; + case Tri::True: + return Tri::False; + default: + return Tri::Unknown; + } +} + +/// @brief Lifts a definite boolean. +/// @param value The boolean. +/// @return `True` or `False`. +[[nodiscard]] constexpr Tri triFromBool(bool value) noexcept { return value ? Tri::True : Tri::False; } + +/// @brief A canonical decimal (`-?[0-9]+(\.[0-9]+)?`) compared on its digits, never through a double. +struct DecimalText { + /// @brief The canonical text. + std::string canonical{}; + /// @brief Textual equality; numeric equality is `compareScalars`. + /// @param other The other decimal. + /// @return `true` when the texts match. + bool operator==(DecimalText const& other) const = default; +}; + +/// @brief An exact scalar a rule compares: none, a boolean, a string, a decimal, or a rational. +using Scalar = std::variant; + +/// @brief Orders two scalars exactly, when they are of comparable kinds. +/// +/// Booleans order `false < true`; strings byte-wise (ISO-8601 instants therefore in time order); +/// decimals and rationals numerically, against each other too. Any other pairing has no order. +/// @param lhs Left scalar. +/// @param rhs Right scalar. +/// @return The ordering, or `std::nullopt` when the two do not compare. +[[nodiscard]] inline std::optional compareScalars(Scalar const& lhs, Scalar const& rhs) { + if (auto const* const left = std::get_if(&lhs)) { + if (auto const* const right = std::get_if(&rhs)) { + return *left <=> *right; + } + return std::nullopt; + } + if (auto const* const left = std::get_if(&lhs)) { + if (auto const* const right = std::get_if(&rhs)) { + return *left <=> *right; + } + return std::nullopt; + } + auto const* const leftDecimal = std::get_if(&lhs); + auto const* const rightDecimal = std::get_if(&rhs); + auto const* const leftRational = std::get_if(&lhs); + auto const* const rightRational = std::get_if(&rhs); + if (leftDecimal != nullptr && rightDecimal != nullptr) { + return detail::engine::compareDecimal(leftDecimal->canonical, rightDecimal->canonical); + } + if (leftRational != nullptr && rightRational != nullptr) { + return *leftRational <=> *rightRational; + } + if (leftRational != nullptr && rightDecimal != nullptr) { + return detail::engine::compareRationalToDecimal(*leftRational, rightDecimal->canonical); + } + if (leftDecimal != nullptr && rightRational != nullptr) { + return 0 <=> detail::engine::compareRationalToDecimal(*rightRational, leftDecimal->canonical); + } + return std::nullopt; +} + +/// @brief What a rule sees of one field. +struct RuleValue { + /// @brief The field's draft, as far as rules are concerned. + enum class State : std::uint8_t { + Blank, ///< Nothing entered. + Invalid, ///< Something entered that does not encode. + Valid ///< Entered and encodes; `scalar` holds the exact value. + }; + /// @brief The draft's state. + State state = State::Blank; + /// @brief The exact value when `state` is `Valid`. + Scalar scalar{}; +}; + +/// @brief Looks a top-level field up by wire name; an unknown name reads as blank. +using RuleLookup = std::function; + +/// @brief One parsed `x-rules` node: a rule, or a condition nested inside one. +class RuleExpr { +public: + /// @brief The node kinds `forms.hpp` emits, plus `Unknown` for any other `"kind"`. + enum class Kind : std::uint8_t { + Engaged, + NotEngaged, + Equals, + Greater, + GreaterOrEqual, + Less, + LessOrEqual, + RequiredWhen, + ExactlyOneOf, + AtLeastOneOf, + MutuallyExclusive, + VisibleWhen, + ReadonlyWhen, + And, + Or, + Not, + Unknown + }; + + /// @brief Parses one node. + /// + /// An unrecognised `"kind"` is kept as `Kind::Unknown`, not refused: it is a rule this engine + /// cannot judge, and the server, which knows it, still does. A recognised kind missing what + /// it needs (`fields`, `when`, `condition`, `conditions`) is malformed. + /// @param node The node. + /// @param path Where it is, for the error (e.g. `x-rules[2]`). + /// @return The node, or the error naming where the document is malformed. + // NOLINTNEXTLINE(misc-no-recursion) -- a condition tree is parsed by the same function, node by node + [[nodiscard]] static std::expected parse(detail::engine::Json const& node, + std::string const& path) { + using detail::engine::Json; + if (!node.is_object()) { + return std::unexpected(SchemaError{path, "a rule must be a JSON object"}); + } + auto const* const kind = detail::engine::stringAt(node, "kind"); + if (kind == nullptr) { + return std::unexpected(SchemaError{path, "a rule needs a string \"kind\""}); + } + RuleExpr expr{}; + expr._kindName = *kind; + expr._kind = kindOf(*kind); + if (Json const* const fields = detail::engine::member(node, "fields")) { + if (!fields->is_array()) { + return std::unexpected(SchemaError{path, "\"fields\" must be an array of strings"}); + } + for (Json const& name : fields->get_array()) { + auto const* const text = name.get_if(); + if (text == nullptr) { + return std::unexpected(SchemaError{path, "\"fields\" must be an array of strings"}); + } + expr._fields.push_back(*text); + } + } + auto child = [&](std::string_view key) -> std::expected { + Json const* const nested = detail::engine::member(node, key); + if (nested == nullptr) { + return std::unexpected(SchemaError{path, expr._kindName + " needs \"" + std::string{key} + "\""}); + } + auto parsed = parse(*nested, path + "." + std::string{key}); + if (!parsed) { + return std::unexpected(parsed.error()); + } + expr._children.push_back(*std::move(parsed)); + return {}; + }; + auto const needFields = [&](std::size_t count, bool exact) -> std::expected { + if (exact ? expr._fields.size() != count : expr._fields.size() < count) { + return std::unexpected(SchemaError{path, "\"" + expr._kindName + "\" needs " + + (exact ? "exactly " : "at least ") + + std::to_string(count) + " field(s)"}); + } + return {}; + }; + std::expected shape{}; + switch (expr._kind) { + case Kind::RequiredWhen: + case Kind::VisibleWhen: + case Kind::ReadonlyWhen: + shape = needFields(1, false); + if (shape) { + shape = child("when"); + } + break; + case Kind::Not: + shape = child("condition"); + break; + case Kind::And: + case Kind::Or: { + Json const* const list = detail::engine::member(node, "conditions"); + if (list == nullptr || !list->is_array()) { + shape = std::unexpected(SchemaError{path, "\"" + expr._kindName + "\" needs a \"conditions\" array"}); + break; + } + std::size_t index = 0; + for (Json const& nested : list->get_array()) { + auto parsed = parse(nested, path + ".conditions[" + std::to_string(index++) + "]"); + if (!parsed) { + return std::unexpected(parsed.error()); + } + expr._children.push_back(*std::move(parsed)); + } + break; + } + case Kind::Greater: + case Kind::GreaterOrEqual: + case Kind::Less: + case Kind::LessOrEqual: + shape = needFields(2, true); + break; + case Kind::Equals: + shape = needFields(1, false); + expr._literal = literalOf(node); + break; + case Kind::Engaged: + case Kind::NotEngaged: + case Kind::ExactlyOneOf: + case Kind::AtLeastOneOf: + case Kind::MutuallyExclusive: + shape = needFields(1, false); + break; + default: + break; + } + if (!shape) { + return std::unexpected(shape.error()); + } + return expr; + } + + /// @brief The node's kind. + /// @return The kind; `Unknown` for a `"kind"` this engine does not know. + [[nodiscard]] Kind kind() const noexcept { return _kind; } + + /// @brief The `"kind"` string as written. + /// @return The name. + [[nodiscard]] std::string_view kindName() const noexcept { return _kindName; } + + /// @brief The wire names the node ranges over. + /// @return The `fields` list; empty for the compound kinds. + [[nodiscard]] std::span fields() const noexcept { return _fields; } + + /// @brief The `when` clause of `requiredWhen`, `visibleWhen` or `readonlyWhen`. + /// @return The clause, or null for any other kind. + [[nodiscard]] RuleExpr const* when() const noexcept { + bool const hasWhen = _kind == Kind::RequiredWhen || _kind == Kind::VisibleWhen || _kind == Kind::ReadonlyWhen; + return hasWhen && !_children.empty() ? &_children.front() : nullptr; + } + + /// @brief The negated condition of `not`. + /// @return The condition, or null for any other kind. + [[nodiscard]] RuleExpr const* condition() const noexcept { + return _kind == Kind::Not && !_children.empty() ? &_children.front() : nullptr; + } + + /// @brief The operands of `and` / `or`. + /// @return The conditions; empty for any other kind. + [[nodiscard]] std::span conditions() const noexcept { + return (_kind == Kind::And || _kind == Kind::Or) ? std::span{_children} + : std::span{}; + } + + /// @brief The literal `equals` compares against. + /// @return The literal; `std::monostate` for any other kind. + [[nodiscard]] Scalar const& literal() const noexcept { return _literal; } + + /// @brief Whether this node, as a top-level rule, can block submission. + /// @return `false` for `visibleWhen` / `readonlyWhen`, which only present. + [[nodiscard]] bool gates() const noexcept { return _kind != Kind::VisibleWhen && _kind != Kind::ReadonlyWhen; } + + /// @brief Evaluates the node as a condition (inside `when`, `and`, `or`, `not`). + /// @param lookup The form's field values. + /// @return The verdict; `Unknown` for the rule-only kinds nested as a condition. + // NOLINTNEXTLINE(misc-no-recursion) -- and/or/not evaluate their operands with the same function + [[nodiscard]] Tri holds(RuleLookup const& lookup) const { + switch (_kind) { + case Kind::Engaged: + return engaged(lookup(_fields.front())); + case Kind::NotEngaged: + return triNot(engaged(lookup(_fields.front()))); + case Kind::Equals: + return equals(lookup(_fields.front())); + case Kind::Greater: + case Kind::GreaterOrEqual: + case Kind::Less: + case Kind::LessOrEqual: + return compare(lookup(_fields.front()), lookup(_fields.back())); + case Kind::ExactlyOneOf: + case Kind::AtLeastOneOf: + case Kind::MutuallyExclusive: + return membership(lookup); + case Kind::And: { + Tri verdict = Tri::True; + for (RuleExpr const& operand : _children) { + Tri const part = operand.holds(lookup); + if (part == Tri::False) { + return Tri::False; + } + if (part == Tri::Unknown) { + verdict = Tri::Unknown; + } + } + return verdict; + } + case Kind::Or: { + Tri verdict = Tri::False; + for (RuleExpr const& operand : _children) { + Tri const part = operand.holds(lookup); + if (part == Tri::True) { + return Tri::True; + } + if (part == Tri::Unknown) { + verdict = Tri::Unknown; + } + } + return verdict; + } + case Kind::Not: + return triNot(_children.front().holds(lookup)); + default: + return Tri::Unknown; + } + } + + /// @brief Evaluates the node as a top-level rule: whether it lets the form submit. + /// @param lookup The form's field values. + /// @return The gating verdict; `True` for the presentation kinds. + [[nodiscard]] Tri test(RuleLookup const& lookup) const { + switch (_kind) { + case Kind::RequiredWhen: + if (_children.front().holds(lookup) != Tri::True) { + return Tri::True; + } + return engaged(lookup(_fields.front())); + case Kind::VisibleWhen: + case Kind::ReadonlyWhen: + return Tri::True; + default: + return holds(lookup); + } + } + +private: + [[nodiscard]] static Kind kindOf(std::string_view name) noexcept { + constexpr std::array, 16> kNames{{ + {"engaged", Kind::Engaged}, + {"notEngaged", Kind::NotEngaged}, + {"equals", Kind::Equals}, + {"greater", Kind::Greater}, + {"greaterOrEqual", Kind::GreaterOrEqual}, + {"less", Kind::Less}, + {"lessOrEqual", Kind::LessOrEqual}, + {"requiredWhen", Kind::RequiredWhen}, + {"exactlyOneOf", Kind::ExactlyOneOf}, + {"atLeastOneOf", Kind::AtLeastOneOf}, + {"mutuallyExclusive", Kind::MutuallyExclusive}, + {"visibleWhen", Kind::VisibleWhen}, + {"readonlyWhen", Kind::ReadonlyWhen}, + {"and", Kind::And}, + {"or", Kind::Or}, + {"not", Kind::Not}, + }}; + auto const found = std::ranges::find(kNames, name, &std::pair::first); + return found == kNames.end() ? Kind::Unknown : found->second; + } + + [[nodiscard]] static Scalar literalOf(detail::engine::Json const& node) { + using detail::engine::Json; + if (auto const* const digits = detail::engine::stringAt(node, "valueText"); + digits != nullptr && detail::engine::isCanonicalInteger(*digits)) { + return DecimalText{*digits}; + } + Json const* const value = detail::engine::member(node, "value"); + if (value == nullptr) { + return std::monostate{}; + } + if (auto const* const flag = value->get_if()) { + return *flag; + } + if (auto const* const text = value->get_if()) { + return *text; + } + if (auto number = detail::engine::numberText(*value)) { + return DecimalText{*std::move(number)}; + } + auto const integer = [](Json const* part) -> std::optional { + if (part == nullptr) { + return std::nullopt; + } + if (auto const* const asSigned = part->get_if()) { + return *asSigned; + } + if (auto const* const asUnsigned = part->get_if(); + asUnsigned != nullptr && std::in_range(*asUnsigned)) { + return static_cast(*asUnsigned); + } + return std::nullopt; + }; + auto const num = integer(detail::engine::member(*value, "num")); + auto const den = integer(detail::engine::member(*value, "den")); + if (num && den && *den != 0) { + return math::Rational{math::Numerator{*num}, math::Denominator{*den}, math::DecimalPlaces{0}}; + } + return std::monostate{}; + } + + [[nodiscard]] static Tri engaged(RuleValue const& value) noexcept { + switch (value.state) { + case RuleValue::State::Blank: + return Tri::False; + case RuleValue::State::Valid: + return Tri::True; + default: + return Tri::Unknown; + } + } + + [[nodiscard]] Tri equals(RuleValue const& value) const { + if (value.state == RuleValue::State::Blank) { + return Tri::False; + } + if (value.state == RuleValue::State::Invalid) { + return Tri::Unknown; + } + auto const order = compareScalars(value.scalar, _literal); + return triFromBool(order.has_value() && *order == std::strong_ordering::equal); + } + + [[nodiscard]] Tri compare(RuleValue const& lhs, RuleValue const& rhs) const { + if (lhs.state == RuleValue::State::Blank || rhs.state == RuleValue::State::Blank) { + return Tri::True; + } + if (lhs.state == RuleValue::State::Invalid || rhs.state == RuleValue::State::Invalid) { + return Tri::Unknown; + } + auto const order = compareScalars(lhs.scalar, rhs.scalar); + if (!order) { + return Tri::Unknown; + } + switch (_kind) { + case Kind::Greater: + return triFromBool(*order == std::strong_ordering::greater); + case Kind::GreaterOrEqual: + return triFromBool(*order != std::strong_ordering::less); + case Kind::Less: + return triFromBool(*order == std::strong_ordering::less); + default: + return triFromBool(*order != std::strong_ordering::greater); + } + } + + [[nodiscard]] Tri membership(RuleLookup const& lookup) const { + std::size_t definite = 0; + std::size_t open = 0; + for (std::string const& name : _fields) { + Tri const one = engaged(lookup(name)); + definite += one == Tri::True ? 1U : 0U; + open += one == Tri::Unknown ? 1U : 0U; + } + switch (_kind) { + case Kind::ExactlyOneOf: + if (definite >= 2) { + return Tri::False; + } + if (open == 0) { + return triFromBool(definite == 1); + } + return Tri::Unknown; + case Kind::AtLeastOneOf: + if (definite >= 1) { + return Tri::True; + } + return open == 0 ? Tri::False : Tri::Unknown; + default: + if (definite >= 2) { + return Tri::False; + } + return definite + open <= 1 ? Tri::True : Tri::Unknown; + } + } + + Kind _kind = Kind::Unknown; + std::string _kindName{}; + std::vector _fields{}; + std::vector _children{}; + Scalar _literal{}; +}; + +/// @brief Whether a rule list lets the form submit. +/// @param rules The parsed `x-rules`. +/// @param lookup The form's field values. +/// @return `False` when any rule fails, else `Unknown` when any is undecidable, else `True`. +[[nodiscard]] inline Tri gatingVerdict(std::span rules, RuleLookup const& lookup) { + Tri verdict = Tri::True; + for (RuleExpr const& entry : rules) { + Tri const one = entry.test(lookup); + if (one == Tri::False) { + return Tri::False; + } + if (one == Tri::Unknown) { + verdict = Tri::Unknown; + } + } + return verdict; +} + +namespace detail::engine { + +/// @brief Combines the `when` verdicts of every rule of @p kind naming @p field. +/// @param rules The parsed `x-rules`. +/// @param kind The presentation kind. +/// @param field The wire name. +/// @param lookup The form's field values. +/// @param decisive The verdict that settles the answer as soon as one rule gives it. +/// @param absent The answer when no rule names the field. +/// @return @p decisive if any rule gives it, else `Unknown` if any is undecidable, else the other value +/// (or @p absent when no rule names the field). +[[nodiscard]] inline Tri presentation(std::span rules, RuleExpr::Kind kind, std::string_view field, + RuleLookup const& lookup, Tri decisive, Tri absent) { + bool named = false; + bool unknown = false; + for (RuleExpr const& entry : rules) { + if (entry.kind() != kind || entry.fields().empty() || entry.fields().front() != field) { + continue; + } + named = true; + Tri const when = entry.when()->holds(lookup); + if (when == decisive) { + return decisive; + } + unknown = unknown || when == Tri::Unknown; + } + if (!named) { + return absent; + } + return unknown ? Tri::Unknown : triNot(decisive); +} + +} // namespace detail::engine + +/// @brief Whether `visibleWhen` shows @p field. Present `Unknown` as visible. +/// @param rules The parsed `x-rules`. +/// @param field The wire name. +/// @param lookup The form's field values. +/// @return `False` when any rule's condition is false; `True` when none names the field. +[[nodiscard]] inline Tri visibleVerdict(std::span rules, std::string_view field, + RuleLookup const& lookup) { + return detail::engine::presentation(rules, RuleExpr::Kind::VisibleWhen, field, lookup, Tri::False, Tri::True); +} + +/// @brief Whether `readonlyWhen` freezes @p field. Present `Unknown` as editable. +/// @param rules The parsed `x-rules`. +/// @param field The wire name. +/// @param lookup The form's field values. +/// @return `True` when any rule's condition is true; `False` when none names the field. +[[nodiscard]] inline Tri readonlyVerdict(std::span rules, std::string_view field, + RuleLookup const& lookup) { + return detail::engine::presentation(rules, RuleExpr::Kind::ReadonlyWhen, field, lookup, Tri::True, Tri::False); +} + +/// @brief Whether `requiredWhen` currently requires @p field. Present `Unknown` as not required. +/// @param rules The parsed `x-rules`. +/// @param field The wire name. +/// @param lookup The form's field values. +/// @return `True` when any rule's condition is true; `False` when none names the field. +[[nodiscard]] inline Tri requiredVerdict(std::span rules, std::string_view field, + RuleLookup const& lookup) { + return detail::engine::presentation(rules, RuleExpr::Kind::RequiredWhen, field, lookup, Tri::True, Tri::False); +} + +} // namespace morph::forms +``` + +Register the header and the test as listed under **Files**. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][rules]"` +Expected: PASS, 8 test cases. + +Mutation check: in `RuleExpr::compare`, change the blank branch's `return Tri::True;` to `return Tri::False;`. +Expected: FAIL in "comparisons are vacuous on a blank operand and exact on valid ones". Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/rules.hpp tests/test_forms_engine_rules.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): x-rules as RuleExpr, three-valued and exact + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 4: `FormModel::fromSchema` — the field model + +**Files:** +- Create: `include/morph/forms/engine/field_model.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/forms/engine/rules.hpp`, add + `include/morph/forms/engine/field_model.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_model.cpp` after `test_forms_engine_rules.cpp` +- Test: `tests/test_forms_engine_model.cpp` + +**Interfaces:** +- Consumes: Task 2's text helpers; Task 3's `SchemaError`, `RuleExpr`; `forms::BlankAs` + (`include/morph/forms/forms.hpp:197`); `forms::GroupKind` (`include/morph/forms/layout.hpp`); + `forms::schemaJson()` (`include/morph/forms/forms.hpp:3594`); `model::ActionTraits::typeId()` + (`include/morph/core/registry.hpp`). +- Produces (`morph::forms`), exactly as later tasks use them: + - `FieldKind{Text, Multiline, Integer, Number, Decimal, Quantity, Boolean, Enum, Choice, DateTime, Date, Slider, + Array, Object, ObjectArray}` (the contract's enumerators) and `fieldKindName(FieldKind) -> std::string_view`. + - `SubmitMode{Automatic, Explicit}`. + - `kMaxNestingDepth` (= 4): how many object levels below the action a schema is described. + - `UnitOption{id, display, decimals, num, den}`, `ChoiceOption{valueJson, label}`, `ChoiceSpec{optionsAction, + valueField, labelField, dependsOn}`. + - `FieldSpec` with the members listed in Step 3; `FieldGroupSpec{title, kind, fields, implicit}`. + - `FormModel`: `fromSchema(std::string_view actionType, std::string_view schemaJson) -> std::expected`, the contract's `template static FormModel forAction()` (throws `std::logic_error` + naming the action id, the error's path and message when the engine cannot read the action's own schema), + `actionType()`, `title()`, `fields()`, `groups()`, `rules()`, `submitMode()`, and the addition + `find(std::string_view name) const -> FieldSpec const*`. + - In `detail::engine`: `contains(range, value)` and `elementAt(std::span, std::size_t) -> T&` (bounds-checked; + a span has no `at()` before C++26) — every later engine header indexes spans through it. + +A field's kind is decided in the order the QML renderer's encoder asked, so a schema keeps the control it had: +array (of objects → `ObjectArray`, else `Array`); a closed set → `Enum`; `x-optionsAction` → `Choice`; +`format: date-time` → `DateTime`; `format: date` → `Date`; `x-decimalPlaces` → `Quantity` with `ExtUnits`, else +`Decimal`; `x-widget: slider` with `x-min`/`x-max` on a numeric type → `Slider`; `integer`; `boolean`; `number`; +`object` → `Object`; `x-widget: textarea` → `Multiline`; else `Text`. Object members are described to +`kMaxNestingDepth` levels and stop at a `$ref` already on the path — a self-referential type is described once, +and the member where it would repeat is `truncated` (Task 7 says how a truncated member encodes). + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_model.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// File-scope, not anonymous-namespaced: glaze's reflection needs linkage. +// NOLINTBEGIN(misc-use-internal-linkage) +struct FmBooking { + std::int64_t guests = 0; + std::optional note{}; +}; +struct FmBooked { + bool confirmed = false; +}; +struct FmModel { + FmBooked execute(FmBooking const&) { return FmBooked{.confirmed = true}; } +}; +// NOLINTEND(misc-use-internal-linkage) + +BRIDGE_REGISTER_MODEL(FmModel, "Test_FormsEngineModel_Model") +BRIDGE_REGISTER_ACTION(FmModel, FmBooking, "Test_FormsEngineModel_Booking") + +namespace { + +using morph::forms::FieldKind; +using morph::forms::FieldSpec; +using morph::forms::FormModel; +using morph::forms::GroupKind; + +[[nodiscard]] FormModel model(std::string_view action, std::string_view schema) { + auto parsed = FormModel::fromSchema(action, schema); + INFO((parsed ? std::string{} : parsed.error().path + ": " + parsed.error().message)); + REQUIRE(parsed.has_value()); + return *std::move(parsed); +} + +[[nodiscard]] std::string names(std::span fields) { + std::string out{}; + for (auto const& field : fields) { + out += (out.empty() ? "" : ",") + field.name; + } + return out; +} + +constexpr std::string_view kProbe = R"({"type":"object","properties":{ + "slot":{"type":["integer","null"],"x-order":0,"title":"Slot","x-optionsAction":"ListSlots","x-optionValue":"id","x-optionLabel":"name"}, + "mass":{"$ref":"#/$defs/q","x-order":1,"x-decimalPlaces":3,"title":"Mass","x-placeholder":"e.g. 1050", + "x-unitAlternatives":[{"id":"g","display":"g","decimals":1,"num":1,"den":1000},{"id":"t","display":"t","decimals":4,"num":1000,"den":1}]}, + "when":{"type":["string","null"],"format":"date-time","x-order":2,"title":"When","x-readonly":true}, + "note":{"type":["string","null"],"x-order":3,"title":"Notes","x-hidden":true}}, + "$defs":{"q":{"type":["object","null"],"ExtUnits":{"unitAscii":"kg","unitUnicode":"kg"}}}, + "required":["slot","mass","when"]})"; + +} // namespace + +TEST_CASE("FormModel: kinds, units, the Choice descriptor and flags come from the schema", "[forms-engine][model]") { + FormModel const probe = model("Probe", kProbe); + CHECK(names(probe.fields()) == "slot,mass,when,note"); + FieldSpec const& slot = probe.fields()[0]; + CHECK(slot.kind == FieldKind::Choice); + REQUIRE(slot.choice.has_value()); + CHECK(slot.choice->optionsAction == "ListSlots"); + CHECK(slot.choice->valueField == "id"); + CHECK(slot.choice->labelField == "name"); + CHECK(slot.required); + CHECK(slot.title == "Slot"); + FieldSpec const& mass = probe.fields()[1]; + CHECK(mass.kind == FieldKind::Quantity); + CHECK(mass.decimalPlaces == 3U); + CHECK(mass.unit == "kg"); + REQUIRE(mass.units.size() == 3); + CHECK(mass.units[1].display == "g"); + CHECK(mass.units[2].num == 1000); + CHECK(mass.placeholder == "e.g. 1050"); + FieldSpec const& when = probe.fields()[2]; + CHECK(when.kind == FieldKind::DateTime); + CHECK(when.readOnly); + FieldSpec const& note = probe.fields()[3]; + CHECK(note.kind == FieldKind::Text); + CHECK_FALSE(note.required); + CHECK(note.hidden); + CHECK(probe.submitMode() == morph::forms::SubmitMode::Automatic); + CHECK(probe.actionType() == "Probe"); +} + +TEST_CASE("FormModel: widget hints become Multiline, Slider and a radio Choice", "[forms-engine][model]") { + FormModel const hints = model("WidgetHintsProbe", R"({"properties":{ + "summary":{"type":["string","null"],"x-order":0,"x-widget":"textarea"}, + "level":{"type":["integer","null"],"x-order":1,"x-widget":"slider","x-min":0,"x-max":100,"x-step":5}, + "mode":{"type":["integer","null"],"x-order":2,"x-widget":"radio","x-optionsAction":"ListModes"}, + "plain":{"type":["string","null"],"x-order":3}},"required":["summary","level","mode"]})"); + CHECK(hints.fields()[0].kind == FieldKind::Multiline); + FieldSpec const& level = hints.fields()[1]; + CHECK(level.kind == FieldKind::Slider); + CHECK(level.sliderMin == 0); + CHECK(level.sliderMax == 100); + CHECK(level.sliderStep == 5); + CHECK(hints.fields()[2].kind == FieldKind::Choice); + CHECK(hints.fields()[2].widget == "radio"); + CHECK(hints.fields()[3].kind == FieldKind::Text); +} + +TEST_CASE("FormModel: every renderer kind is named", "[forms-engine][model]") { + FormModel const kinds = model("T_Kind", R"({"$defs":{"double":{"type":"number"}, + "Row":{"type":"object","properties":{"a":{"type":"integer","x-order":0}},"required":["a"]}, + "Sub":{"type":"object","properties":{"b":{"type":"string","x-order":0}}}}, + "properties":{ + "mass":{"type":["object","null"],"properties":{"num":{"type":"integer"},"den":{"type":"integer"},"dp":{"type":"integer"}},"ExtUnits":{"unitAscii":"kg","unitUnicode":"kg"},"x-decimalPlaces":2,"x-order":0}, + "sample":{"type":"integer","x-optionsAction":"ListSamples","x-order":1}, + "role":{"type":"string","oneOf":[{"title":"A","const":"A"},{"title":"B","const":"B"}],"x-order":2}, + "takenAt":{"type":"string","format":"date-time","x-order":3}, + "day":{"type":"string","format":"date","x-order":4}, + "done":{"type":"boolean","x-order":5}, + "count":{"type":"integer","x-order":6}, + "ratio":{"$ref":"#/$defs/double","x-order":7}, + "note":{"type":"string","x-order":8}, + "tags":{"type":"array","items":{"type":"string"},"x-order":9}, + "rows":{"type":"array","items":{"$ref":"#/$defs/Row"},"x-order":10}, + "sub":{"$ref":"#/$defs/Sub","x-order":11}, + "limit":{"type":"object","properties":{"num":{"type":"integer"},"den":{"type":"integer"},"dp":{"type":"integer"}},"x-decimalPlaces":2,"x-order":12}}, + "required":[]})"); + std::vector kindsByField{}; + for (auto const& field : kinds.fields()) { + kindsByField.push_back(field.name + ":" + std::string{morph::forms::fieldKindName(field.kind)}); + } + CHECK(kindsByField == std::vector{"mass:quantity", "sample:choice", "role:enum", "takenAt:datetime", + "day:date", "done:boolean", "count:integer", "ratio:number", + "note:text", "tags:array", "rows:objectArray", "sub:object", + "limit:decimal"}); +} + +TEST_CASE("FormModel: a closed set is an Enum in every spelling the emitter or a hand writes", + "[forms-engine][model]") { + FormModel const sets = model("Sets", R"({"$defs":{"int64_t":{"type":"integer"}, + "Grade":{"type":"string","oneOf":[{"title":"Low","const":"Low"},{"title":"High","const":"High"}]}}, + "properties":{ + "role":{"type":"string","oneOf":[{"title":"Viewer","const":"Viewer"},{"title":"Member","const":"Member"},{"title":"Manager","const":"Manager"}],"x-order":0,"title":"Role"}, + "size":{"enum":[1,2,3],"x-order":1}, + "nullable":{"oneOf":[{"title":"On","const":"On"},{"title":"Off","const":"Off"},{"type":"null"}],"x-order":2}, + "grade":{"anyOf":[{"$ref":"#/$defs/Grade"},{"type":"null"}],"x-order":3}, + "inlined":{"anyOf":[{"oneOf":[{"title":"Low","const":"Low"},{"title":"High","const":"High"}]},{"type":"null"}],"x-order":4}, + "deeper":{"anyOf":[{"anyOf":[{"oneOf":[{"title":"Low","const":"Low"},{"title":"High","const":"High"}]}]},{"type":"null"}],"x-order":5}, + "partial":{"type":"string","oneOf":[{"title":"A","const":"A"},{"type":"string"}],"x-order":6}, + "optI64":{"anyOf":[{"$ref":"#/$defs/int64_t"},{"type":"null"}],"x-order":7}},"required":[]})"); + auto const options = [&](std::size_t index) { + std::vector out; + for (auto const& option : sets.fields()[index].enumOptions) { + out.push_back(option.label + "=" + option.valueJson); + } + return out; + }; + CHECK(sets.fields()[0].kind == FieldKind::Enum); + CHECK(sets.fields()[0].title == "Role"); + CHECK(options(0) == std::vector{R"(Viewer="Viewer")", R"(Member="Member")", R"(Manager="Manager")"}); + CHECK(options(1) == std::vector{"1=1", "2=2", "3=3"}); + CHECK(options(2) == std::vector{R"(On="On")", R"(Off="Off")"}); + CHECK(sets.fields()[3].kind == FieldKind::Enum); + CHECK(options(3) == std::vector{R"(Low="Low")", R"(High="High")"}); + CHECK(sets.fields()[4].kind == FieldKind::Enum); + CHECK(sets.fields()[5].kind != FieldKind::Enum); + CHECK(sets.fields()[5].enumOptions.empty()); + CHECK(sets.fields()[6].kind == FieldKind::Text); + CHECK(sets.fields()[7].kind == FieldKind::Integer); +} + +TEST_CASE("FormModel: key order is irrelevant, x-order is the layout, required and layout read either way", + "[forms-engine][model]") { + FormModel const role = model("SetMemberRole", R"({"type":"object","properties":{ + "role":{"type":"string","oneOf":[{"title":"Viewer","const":"Viewer"}],"x-order":2,"title":"Role"}, + "principal":{"type":"string","x-order":1,"title":"Principal"}, + "projectId":{"$ref":"#/$defs/ProjectId","x-order":0,"title":"Project Id"}}, + "$defs":{"ProjectId":{"type":["integer","null"],"minimum":-9223372036854775808,"maximum":9223372036854775807, + "x-exactMinimum":"-9223372036854775808","x-exactMaximum":"9223372036854775807"}}, + "required":["projectId","principal","role"]})"); + CHECK(names(role.fields()) == "projectId,principal,role"); + CHECK(role.fields()[0].kind == FieldKind::Integer); + CHECK(role.fields()[0].jsonType == "integer"); + CHECK(role.fields()[0].exactMaximum == "9223372036854775807"); + CHECK(role.fields()[0].minimum == "-9223372036854775808"); + + FormModel const reordered = model("X", R"({"properties":{"label":{"type":"string","x-order":1}, + "count":{"type":"integer","x-order":0}},"required":["count","label"]})"); + CHECK(names(reordered.fields()) == "count,label"); + CHECK(reordered.fields()[0].required); + CHECK(reordered.fields()[1].required); + + FormModel const unbounded = + model("Unbounded", R"({"properties":{"size":{"type":"integer","minimum":null,"maximum":null}},"required":["size"]})"); + CHECK_FALSE(unbounded.fields()[0].minimum.has_value()); + CHECK_FALSE(unbounded.fields()[0].maximum.has_value()); +} + +TEST_CASE("FormModel: x-layout buckets fields by x-section in x-order, with a trailing implicit group", + "[forms-engine][model]") { + FormModel const layout = model("LayoutProbe", R"({"properties":{ + "sampleId":{"type":["integer","null"],"x-order":0,"x-section":0}, + "density":{"type":["number","null"],"x-order":1,"x-section":1}, + "moisture":{"type":["number","null"],"x-order":2,"x-section":1}, + "notes":{"type":["string","null"],"x-order":3,"x-section":2,"x-colspan":2}, + "remarks":{"type":["string","null"],"x-order":4}},"required":["sampleId"], + "x-layout":{"groups":[{"title":"Identity","kind":"section","fields":["sampleId"]}, + {"title":"Measurement","kind":"section","fields":["moisture","density"]}, + {"title":"Notes","kind":"accordion","fields":["notes"]}]}})"); + auto const groups = layout.groups(); + REQUIRE(groups.size() == 4); + CHECK(groups[0].title == "Identity"); + CHECK(groups[0].kind == GroupKind::Section); + CHECK(groups[0].fields == std::vector{"sampleId"}); + CHECK(groups[1].fields == std::vector{"density", "moisture"}); + CHECK(groups[2].kind == GroupKind::Accordion); + CHECK(groups[3].implicit); + CHECK(groups[3].fields == std::vector{"remarks"}); + CHECK(layout.find("notes")->colspan == 2); + CHECK(layout.find("sampleId")->colspan == 1); + + FormModel const flat = model("Probe", kProbe); + REQUIRE(flat.groups().size() == 1); + CHECK(flat.groups()[0].implicit); + CHECK(flat.groups()[0].fields.size() == 4); +} + +TEST_CASE("FormModel: a dependent Choice names its parents", "[forms-engine][model]") { + FormModel const shipTo = model("ShipTo", R"({"properties":{ + "country":{"type":["integer","null"],"x-order":0,"x-optionsAction":"ListCountries","x-optionValue":"id","x-optionLabel":"name"}, + "city":{"type":["integer","null"],"x-order":1,"x-optionsAction":"ListCities","x-optionValue":"id","x-optionLabel":"name","x-optionsDependsOn":["country"]}}, + "required":["country","city"]})"); + CHECK(shipTo.fields()[0].choice->dependsOn.empty()); + CHECK(shipTo.fields()[1].choice->dependsOn == std::vector{"country"}); +} + +TEST_CASE("FormModel: a plain number reads its display unit and decimals; a declared precision wins", + "[forms-engine][model]") { + FormModel const reading = model("T_Reading", R"({"$defs":{"double":{"type":"number","minimum":-1.7976931348623157e+308,"maximum":1.7976931348623157e+308}}, + "properties":{"density":{"$ref":"#/$defs/double","x-order":0,"title":"Density","ExtUnits":{"unitAscii":"kg/m³","unitUnicode":"kg/m³"},"x-displayDecimals":3}, + "temperature":{"$ref":"#/$defs/double","x-order":1,"title":"Temperature","ExtUnits":{"unitAscii":"°C","unitUnicode":"°C"}}},"required":["density"]})"); + FieldSpec const& density = reading.fields()[0]; + CHECK(density.kind == FieldKind::Number); + CHECK(density.unit == "kg/m³"); + CHECK(density.unitAscii == "kg/m³"); + CHECK(density.displayDecimals == 3U); + CHECK_FALSE(reading.fields()[1].displayDecimals.has_value()); + FormModel const precision = + model("T_Precision", R"({"properties":{"reading":{"type":"number","x-decimalPlaces":1,"x-displayDecimals":3,"x-order":0}},"required":["reading"]})"); + CHECK(precision.fields()[0].kind == FieldKind::Decimal); + CHECK(precision.fields()[0].decimalPlaces == 1U); + CHECK_FALSE(precision.fields()[0].displayDecimals.has_value()); +} + +TEST_CASE("FormModel: nested objects and collections are described recursively, and a cycle stops", + "[forms-engine][model]") { + FormModel const ignition = model("T_Ignition", R"({"$defs":{"double":{"type":"number"},"int32_t":{"type":"integer"}}, + "properties":{"specimen":{"type":"object","x-order":0,"title":"Specimen","required":["massOfContainer","testTemperature"],"properties":{ + "massOfContainer":{"$ref":"#/$defs/double","x-order":0,"title":"Container","ExtUnits":{"unitAscii":"g","unitUnicode":"g"},"x-displayDecimals":1}, + "readoutBinderContent":{"anyOf":[{"$ref":"#/$defs/double"},{"type":"null"}],"x-order":2,"title":"Readout binder content","ExtUnits":{"unitAscii":"pct","unitUnicode":"%"},"x-displayDecimals":2}, + "testTemperature":{"$ref":"#/$defs/int32_t","x-order":3,"title":"Test Temperature"}}}},"required":["specimen"]})"); + FieldSpec const& specimen = ignition.fields()[0]; + CHECK(specimen.kind == FieldKind::Object); + CHECK_FALSE(specimen.truncated); + CHECK(names(specimen.children) == "massOfContainer,readoutBinderContent,testTemperature"); + CHECK(specimen.children[0].title == "Container"); + CHECK(specimen.children[0].unit == "g"); + CHECK(specimen.children[0].displayDecimals == 1U); + CHECK(specimen.children[0].required); + CHECK(specimen.children[0].path == "specimen.massOfContainer"); + CHECK_FALSE(specimen.children[1].required); + CHECK(specimen.children[2].kind == FieldKind::Integer); + + FormModel const grading = model("T_Grading", R"({"$defs":{"double":{"type":"number"}, + "Row":{"type":"object","properties":{"note":{"type":["string","null"],"x-order":2}, + "sieve":{"$ref":"#/$defs/double","x-order":0,"title":"Sieve","ExtUnits":{"unitAscii":"mm","unitUnicode":"mm"}}, + "passing":{"type":["object","null"],"properties":{"num":{"type":"integer"},"den":{"type":"integer"},"dp":{"type":"integer"}},"ExtUnits":{"unitAscii":"pct","unitUnicode":"%"},"x-decimalPlaces":1,"x-order":1}, + "order":{"type":"integer","x-order":3,"x-readonly":true}},"required":["sieve","passing"]}}, + "properties":{"rows":{"type":"array","items":{"$ref":"#/$defs/Row"},"x-order":0}, + "tags":{"type":"array","items":{"type":"string"},"x-order":1}},"required":["rows"]})"); + FieldSpec const& rows = grading.fields()[0]; + CHECK(rows.kind == FieldKind::ObjectArray); + CHECK(names(rows.children) == "sieve,passing,note,order"); + CHECK(rows.children[0].path == "rows[].sieve"); + CHECK(rows.children[1].kind == FieldKind::Quantity); + CHECK(rows.children[3].readOnly); + CHECK(grading.fields()[1].kind == FieldKind::Array); + CHECK(grading.fields()[1].children.empty()); + + FormModel const tree = model("TreeNodeAction", R"({"$defs":{"TreeNode":{"type":"object","properties":{ + "name":{"type":"string","x-order":0},"children":{"type":"array","items":{"$ref":"#/$defs/TreeNode"},"x-order":1}}, + "required":["name","children"]}}, + "properties":{"name":{"type":"string","x-order":0},"children":{"type":"array","items":{"$ref":"#/$defs/TreeNode"},"x-order":1}}, + "required":["name","children"]})"); + FieldSpec const& children = tree.fields()[1]; + CHECK(children.kind == FieldKind::ObjectArray); + CHECK_FALSE(children.truncated); + CHECK(names(children.children) == "name,children"); + CHECK(children.children[1].truncated); // TreeNode is already on the path + CHECK(children.children[1].children.empty()); +} + +TEST_CASE("FormModel: an instance-decorated schema reads its precision and exact range", "[forms-engine][model]") { + FormModel const decorated = model("ICCapture", R"({"properties":{"value":{"type":["object","null"], + "ExtUnits":{"unitAscii":"mg_per_L","unitUnicode":"mg/L"},"x-order":1,"x-decimalPlaces":1, + "x-minimum":{"num":0,"den":1,"dp":1},"x-maximum":{"num":20,"den":1,"dp":1}}},"required":["value"]})"); + FieldSpec const& value = decorated.fields()[0]; + CHECK(value.decimalPlaces == 1U); + REQUIRE(value.instanceMaximum.has_value()); + CHECK(*value.instanceMaximum == morph::math::Rational{morph::math::Numerator{20}, morph::math::Denominator{1}, + morph::math::DecimalPlaces{1}}); +} + +TEST_CASE("FormModel: a malformed document is a SchemaError", "[forms-engine][model]") { + CHECK(FormModel::fromSchema("A", "not json").error().path.empty()); + CHECK_FALSE(FormModel::fromSchema("A", "[1,2]").has_value()); + CHECK(FormModel::fromSchema("A", R"({"properties":[]})").error().path == "properties"); + CHECK(FormModel::fromSchema("A", R"({"properties":{"a":3}})").error().path == "properties.a"); + CHECK(FormModel::fromSchema("A", R"({"x-rules":[{"fields":["a"]}]})").error().path == "x-rules[0]"); + CHECK(FormModel::fromSchema("A", R"({"x-layout":{"groups":{}}})").error().path == "x-layout.groups"); + CHECK(FormModel::fromSchema("A", "{}").has_value()); + CHECK(FormModel::fromSchema("A", R"({"x-submitMode":"explicit"})")->submitMode() == + morph::forms::SubmitMode::Explicit); +} + +// Only the success path: every document `schemaJson()` emits for a reflectable action is one the +// reader accepts (the conformance and rule corpora pin that), and the reader's failures — not JSON, not +// an object, a non-object property, a malformed `x-rules` or `x-layout` — have no spelling in a C++ +// action type, so no registered action can reach the throw. Its message is reviewed with the code. +TEST_CASE("FormModel: forAction reads a registered action's own schema, as fromSchema does", + "[forms-engine][model]") { + FormModel const typed = FormModel::forAction(); + auto const runtime = + FormModel::fromSchema("Test_FormsEngineModel_Booking", morph::forms::schemaJson()); + REQUIRE(runtime.has_value()); + CHECK(typed.actionType() == "Test_FormsEngineModel_Booking"); + CHECK(names(typed.fields()) == "guests,note"); + REQUIRE(typed.fields().size() == runtime->fields().size()); + for (std::size_t i = 0; i < typed.fields().size(); ++i) { + CHECK(typed.fields()[i].name == runtime->fields()[i].name); + CHECK(typed.fields()[i].kind == runtime->fields()[i].kind); + CHECK(typed.fields()[i].required == runtime->fields()[i].required); + } + CHECK(typed.find("guests")->kind == FieldKind::Integer); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: `fatal error: 'morph/forms/engine/field_model.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `include/morph/forms/engine/field_model.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/field_model.hpp +/// @brief `FormModel`: the immutable runtime model of one action's form, read from the schema +/// JSON `schemaJson()` emits (or any document in that vocabulary). +/// +/// Specified in `docs/spec/forms/engine.md`, "The field model". + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/registry.hpp" +#include "../../util/rational.hpp" +#include "../forms.hpp" +#include "../layout.hpp" +#include "detail/text.hpp" +#include "rules.hpp" + +namespace morph::forms { + +/// @brief The control a field gets, which the JSON type alone does not say. +enum class FieldKind : std::uint8_t { + Text, ///< A string. + Multiline, ///< A string edited over several lines (`x-widget: textarea`). + Integer, ///< An integer of any width; every digit is kept. + Number, ///< A plain JSON number (a `double`/`float` member). + Decimal, ///< An exact `{num, den, dp}` with a declared precision and no unit. + Quantity, ///< An exact `{num, den, dp}` in a unit, with optional convertible alternatives. + Boolean, ///< `true` / `false`. + Enum, ///< A closed set the schema states. + Choice, ///< A set fetched by executing an options action. + DateTime, ///< An instant, entered in the display zone, sent as ISO-8601 UTC. + Date, ///< A calendar date, `YYYY-MM-DD`. + Slider, ///< A number on a track (`x-widget: slider` with `x-min`/`x-max`). + Array, ///< A list of scalars, entered comma-separated. + Object, ///< A nested object. + ObjectArray ///< A list of nested objects. +}; + +/// @brief The stable name of a field kind (what `Overrides::byKind` documents and tests print). +/// @param kind The kind. +/// @return `"text"`, `"multiline"`, `"integer"`, `"number"`, `"decimal"`, `"quantity"`, `"boolean"`, +/// `"enum"`, `"choice"`, `"datetime"`, `"date"`, `"slider"`, `"array"`, `"object"` or `"objectArray"`. +[[nodiscard]] constexpr std::string_view fieldKindName(FieldKind kind) noexcept { + switch (kind) { + case FieldKind::Text: + return "text"; + case FieldKind::Multiline: + return "multiline"; + case FieldKind::Integer: + return "integer"; + case FieldKind::Number: + return "number"; + case FieldKind::Decimal: + return "decimal"; + case FieldKind::Quantity: + return "quantity"; + case FieldKind::Boolean: + return "boolean"; + case FieldKind::Enum: + return "enum"; + case FieldKind::Choice: + return "choice"; + case FieldKind::DateTime: + return "datetime"; + case FieldKind::Date: + return "date"; + case FieldKind::Slider: + return "slider"; + case FieldKind::Array: + return "array"; + case FieldKind::Object: + return "object"; + case FieldKind::ObjectArray: + return "objectArray"; + default: + return "text"; + } +} + +/// @brief When a form submits. +enum class SubmitMode : std::uint8_t { + Automatic, ///< Whenever it becomes ready with a changed body (no `x-submitMode`). + Explicit ///< Only on `FormSession::submit()` (`x-submitMode: explicit`). +}; + +/// @brief How many object levels below the action a schema is described; a deeper member is +/// `FieldSpec::truncated`. +inline constexpr std::size_t kMaxNestingDepth = 4; + +/// @brief One unit a Quantity can be entered in; the first of a field's units is canonical. +struct UnitOption { + /// @brief The unit id (`ExtUnits.unitAscii`, or the alternative's `id`). + std::string id{}; + /// @brief What a selector shows. + std::string display{}; + /// @brief Fraction digits accepted in this unit. + std::uint32_t decimals = 0; + /// @brief Numerator of the exact unit-to-canonical ratio. + std::int64_t num = 1; + /// @brief Denominator of the exact unit-to-canonical ratio. + std::int64_t den = 1; + /// @brief Member-wise equality. + /// @param other The other unit. + /// @return `true` when every member matches. + bool operator==(UnitOption const& other) const = default; +}; + +/// @brief One selectable value of an Enum or a Choice. +struct ChoiceOption { + /// @brief The value as the JSON literal the body carries (`"\"Viewer\""`, `"9007199254740993"`). + std::string valueJson{}; + /// @brief What the user sees. + std::string label{}; + /// @brief Member-wise equality. + /// @param other The other option. + /// @return `true` when value and label match. + bool operator==(ChoiceOption const& other) const = default; +}; + +/// @brief Where a Choice's options come from (`x-optionsAction` and its companions). +struct ChoiceSpec { + /// @brief The action whose result rows are the options. + std::string optionsAction{}; + /// @brief The row member submitted as the value (`x-optionValue`). + std::string valueField = "id"; + /// @brief The row member shown (`x-optionLabel`). + std::string labelField = "name"; + /// @brief Sibling wire names whose values parameterise the options request (`x-optionsDependsOn`). + std::vector dependsOn{}; + /// @brief Member-wise equality. + /// @param other The other descriptor. + /// @return `true` when every member matches. + bool operator==(ChoiceSpec const& other) const = default; +}; + +/// @brief Everything the engine knows about one field, from its schema property. +struct FieldSpec { + /// @brief The wire name. + std::string name{}; + /// @brief The path from the action: `name`, `parent.name`, or `rows[].name` for a collection's member. + std::string path{}; + /// @brief The control and encoder. + FieldKind kind = FieldKind::Text; + /// @brief The first declared JSON type (`"integer"`, …), empty when the property declares none. + std::string jsonType{}; + /// @brief The schema's title, or the wire name. + std::string title{}; + /// @brief The schema's `description`. + std::string help{}; + /// @brief `x-placeholder`. + std::string placeholder{}; + /// @brief `x-i18nKey`: the explicit message-key stem, empty when none is declared. + std::string i18nKey{}; + /// @brief Named in the containing object's `required`. + bool required = false; + /// @brief `x-blankAs` (Text and Multiline only): whether a cleared field submits `""`. + BlankAs blankAs = BlankAs::Omit; + /// @brief `x-readonly`. + bool readOnly = false; + /// @brief `x-hidden`. + bool hidden = false; + /// @brief `x-computed.inputs`: the members a computed field derives from. + std::vector computedInputs{}; + /// @brief `minimum`, as canonical decimal text. + std::optional minimum{}; + /// @brief `maximum`, as canonical decimal text. + std::optional maximum{}; + /// @brief A positive `multipleOf`, as canonical decimal text. + std::optional multipleOf{}; + /// @brief `x-exactMinimum`: an integer bound's exact digits. + std::optional exactMinimum{}; + /// @brief `x-exactMaximum`: an integer bound's exact digits. + std::optional exactMaximum{}; + /// @brief `x-minimum`: a per-instance lower bound (`InstanceConstraints::decorate`). + std::optional instanceMinimum{}; + /// @brief `x-maximum`: a per-instance upper bound. + std::optional instanceMaximum{}; + /// @brief `x-decimalPlaces`: the declared precision of a Decimal or Quantity. + std::optional decimalPlaces{}; + /// @brief `x-displayDecimals`: a plain Number's entry and display precision. + std::optional displayDecimals{}; + /// @brief The display unit (`ExtUnits.unitUnicode`, else `unitAscii`), empty when none. + std::string unit{}; + /// @brief `ExtUnits.unitAscii`. + std::string unitAscii{}; + /// @brief A Decimal's or Quantity's entry units: the canonical one first, then `x-unitAlternatives`. + std::vector units{}; + /// @brief An Enum's options, in schema order. + std::vector enumOptions{}; + /// @brief A Choice's options source. + std::optional choice{}; + /// @brief `x-section`: the index of the `x-layout` group the field sits in. + std::optional group{}; + /// @brief `x-colspan`: grid columns the field spans (at least 1). + int colspan = 1; + /// @brief `x-widget`, empty when none. + std::string widget{}; + /// @brief A Slider's track minimum (`x-min`). + std::int64_t sliderMin = 0; + /// @brief A Slider's track maximum (`x-max`). + std::int64_t sliderMax = 100; + /// @brief A Slider's increment (`x-step`, at least 1). + std::int64_t sliderStep = 1; + /// @brief An Array's item JSON type (`"string"` when undeclared). + std::string itemType{}; + /// @brief An Object's members, or an ObjectArray's element members, in `x-order`. + std::vector children{}; + /// @brief An Object or ObjectArray whose members are not described: its `$ref` is already on + /// the path, it is deeper than `kMaxNestingDepth`, or its schema states no members. + bool truncated = false; +}; + +/// @brief One `x-layout` group, or the implicit group of the fields no group names. +struct FieldGroupSpec { + /// @brief The group's title (empty for the implicit group). + std::string title{}; + /// @brief Section, tab or accordion; the implicit group is a `Section` with `implicit` set. + GroupKind kind = GroupKind::Section; + /// @brief The wire names of the fields in the group, in `x-order`. + std::vector fields{}; + /// @brief The trailing group of fields no declared group names (or every field, without `x-layout`). + bool implicit = false; +}; + +namespace detail::engine { + +/// @brief Whether @p range holds @p value. +/// @param range A range. +/// @param value The value to look for. +/// @return `true` when an element equals @p value. +[[nodiscard]] inline bool contains(auto const& range, auto const& value) { + return std::ranges::find(range, value) != std::ranges::end(range); +} + +/// @brief Bounds-checked element access for a span, which has no `at()` before C++26. +/// @tparam T The element type. +/// @param items The span. +/// @param index The position. +/// @return The element at @p index. +/// @throws std::out_of_range when @p index is not below `items.size()`. +template +[[nodiscard]] T& elementAt(std::span items, std::size_t index) { + if (index >= items.size()) { + throw std::out_of_range{"morph::forms: index " + std::to_string(index) + " past a span of " + + std::to_string(items.size())}; + } + return *std::next(items.begin(), static_cast(index)); +} + +/// @brief Reads property schemas: resolves `$ref` into `$defs`, collapses nullable +/// `anyOf`/`oneOf`, and describes an object's properties as `FieldSpec`s. +class SchemaReader { +public: + /// @param root The whole schema document. Borrowed: it must outlive the reader. + explicit SchemaReader(Json const& root) : _defs{member(root, "$defs")} {} + + /// @brief Follows a `$ref` into `$defs`; the property's own keys win over the definition's. + /// @param prop A property schema. + /// @return The merged object (`{}` for a non-object). + [[nodiscard]] Json resolveRef(Json const& prop) const { + if (!prop.is_object()) { + return emptyObject(); + } + auto const* const ref = stringAt(prop, "$ref"); + if (ref == nullptr) { + return prop; + } + std::string_view name{*ref}; + name = name.substr(name.rfind('/') + 1); + Json merged = emptyObject(); + if (Json const* const def = _defs == nullptr ? nullptr : member(*_defs, name); def != nullptr && def->is_object()) { + merged = *def; + } + for (auto const& [key, value] : prop.get_object()) { + merged.get_object().insert_or_assign(key, value); + } + return merged; + } + + /// @brief Resolves `$ref`, then collapses a nullable `anyOf`/`oneOf` onto its first non-null + /// branch; the outer keys win, and the branches' `anyOf`/`oneOf`/`const` are dropped. + /// @param prop A property schema. + /// @return The resolved property. + [[nodiscard]] Json resolveProp(Json const& prop) const { + Json const resolved = resolveRef(prop); + Json const* branches = member(resolved, "anyOf"); + if (branches == nullptr || !branches->is_array()) { + branches = member(resolved, "oneOf"); + } + if (branches == nullptr || !branches->is_array()) { + return resolved; + } + auto const dropped = [](std::string_view key) { return key == "anyOf" || key == "oneOf" || key == "const"; }; + for (Json const& raw : branches->get_array()) { + Json const branch = resolveRef(raw); + if (auto const* const type = stringAt(branch, "type"); type != nullptr && *type == "null") { + continue; + } + Json merged = emptyObject(); + for (auto const& [key, value] : branch.get_object()) { + if (member(resolved, key) == nullptr && !dropped(key)) { + merged.get_object().insert_or_assign(key, value); + } + } + for (auto const& [key, value] : resolved.get_object()) { + if (!dropped(key)) { + merged.get_object().insert_or_assign(key, value); + } + } + return merged; + } + return resolved; + } + + /// @brief Describes an object schema's properties, in `x-order` (stable for ties). + /// @param objectSchema The object schema. + /// @param depth 0 for the action, one more per nested level. + /// @param pathPrefix Prefix of every member's path (`""`, `"parent."`, `"rows[]."`). + /// @param refChain The `$defs` references on the path here. + /// @return The fields, or the error naming the malformed property. + // NOLINTNEXTLINE(misc-no-recursion) -- an object's members are described by the same walk, to kMaxNestingDepth + [[nodiscard]] std::expected, SchemaError> describe( + Json const& objectSchema, std::size_t depth, std::string const& pathPrefix, + std::vector const& refChain) const { + std::vector fields; + Json const* const props = member(objectSchema, "properties"); + if (props == nullptr) { + return fields; + } + if (!props->is_object()) { + return std::unexpected(SchemaError{pathPrefix + "properties", "\"properties\" must be an object"}); + } + std::vector required; + if (Json const* const list = member(objectSchema, "required"); list != nullptr && list->is_array()) { + for (Json const& entry : list->get_array()) { + if (auto const* const text = entry.get_if()) { + required.push_back(*text); + } + } + } + std::vector> order; + for (auto const& [name, prop] : props->get_object()) { + order.emplace_back(name, unsignedOf(member(prop, "x-order")).value_or(0)); + } + std::ranges::stable_sort(order, {}, &std::pair::second); + for (auto const& [name, ignoredOrder] : order) { + static_cast(ignoredOrder); + Json const& raw = *member(*props, name); + if (!raw.is_object()) { + return std::unexpected( + SchemaError{"properties." + pathPrefix + name, "a property schema must be a JSON object"}); + } + auto field = describeOne(raw, name, depth, pathPrefix, refChain, contains(required, name)); + if (!field) { + return std::unexpected(field.error()); + } + fields.push_back(*std::move(field)); + } + return fields; + } + + /// @brief A non-negative integer member, also from an integral double. + /// @param value The member, or null. + /// @return The value, or `std::nullopt`. + [[nodiscard]] static std::optional unsignedOf(Json const* value) { + if (value == nullptr) { + return std::nullopt; + } + if (auto const* const asUnsigned = value->get_if()) { + return *asUnsigned; + } + if (auto const* const asSigned = value->get_if(); asSigned != nullptr && *asSigned >= 0) { + return static_cast(*asSigned); + } + if (auto const* const asDouble = value->get_if(); + asDouble != nullptr && *asDouble >= 0 && *asDouble == std::floor(*asDouble) && *asDouble < 1.8e19) { + return static_cast(*asDouble); + } + return std::nullopt; + } + + /// @brief A signed integer member; a double is truncated toward zero. + /// @param value The member, or null. + /// @return The value, or `std::nullopt`. + [[nodiscard]] static std::optional signedOf(Json const* value) { + if (value == nullptr) { + return std::nullopt; + } + if (auto const* const asSigned = value->get_if()) { + return *asSigned; + } + if (auto const* const asUnsigned = value->get_if(); + asUnsigned != nullptr && std::in_range(*asUnsigned)) { + return static_cast(*asUnsigned); + } + if (auto const* const asDouble = value->get_if(); + asDouble != nullptr && std::isfinite(*asDouble) && std::abs(*asDouble) < 9.2e18) { + return static_cast(*asDouble); + } + return std::nullopt; + } + +private: + [[nodiscard]] static std::vector jsonTypes(Json const& prop) { + std::vector types; + Json const* const type = member(prop, "type"); + if (type == nullptr) { + return types; + } + if (auto const* const one = type->get_if()) { + types.push_back(*one); + } else if (type->is_array()) { + for (Json const& entry : type->get_array()) { + if (auto const* const text = entry.get_if()) { + types.push_back(*text); + } + } + } + return types; + } + + /// The key from the raw property first, then from the resolved one (the QML reader's order). + [[nodiscard]] static Json const* either(Json const& raw, Json const& resolved, std::string_view key) { + Json const* const own = member(raw, key); + return own != nullptr ? own : member(resolved, key); + } + + [[nodiscard]] static std::string textOf(Json const& raw, Json const& resolved, std::string_view key) { + Json const* const value = either(raw, resolved, key); + auto const* const text = value == nullptr ? nullptr : value->get_if(); + return text == nullptr ? std::string{} : *text; + } + + [[nodiscard]] static bool flagOf(Json const& raw, Json const& resolved, std::string_view key) { + Json const* const value = either(raw, resolved, key); + auto const* const flag = value == nullptr ? nullptr : value->get_if(); + return flag != nullptr && *flag; + } + + [[nodiscard]] static std::string refNameOf(Json const& raw) { + if (auto const* const ref = stringAt(raw, "$ref")) { + return *ref; + } + for (std::string_view const key : {"anyOf", "oneOf"}) { + if (Json const* const branches = member(raw, key); branches != nullptr && branches->is_array()) { + for (Json const& branch : branches->get_array()) { + if (auto const* const ref = stringAt(branch, "$ref")) { + return *ref; + } + } + } + } + return {}; + } + + // NOLINTNEXTLINE(misc-no-recursion) -- one nested level of branches, bounded by `nested` + [[nodiscard]] std::optional> constBranches(Json const& prop, bool nested) const { + Json const* branches = member(prop, "anyOf"); + if (branches == nullptr || !branches->is_array()) { + branches = member(prop, "oneOf"); + } + if (branches == nullptr || !branches->is_array()) { + return std::nullopt; + } + std::vector rows; + for (Json const& raw : branches->get_array()) { + Json const branch = resolveRef(raw); + if (auto const* const type = stringAt(branch, "type"); type != nullptr && *type == "null") { + continue; + } + if (Json const* const literal = member(branch, "const")) { + auto const* const title = stringAt(branch, "title"); + rows.push_back(ChoiceOption{toJson(*literal), title != nullptr ? *title : displayText(*literal)}); + continue; + } + auto inner = nested ? constBranches(branch, false) : std::nullopt; + if (!inner || inner->empty()) { + return std::nullopt; + } + rows.insert(rows.end(), inner->begin(), inner->end()); + } + return rows; + } + + [[nodiscard]] std::vector enumChoices(Json const& raw) const { + Json const prop = resolveRef(raw); + if (Json const* const literals = member(prop, "enum"); literals != nullptr && literals->is_array()) { + std::vector rows; + for (Json const& literal : literals->get_array()) { + if (!literal.is_null()) { + rows.push_back(ChoiceOption{toJson(literal), displayText(literal)}); + } + } + return rows; + } + return constBranches(prop, true).value_or(std::vector{}); + } + + [[nodiscard]] static std::optional rationalOf(Json const* node) { + if (node == nullptr) { + return std::nullopt; + } + auto const num = signedOf(member(*node, "num")); + auto const den = signedOf(member(*node, "den")); + if (!num || !den || *den == 0) { + return std::nullopt; + } + auto const places = unsignedOf(member(*node, "dp")).value_or(0); + return math::Rational{math::Numerator{*num}, math::Denominator{*den}, + math::DecimalPlaces{static_cast(std::min(places, math::kMaxDecimalPlaces))}}; + } + + // NOLINTNEXTLINE(misc-no-recursion) -- the other half of describe's walk + [[nodiscard]] std::expected describeOne(Json const& raw, std::string const& name, + std::size_t depth, std::string const& pathPrefix, + std::vector const& refChain, + bool required) const { + Json const prop = resolveProp(raw); + std::vector const types = jsonTypes(prop); + auto const hasType = [&](std::string_view type) { return contains(types, type); }; + FieldSpec spec{}; + spec.name = name; + spec.path = pathPrefix + name; + spec.jsonType = types.empty() ? std::string{} : types.front(); + spec.title = textOf(raw, prop, "title"); + if (spec.title.empty()) { + spec.title = name; + } + spec.help = textOf(prop, prop, "description"); + spec.placeholder = textOf(raw, prop, "x-placeholder"); + spec.i18nKey = textOf(raw, prop, "x-i18nKey"); + spec.required = required; + spec.readOnly = flagOf(raw, prop, "x-readonly"); + spec.hidden = flagOf(raw, prop, "x-hidden"); + spec.widget = textOf(raw, prop, "x-widget"); + if (Json const* const computed = either(raw, prop, "x-computed")) { + if (Json const* const inputs = member(*computed, "inputs"); inputs != nullptr && inputs->is_array()) { + for (Json const& input : inputs->get_array()) { + if (auto const* const text = input.get_if()) { + spec.computedInputs.push_back(*text); + } + } + } + } + if (Json const* const minimum = member(prop, "minimum")) { + spec.minimum = numberText(*minimum); + } + if (Json const* const maximum = member(prop, "maximum")) { + spec.maximum = numberText(*maximum); + } + if (Json const* const step = member(prop, "multipleOf")) { + auto text = numberText(*step); + if (text && compareDecimal(*text, "0") == std::strong_ordering::greater) { + spec.multipleOf = std::move(text); + } + } + if (auto const* const exact = stringAt(prop, "x-exactMinimum"); exact != nullptr && isCanonicalInteger(*exact)) { + spec.exactMinimum = *exact; + } + if (auto const* const exact = stringAt(prop, "x-exactMaximum"); exact != nullptr && isCanonicalInteger(*exact)) { + spec.exactMaximum = *exact; + } + spec.instanceMinimum = rationalOf(either(raw, prop, "x-minimum")); + spec.instanceMaximum = rationalOf(either(raw, prop, "x-maximum")); + if (auto const places = unsignedOf(either(raw, prop, "x-decimalPlaces"))) { + spec.decimalPlaces = static_cast(std::min(*places, math::kMaxDecimalPlaces)); + } + if (Json const* const units = member(prop, "ExtUnits")) { + auto const* const ascii = stringAt(*units, "unitAscii"); + auto const* const unicode = stringAt(*units, "unitUnicode"); + spec.unitAscii = ascii != nullptr ? *ascii : std::string{}; + spec.unit = unicode != nullptr && !unicode->empty() ? *unicode : spec.unitAscii; + } + if (auto const section = unsignedOf(either(raw, prop, "x-section"))) { + spec.group = static_cast(*section); + } + spec.colspan = static_cast(std::clamp(signedOf(either(raw, prop, "x-colspan")).value_or(1), 1, 1024)); + + auto const* const optionsAction = [&]() -> std::string const* { + Json const* const value = either(raw, prop, "x-optionsAction"); + return value == nullptr ? nullptr : value->get_if(); + }(); + std::vector enumRows = optionsAction != nullptr ? std::vector{} : enumChoices(raw); + auto const format = textOf(prop, prop, "format"); + auto const sliderMin = signedOf(either(raw, prop, "x-min")); + auto const sliderMax = signedOf(either(raw, prop, "x-max")); + std::string const refName = refNameOf(raw); + bool const describesNested = depth < kMaxNestingDepth && (refName.empty() || !contains(refChain, refName)); + std::vector nestedChain = refChain; + if (!refName.empty()) { + nestedChain.push_back(refName); + } + + if (hasType("array")) { + Json const* const rawItems = member(prop, "items"); + Json const items = rawItems == nullptr ? emptyObject() : resolveProp(*rawItems); + std::vector const itemTypes = jsonTypes(items); + if (contains(itemTypes, "object") || contains(itemTypes, "array")) { + spec.kind = FieldKind::ObjectArray; + std::string const itemRef = rawItems == nullptr ? std::string{} : refNameOf(*rawItems); + bool const itemsDescribed = depth < kMaxNestingDepth && member(items, "properties") != nullptr && + (itemRef.empty() || !contains(refChain, itemRef)); + if (itemsDescribed) { + std::vector itemChain = refChain; + if (!itemRef.empty()) { + itemChain.push_back(itemRef); + } + auto children = describe(items, depth + 1, spec.path + "[].", itemChain); + if (!children) { + return std::unexpected(children.error()); + } + spec.children = *std::move(children); + } else { + spec.truncated = true; + } + } else { + spec.kind = FieldKind::Array; + spec.itemType = itemTypes.empty() ? std::string{"string"} : itemTypes.front(); + } + } else if (!enumRows.empty()) { + spec.kind = FieldKind::Enum; + spec.enumOptions = std::move(enumRows); + } else if (optionsAction != nullptr) { + spec.kind = FieldKind::Choice; + ChoiceSpec choice{}; + choice.optionsAction = *optionsAction; + if (auto value = textOf(raw, prop, "x-optionValue"); !value.empty()) { + choice.valueField = std::move(value); + } + if (auto label = textOf(raw, prop, "x-optionLabel"); !label.empty()) { + choice.labelField = std::move(label); + } + if (Json const* const parents = either(raw, prop, "x-optionsDependsOn"); parents != nullptr && parents->is_array()) { + for (Json const& parent : parents->get_array()) { + if (auto const* const text = parent.get_if()) { + choice.dependsOn.push_back(*text); + } + } + } + spec.choice = std::move(choice); + } else if (format == "date-time") { + spec.kind = FieldKind::DateTime; + } else if (format == "date") { + spec.kind = FieldKind::Date; + } else if (spec.decimalPlaces.has_value()) { + spec.kind = member(prop, "ExtUnits") != nullptr ? FieldKind::Quantity : FieldKind::Decimal; + spec.units.push_back(UnitOption{spec.unitAscii, spec.unit, *spec.decimalPlaces, 1, 1}); + if (Json const* const alternatives = either(raw, prop, "x-unitAlternatives"); + spec.kind == FieldKind::Quantity && alternatives != nullptr && alternatives->is_array()) { + for (Json const& alternative : alternatives->get_array()) { + UnitOption option{}; + option.id = textOf(alternative, alternative, "id"); + option.display = textOf(alternative, alternative, "display"); + if (option.display.empty()) { + option.display = option.id; + } + option.decimals = static_cast(std::min( + unsignedOf(member(alternative, "decimals")).value_or(0), math::kMaxDecimalPlaces)); + option.num = signedOf(member(alternative, "num")).value_or(1); + option.den = signedOf(member(alternative, "den")).value_or(1); + if (option.num > 0 && option.den > 0) { + spec.units.push_back(std::move(option)); + } + } + } + } else if ((hasType("integer") || hasType("number")) && spec.widget == "slider" && sliderMin && sliderMax) { + spec.kind = FieldKind::Slider; + spec.sliderMin = *sliderMin; + spec.sliderMax = *sliderMax; + spec.sliderStep = std::max(1, signedOf(either(raw, prop, "x-step")).value_or(1)); + } else if (hasType("integer")) { + spec.kind = FieldKind::Integer; + } else if (hasType("boolean")) { + spec.kind = FieldKind::Boolean; + } else if (hasType("number")) { + spec.kind = FieldKind::Number; + if (auto const decimals = unsignedOf(either(raw, prop, "x-displayDecimals"))) { + spec.displayDecimals = + static_cast(std::min(*decimals, math::kMaxDecimalPlaces)); + } + } else if (hasType("object")) { + spec.kind = FieldKind::Object; + if (describesNested && member(prop, "properties") != nullptr) { + auto children = describe(prop, depth + 1, spec.path + ".", nestedChain); + if (!children) { + return std::unexpected(children.error()); + } + spec.children = *std::move(children); + } else { + spec.truncated = true; + } + } else if (spec.widget == "textarea") { + spec.kind = FieldKind::Multiline; + } else { + spec.kind = FieldKind::Text; + } + if ((spec.kind == FieldKind::Text || spec.kind == FieldKind::Multiline) && + textOf(raw, prop, "x-blankAs") == "empty") { + spec.blankAs = BlankAs::Empty; + } + return spec; + } + + Json const* _defs; +}; + +} // namespace detail::engine + +/// @brief The immutable runtime model of one action's form. +/// +/// Built from schema JSON by `fromSchema`; `forAction()` (which the typed facade `Form` +/// uses) reads `schemaJson()` through the same reader, so a typed and a runtime form of one +/// action are the same model. +class FormModel { +public: + /// @brief Reads a schema document. + /// @param actionType The action type the form submits (the registered type id). + /// @param schemaJson The schema, in the vocabulary `schemaJson()` emits. + /// @return The model, or the error naming where the document is malformed. + [[nodiscard]] static std::expected fromSchema(std::string_view actionType, + std::string_view schemaJson) { + using detail::engine::Json; + auto const dom = detail::engine::parseJson(schemaJson); + if (!dom) { + return std::unexpected(SchemaError{"", "the schema is not valid JSON"}); + } + if (!dom->is_object()) { + return std::unexpected(SchemaError{"", "the schema is not a JSON object"}); + } + FormModel model{}; + model._actionType = actionType; + auto const* const title = detail::engine::stringAt(*dom, "title"); + model._title = title != nullptr ? *title : std::string{actionType}; + detail::engine::SchemaReader const reader{*dom}; + auto fields = reader.describe(*dom, 0, "", {}); + if (!fields) { + return std::unexpected(fields.error()); + } + model._fields = *std::move(fields); + if (auto groups = readGroups(*dom, model._fields); groups) { + model._groups = *std::move(groups); + } else { + return std::unexpected(groups.error()); + } + if (Json const* const rules = detail::engine::member(*dom, "x-rules")) { + if (!rules->is_array()) { + return std::unexpected(SchemaError{"x-rules", "\"x-rules\" must be an array"}); + } + std::size_t index = 0; + for (Json const& node : rules->get_array()) { + auto parsed = RuleExpr::parse(node, "x-rules[" + std::to_string(index++) + "]"); + if (!parsed) { + return std::unexpected(parsed.error()); + } + model._rules.push_back(*std::move(parsed)); + } + } + auto const* const mode = detail::engine::stringAt(*dom, "x-submitMode"); + model._submitMode = (mode != nullptr && *mode == "explicit") ? SubmitMode::Explicit : SubmitMode::Automatic; + return model; + } + + /// @brief The model of a registered action's own form: `fromSchema` over + /// `ActionTraits::typeId()` and `schemaJson()`. + /// + /// The schema is compiled from `A`, so a reader error here is a defect in the emitter or the + /// engine, never bad input: it throws rather than returning an `expected` every caller would + /// have to unwrap. + /// @tparam A The action type (registered with `BRIDGE_REGISTER_ACTION` or declared with + /// `BRIDGE_DECLARE_ACTION`). + /// @return The model. + /// @throws std::logic_error naming the action id and the error's path and message when the + /// engine cannot read the action's schema. + template + [[nodiscard]] static FormModel forAction() { + std::string_view const typeId = model::ActionTraits::typeId(); + auto parsed = fromSchema(typeId, schemaJson()); + if (!parsed) { + throw std::logic_error{"FormModel::forAction: the schema of '" + std::string{typeId} + + "' does not read as a form (" + parsed.error().path + ": " + + parsed.error().message + ")"}; + } + return *std::move(parsed); + } + + /// @brief The action type the form submits. + /// @return The type id passed to `fromSchema`. + [[nodiscard]] std::string_view actionType() const noexcept { return _actionType; } + + /// @brief The schema's top-level `title`, or the action type. + /// @return The title. + [[nodiscard]] std::string_view title() const noexcept { return _title; } + + /// @brief The top-level fields, in `x-order`. + /// @return The fields. + [[nodiscard]] std::span fields() const noexcept { return _fields; } + + /// @brief The layout groups: the declared `x-layout` groups, then an implicit group of the + /// fields none names; without `x-layout`, one implicit group of every field. + /// @return The groups. + [[nodiscard]] std::span groups() const noexcept { return _groups; } + + /// @brief The parsed `x-rules`, in declaration order. + /// @return The rules. + [[nodiscard]] std::span rules() const noexcept { return _rules; } + + /// @brief When the form submits. + /// @return `Explicit` for `x-submitMode: explicit`, else `Automatic`. + [[nodiscard]] SubmitMode submitMode() const noexcept { return _submitMode; } + + /// @brief A top-level field by wire name. + /// @param name The wire name. + /// @return The field, or null. + [[nodiscard]] FieldSpec const* find(std::string_view name) const noexcept { + auto const found = std::ranges::find(_fields, name, &FieldSpec::name); + return found == _fields.end() ? nullptr : &*found; + } + +private: + FormModel() = default; + + [[nodiscard]] static std::expected, SchemaError> readGroups( + detail::engine::Json const& dom, std::vector const& fields) { + using detail::engine::Json; + std::vector groups; + Json const* const layout = detail::engine::member(dom, "x-layout"); + Json const* const declared = layout == nullptr ? nullptr : detail::engine::member(*layout, "groups"); + if (declared != nullptr && !declared->is_array()) { + return std::unexpected(SchemaError{"x-layout.groups", "\"groups\" must be an array"}); + } + if (declared != nullptr) { + for (Json const& node : declared->get_array()) { + FieldGroupSpec group{}; + if (auto const* const title = detail::engine::stringAt(node, "title")) { + group.title = *title; + } + auto const* const kind = detail::engine::stringAt(node, "kind"); + group.kind = kind == nullptr ? GroupKind::Section + : *kind == "tab" ? GroupKind::Tab + : *kind == "accordion" ? GroupKind::Accordion + : GroupKind::Section; + groups.push_back(std::move(group)); + } + } + FieldGroupSpec trailing{}; + trailing.implicit = true; + for (FieldSpec const& field : fields) { + if (field.group.has_value() && *field.group < groups.size()) { + groups.at(*field.group).fields.push_back(field.name); + } else { + trailing.fields.push_back(field.name); + } + } + if (!trailing.fields.empty()) { + groups.push_back(std::move(trailing)); + } + return groups; + } + + std::string _actionType{}; + std::string _title{}; + std::vector _fields{}; + std::vector _groups{}; + std::vector _rules{}; + SubmitMode _submitMode = SubmitMode::Automatic; +}; + +} // namespace morph::forms +``` + +`contains` is the helper defined at the top of `detail::engine` in this header rather than +`std::ranges::contains`, which the libc++ emscripten ships does not have (see the comment at +`include/morph/forms/forms.hpp:2788`). + +Register the header and the test file as listed under **Files**. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][model]"` +Expected: PASS, 12 test cases. + +Mutation check: in `SchemaReader::describe`, replace `std::ranges::stable_sort(order, …)` with nothing (keep the +document's key order). Expected: FAIL in "key order is irrelevant, x-order is the layout…" (`"count,label"` becomes +`"label,count"`). Restore it. Then make `forAction` pass `"X"` instead of `typeId` to `fromSchema`. Expected: FAIL in +"forAction reads a registered action's own schema…" (`actionType()` is `"X"`). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/field_model.hpp tests/test_forms_engine_model.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): FormModel, the field model read from schema JSON + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 5: Encoders and decoders (`forms/engine/values.hpp`) + +Pure functions over a `FieldSpec`: a draft in, an `Encoding` out, byte-exact; a wire value in, a draft out. The +session (Task 6) composes them; nothing here is reactive. + +**Files:** +- Create: `include/morph/forms/engine/values.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/forms/engine/field_model.hpp`, add + `include/morph/forms/engine/values.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_values.cpp` after `test_forms_engine_model.cpp` +- Test: `tests/test_forms_engine_values.cpp` + +**Interfaces:** +- Consumes: Task 1's `math::parseDecimal`; Task 2's text helpers; Task 3's `Scalar`, `DecimalText`; Task 4's + `FieldSpec`, `FieldKind`, `UnitOption`, `ChoiceSpec`, `ChoiceOption`; `render::NumericLocale`, + `render::normalizeLocaleNumber`, `render::formatCanonicalNumber` (`include/morph/render/locale_format.hpp`); + `render::TranslationProvider`, `render::resolveText` (`include/morph/render/i18n.hpp`); `math::checkedMul`, + `math::checkedDiv`, `math::detail::mulOverflows`, `math::detail::powerOfTen` (`include/morph/util/rational.hpp`). +- Produces (`morph::forms`): + - `FieldError{code, message}`; `WireValue{json, scalar}`; `Encoding = + std::optional>` (`nullopt` = blank). + - `ValueContext{locale, displayOffsetMinutes, translations, bcp47}` — the session's options a value + conversion needs. + - `encodeScalar(FieldSpec const&, std::string_view draft, std::size_t unit, ValueContext const&) -> Encoding` + for every kind but Object and ObjectArray. + - `decodeScalar(FieldSpec const&, detail::engine::Json const& value, ValueContext const&) -> std::string`. + - `convertDraft(std::string_view draft, UnitOption const& from, UnitOption const& to, render::NumericLocale + const&) -> std::optional`. + - `parseOptions(std::string_view reply, ChoiceSpec const&) -> std::vector`. + - `defaultPlaceholder(FieldSpec const&) -> std::string`; `scalarOf(detail::engine::Json const&) -> Scalar`; + `fieldError(std::string_view code, ValueContext const&) -> FieldError`. + - Error codes: `malformed`, `too-precise`, `below-minimum`, `above-maximum`, `not-a-multiple`, `not-an-option`, + `out-of-range`, `incomplete`, `unrepresentable`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_values.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using morph::forms::Encoding; +using morph::forms::FieldSpec; +using morph::forms::FormModel; +using morph::forms::ValueContext; +namespace engine = morph::forms::detail::engine; + +/// One field described from a one-property schema. +[[nodiscard]] FieldSpec field(std::string_view propertyJson, std::string_view defs = "{}") { + std::string const schema = R"({"$defs":)" + std::string{defs} + R"(,"properties":{"f":)" + std::string{propertyJson} + + R"(},"required":["f"]})"; + auto model = FormModel::fromSchema("T", schema); + REQUIRE(model.has_value()); + return model->fields().front(); +} + +[[nodiscard]] std::string json(Encoding const& encoding) { + REQUIRE(encoding.has_value()); + INFO((encoding->has_value() ? std::string{} : encoding->error().code)); + REQUIRE(encoding->has_value()); + return (*encoding)->json; +} + +[[nodiscard]] std::string error(Encoding const& encoding) { + REQUIRE(encoding.has_value()); + REQUIRE_FALSE(encoding->has_value()); + return encoding->error().code; +} + +ValueContext const kContext{}; +ValueContext const kGerman{.locale = {.decimalSeparator = ",", .groupSeparator = "."}}; + +constexpr std::string_view kMass = R"({"$ref":"#/$defs/q","x-decimalPlaces":3,"x-unitAlternatives":[ + {"id":"g","display":"g","decimals":1,"num":1,"den":1000},{"id":"t","display":"t","decimals":4,"num":1000,"den":1}]})"; +constexpr std::string_view kMassDefs = R"({"q":{"type":["object","null"],"ExtUnits":{"unitAscii":"kg","unitUnicode":"kg"}}})"; +constexpr std::string_view kInt64Defs = R"({"int64_t":{"type":"integer","minimum":-9223372036854775808, + "maximum":9223372036854775807,"x-exactMinimum":"-9223372036854775808","x-exactMaximum":"9223372036854775807"}})"; + +} // namespace + +TEST_CASE("values: a Quantity keeps the unreduced scaled digits the QML renderer sent", "[forms-engine][values]") { + FieldSpec const mass = field(kMass, kMassDefs); + CHECK(json(morph::forms::encodeScalar(mass, "2650.5", 0, kContext)) == R"({"num":2650500,"den":1000,"dp":3})"); + CHECK(json(morph::forms::encodeScalar(mass, "2650500.0", 1, kContext)) == R"({"num":26505000,"den":10000,"dp":3})"); + CHECK(json(morph::forms::encodeScalar(mass, "1.050,25", 0, kGerman)) == R"({"num":1050250,"den":1000,"dp":3})"); + CHECK(error(morph::forms::encodeScalar(mass, "2650.5001", 0, kContext)) == "too-precise"); + CHECK(error(morph::forms::encodeScalar(mass, "1.5", 0, kGerman)) == "malformed"); + FieldSpec const tenth = field(R"({"type":"object","x-decimalPlaces":1,"ExtUnits":{"unitAscii":"x"}})"); + CHECK(json(morph::forms::encodeScalar(tenth, "-0.5", 0, kContext)) == R"({"num":-5,"den":10,"dp":1})"); + CHECK(json(morph::forms::encodeScalar(tenth, "123456789012345678.9", 0, kContext)) == + R"({"num":1234567890123456789,"den":10,"dp":1})"); + CHECK(error(morph::forms::encodeScalar(tenth, "1234567890123456789.9", 0, kContext)) == "out-of-range"); + FieldSpec const decimal = field(R"({"type":"object","x-decimalPlaces":2})"); + CHECK(json(morph::forms::encodeScalar(decimal, "3.5", 0, kContext)) == R"({"num":350,"den":100,"dp":2})"); +} + +TEST_CASE("values: Quantity bounds, instance bounds and multipleOf are checked exactly in the canonical unit", + "[forms-engine][values]") { + FieldSpec const budget = field(R"({"type":"object","x-decimalPlaces":1,"ExtUnits":{"unitAscii":"count"},"minimum":1,"multipleOf":1})"); + CHECK(error(morph::forms::encodeScalar(budget, "0", 0, kContext)) == "below-minimum"); + CHECK(error(morph::forms::encodeScalar(budget, "2.5", 0, kContext)) == "not-a-multiple"); + CHECK(json(morph::forms::encodeScalar(budget, "2", 0, kContext)) == R"({"num":20,"den":10,"dp":1})"); + FieldSpec const step = field(R"({"type":"object","x-decimalPlaces":2,"multipleOf":0.5,"ExtUnits":{"unitAscii":"u"}})"); + CHECK((*morph::forms::encodeScalar(step, "1.5", 0, kContext)).has_value()); + CHECK(error(morph::forms::encodeScalar(step, "0.75", 0, kContext)) == "not-a-multiple"); + FieldSpec const decorated = field(R"({"type":["object","null"],"ExtUnits":{"unitAscii":"mg_per_L"},"x-decimalPlaces":1, + "x-minimum":{"num":0,"den":1,"dp":1},"x-maximum":{"num":20,"den":1,"dp":1}})"); + CHECK((*morph::forms::encodeScalar(decorated, "12.5", 0, kContext)).has_value()); + CHECK(error(morph::forms::encodeScalar(decorated, "25", 0, kContext)) == "above-maximum"); + CHECK(error(morph::forms::encodeScalar(decorated, "-1", 0, kContext)) == "below-minimum"); + CHECK(error(morph::forms::encodeScalar(decorated, "12.55", 0, kContext)) == "too-precise"); + FieldSpec const capped = field(R"({"$ref":"#/$defs/q","x-decimalPlaces":3,"maximum":2000, + "x-unitAlternatives":[{"id":"g","display":"g","decimals":1,"num":1,"den":1000}]})", kMassDefs); + CHECK(error(morph::forms::encodeScalar(capped, "2000000.1", 1, kContext)) == "above-maximum"); // checked in kg +} + +TEST_CASE("values: unit conversion is exact and rounds half away from zero", "[forms-engine][values]") { + using morph::forms::convertDraft; + using morph::forms::UnitOption; + UnitOption const kilograms{"kg", "kg", 3, 1, 1}; + UnitOption const gram{"g", "g", 1, 1, 1000}; + UnitOption const tonne{"t", "t", 4, 1000, 1}; + CHECK(convertDraft("2650.5", kilograms, gram, {}) == "2650500.0"); + CHECK(convertDraft("2650500.0", gram, kilograms, {}) == "2650.500"); + CHECK(convertDraft("2650.5", kilograms, tonne, {}) == "2.6505"); + CHECK(convertDraft("2.6505", tonne, gram, {}) == "2650500.0"); + CHECK(convertDraft("-0.001", kilograms, gram, {}) == "-1.0"); + CHECK(convertDraft("0.05", UnitOption{"a", "a", 2, 1, 1}, UnitOption{"b", "b", 1, 1, 1}, {}) == "0.1"); + CHECK(convertDraft("junk", kilograms, gram, {}) == std::nullopt); + CHECK(convertDraft("2650,5", kilograms, gram, {.decimalSeparator = ",", .groupSeparator = "."}) == "2.650.500,0"); +} + +TEST_CASE("values: an integer keeps every digit and prefers the exact bound", "[forms-engine][values]") { + FieldSpec const wideId = field(R"({"$ref":"#/$defs/int64_t"})", kInt64Defs); + CHECK(json(morph::forms::encodeScalar(wideId, "9223372036854775807", 0, kContext)) == "9223372036854775807"); + CHECK(json(morph::forms::encodeScalar(wideId, "-9223372036854775808", 0, kContext)) == "-9223372036854775808"); + CHECK(error(morph::forms::encodeScalar(wideId, "9223372036854775808", 0, kContext)) == "above-maximum"); + CHECK(error(morph::forms::encodeScalar(wideId, "-9223372036854775809", 0, kContext)) == "below-minimum"); + CHECK(error(morph::forms::encodeScalar(wideId, "99999999999999999999", 0, kContext)) == "above-maximum"); + CHECK(json(morph::forms::encodeScalar(wideId, "0009223372036854775807", 0, kContext)) == "9223372036854775807"); + CHECK(json(morph::forms::encodeScalar(wideId, "-42", 0, kContext)) == "-42"); + CHECK(error(morph::forms::encodeScalar(wideId, "banana", 0, kContext)) == "malformed"); + FieldSpec const small = field(R"({"type":"integer","minimum":-10,"maximum":10})"); + CHECK((*morph::forms::encodeScalar(small, "10", 0, kContext)).has_value()); + CHECK(error(morph::forms::encodeScalar(small, "11", 0, kContext)) == "above-maximum"); + CHECK(error(morph::forms::encodeScalar(small, "-11", 0, kContext)) == "below-minimum"); + FieldSpec const unbounded = field(R"({"type":"integer"})"); + CHECK(json(morph::forms::encodeScalar(unbounded, "18446744073709551615", 0, kContext)) == "18446744073709551615"); + auto const scalar = (*morph::forms::encodeScalar(wideId, "9007199254740993", 0, kContext))->scalar; + CHECK(std::get(scalar).canonical == "9007199254740993"); +} + +TEST_CASE("values: a plain number is a JSON number of the digits typed, gated by its bounds", + "[forms-engine][values]") { + constexpr std::string_view kDefs = R"({"double":{"type":"number","minimum":-1.7976931348623157e+308,"maximum":1.7976931348623157e+308}, + "float":{"type":"number","minimum":-3.4028234663852886e+38,"maximum":3.4028234663852886e+38}})"; + FieldSpec const ratio = field(R"({"$ref":"#/$defs/double"})", kDefs); + FieldSpec const score = field(R"({"$ref":"#/$defs/float"})", kDefs); + CHECK(json(morph::forms::encodeScalar(ratio, "3.5", 0, kContext)) == "3.5"); + CHECK(json(morph::forms::encodeScalar(ratio, "4", 0, kContext)) == "4"); + CHECK(json(morph::forms::encodeScalar(ratio, "-0.25", 0, kContext)) == "-0.25"); + CHECK(json(morph::forms::encodeScalar(ratio, "007.50", 0, kContext)) == "7.50"); + for (std::string_view const text : {"abc", "3.", ".5", "1e5", "--1", "1 2", "0x10", "3,5"}) { + INFO("accepted " << text); + CHECK(error(morph::forms::encodeScalar(ratio, text, 0, kContext)) == "malformed"); + } + ValueContext const grouped{.locale = {.groupSeparator = ","}}; + CHECK(json(morph::forms::encodeScalar(ratio, "1,000.5", 0, grouped)) == "1000.5"); + std::string const huge = "100000000000000000000000000000000000000000"; + CHECK(error(morph::forms::encodeScalar(score, huge, 0, kContext)) == "above-maximum"); + CHECK(json(morph::forms::encodeScalar(ratio, huge, 0, kContext)) == huge); + FieldSpec const temperature = field(R"({"type":"number","minimum":-40.5,"maximum":120,"multipleOf":0.5})"); + CHECK((*morph::forms::encodeScalar(temperature, "-40.5", 0, kContext)).has_value()); + CHECK(error(morph::forms::encodeScalar(temperature, "-41", 0, kContext)) == "below-minimum"); + CHECK(error(morph::forms::encodeScalar(temperature, "120.5", 0, kContext)) == "above-maximum"); + CHECK(error(morph::forms::encodeScalar(temperature, "0.25", 0, kContext)) == "not-a-multiple"); + CHECK(json(morph::forms::encodeScalar(temperature, "0.5", 0, kContext)) == "0.5"); + FieldSpec const density = field(R"({"type":"number","x-displayDecimals":3})"); + CHECK(json(morph::forms::encodeScalar(density, "2.505", 0, kContext)) == "2.505"); + CHECK(error(morph::forms::encodeScalar(density, "2.5051", 0, kContext)) == "too-precise"); + FieldSpec const whole = field(R"({"type":"number","x-displayDecimals":0})"); + CHECK(json(morph::forms::encodeScalar(whole, "12", 0, kContext)) == "12"); + CHECK(error(morph::forms::encodeScalar(whole, "12.5", 0, kContext)) == "too-precise"); +} + +TEST_CASE("values: booleans, enums, choices, dates and arrays encode to their wire shapes", "[forms-engine][values]") { + FieldSpec const flag = field(R"({"type":"boolean"})"); + CHECK(json(morph::forms::encodeScalar(flag, "true", 0, kContext)) == "true"); + CHECK(json(morph::forms::encodeScalar(flag, "", 0, kContext)) == "false"); // required: an unchecked box answers + FieldSpec optionalFlag = flag; + optionalFlag.required = false; + CHECK_FALSE(morph::forms::encodeScalar(optionalFlag, "", 0, kContext).has_value()); + CHECK(error(morph::forms::encodeScalar(flag, "banana", 0, kContext)) == "malformed"); + + FieldSpec const role = field(R"({"type":"string","oneOf":[{"title":"Viewer","const":"Viewer"},{"title":"Manager","const":"Manager"}]})"); + CHECK(json(morph::forms::encodeScalar(role, R"("Manager")", 0, kContext)) == R"("Manager")"); + CHECK(error(morph::forms::encodeScalar(role, R"("Emperor")", 0, kContext)) == "not-an-option"); + CHECK(error(morph::forms::encodeScalar(role, "Manager", 0, kContext)) == "not-an-option"); + + FieldSpec const slot = field(R"({"type":"integer","x-optionsAction":"ListSlots"})"); + CHECK(json(morph::forms::encodeScalar(slot, "9007199254740993", 0, kContext)) == "9007199254740993"); + CHECK(error(morph::forms::encodeScalar(slot, "{not json", 0, kContext)) == "malformed"); + + FieldSpec const when = field(R"({"type":"string","format":"date-time"})"); + CHECK(json(morph::forms::encodeScalar(when, "2026-07-05T14:30", 0, kContext)) == R"("2026-07-05T14:30:00Z")"); + CHECK(json(morph::forms::encodeScalar(when, "2026-07-05T16:30:00", 0, ValueContext{.displayOffsetMinutes = 120})) == + R"("2026-07-05T14:30:00Z")"); + CHECK(error(morph::forms::encodeScalar(when, "not-a-date", 0, kContext)) == "malformed"); + FieldSpec const day = field(R"({"type":"string","format":"date"})"); + CHECK(json(morph::forms::encodeScalar(day, "2026-07-20", 0, kContext)) == R"("2026-07-20")"); + CHECK(error(morph::forms::encodeScalar(day, "2026-07-32", 0, kContext)) == "malformed"); + + FieldSpec const tags = field(R"({"type":"array","items":{"type":"string"}})"); + CHECK(json(morph::forms::encodeScalar(tags, "red, green, blue", 0, kContext)) == R"(["red","green","blue"])"); + CHECK(json(morph::forms::encodeScalar(tags, " red ,, green , ", 0, kContext)) == R"(["red","green"])"); + CHECK(json(morph::forms::encodeScalar(tags, " , , ", 0, kContext)) == "[]"); + FieldSpec const counts = field(R"({"type":"array","items":{"type":"integer"}})"); + CHECK(json(morph::forms::encodeScalar(counts, "1, 2, 30", 0, kContext)) == "[1,2,30]"); + CHECK(error(morph::forms::encodeScalar(counts, "1, x", 0, kContext)) == "malformed"); + + FieldSpec const text = field(R"({"type":"string"})"); + CHECK(json(morph::forms::encodeScalar(text, " say \"hi\" ", 0, kContext)) == R"("say \"hi\"")"); +} + +TEST_CASE("values: decoding is the inverse of encoding, per kind", "[forms-engine][values]") { + auto const decode = [](FieldSpec const& spec, std::string_view value, ValueContext const& context = kContext) { + auto const dom = engine::parseJson(value); + REQUIRE(dom.has_value()); + return morph::forms::decodeScalar(spec, *dom, context); + }; + FieldSpec const density = field(R"({"type":["object","null"],"ExtUnits":{"unitAscii":"kg_per_m3"},"x-decimalPlaces":2})"); + CHECK(decode(density, R"({"num":5,"den":4,"dp":2})") == "1.25"); + CHECK(decode(density, R"({"num":-1,"den":3,"dp":2})") == "-0.33"); + CHECK(decode(density, R"({"num":245050,"den":100,"dp":2})") == "2450.50"); + CHECK(decode(density, R"({"num":245050,"den":100,"dp":2})", kGerman) == "2450,50"); + CHECK(decode(density, R"("oops")").empty()); + FieldSpec const temperature = field(R"({"type":"number"})"); + CHECK(decode(temperature, "1e-7") == "0.0000001"); + CHECK(decode(temperature, "3") == "3"); + CHECK(decode(temperature, "21.5", kGerman) == "21,5"); + FieldSpec const padded = field(R"({"type":"number","x-displayDecimals":2})"); + CHECK(decode(padded, "3") == "3.00"); + CHECK(decode(padded, "21.5") == "21.50"); + CHECK(decode(padded, "1.23456") == "1.23456"); + CHECK(decode(field(R"({"type":"boolean"})"), "false") == "false"); + CHECK(decode(field(R"({"type":"string","oneOf":[{"const":"A"},{"const":"B"}]})"), R"("A")") == R"("A")"); + FieldSpec const takenAt = field(R"({"type":"string","format":"date-time"})"); + CHECK(decode(takenAt, R"("2026-01-01T00:30:00+01:00")") == "2025-12-31T23:30:00"); + CHECK(decode(takenAt, R"("2026-07-20T09:00:00Z")", ValueContext{.displayOffsetMinutes = 120}) == + "2026-07-20T11:00:00"); + CHECK(decode(field(R"({"type":"integer"})"), "9007199254740993") == "9007199254740993"); + CHECK(decode(field(R"({"type":"array","items":{"type":"string"}})"), R"(["a","b"])") == "a, b"); + CHECK(decode(field(R"({"type":"string"})"), "null").empty()); +} + +TEST_CASE("values: option rows are the reply array or its first array member, ids exact", "[forms-engine][values]") { + morph::forms::ChoiceSpec const spec{.optionsAction = "ListRows"}; + CHECK(morph::forms::parseOptions(R"([{"id":1,"name":"One"}])", spec).size() == 1); + CHECK(morph::forms::parseOptions(R"({"rows":[{"id":1},{"id":2}]})", spec).size() == 2); + CHECK(morph::forms::parseOptions(R"({"nothing":1})", spec).empty()); + CHECK(morph::forms::parseOptions("not json", spec).empty()); + auto const big = morph::forms::parseOptions( + R"({"rows":[{"id":9007199254740993,"name":"Alpha"},{"id":9007199254740992,"name":"Beta"}]})", spec); + REQUIRE(big.size() == 2); + CHECK(big[0].valueJson == "9007199254740993"); + CHECK(big[1].valueJson == "9007199254740992"); + CHECK(big[0].label == "Alpha"); + morph::forms::ChoiceSpec const byId{.optionsAction = "ListRows", .labelField = "id"}; + CHECK(morph::forms::parseOptions(R"([{"id":9007199254740993}])", byId).front().label == "9007199254740993"); +} + +TEST_CASE("values: placeholders follow the kind and the declared precision", "[forms-engine][values]") { + CHECK(morph::forms::defaultPlaceholder(field(R"({"type":"number","x-displayDecimals":3})")) == "0.000"); + CHECK(morph::forms::defaultPlaceholder(field(R"({"type":"number","x-displayDecimals":0})")) == "0"); + CHECK(morph::forms::defaultPlaceholder(field(R"({"type":"number"})")).empty()); + CHECK(morph::forms::defaultPlaceholder(field(R"({"type":"integer"})")) == "0"); + CHECK(morph::forms::defaultPlaceholder(field(kMass, kMassDefs)) == "0.000"); + CHECK(morph::forms::defaultPlaceholder(field(R"({"type":"string","x-placeholder":"e.g. 1050"})")) == "e.g. 1050"); + CHECK(morph::forms::defaultPlaceholder(field(R"({"type":"array","items":{"type":"string"}})")) == + "comma-separated (e.g. red, green, blue)"); +} + +TEST_CASE("values: an error message resolves through the translation provider", "[forms-engine][values]") { + ValueContext const translated{.translations = + [](std::string_view key, std::string_view locale) -> std::optional { + if (key == "morph.forms.error.malformed" && locale == "de") { + return "Ungültiger Wert"; + } + return std::nullopt; + }, + .bcp47 = "de"}; + auto const encoding = morph::forms::encodeScalar(field(R"({"type":"integer"})"), "x", 0, translated); + REQUIRE(encoding.has_value()); + CHECK(encoding->error().message == "Ungültiger Wert"); + CHECK(morph::forms::fieldError("too-precise", kContext).message == "Too many decimal places"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: `fatal error: 'morph/forms/engine/values.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `include/morph/forms/engine/values.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/values.hpp +/// @brief The forms engine's encoders and decoders: a field's draft text to its exact wire +/// literal, a wire value back to the draft a control shows, exact unit conversion, and +/// Choice option rows. +/// +/// Specified in `docs/spec/forms/engine.md`, "Values and exactness". + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../render/i18n.hpp" +#include "../../render/locale_format.hpp" +#include "../../util/rational.hpp" +#include "detail/text.hpp" +#include "field_model.hpp" +#include "rules.hpp" + +namespace morph::forms { + +/// @brief Why a draft does not encode: a stable code and the message shown beside the field. +struct FieldError { + /// @brief One of `malformed`, `too-precise`, `below-minimum`, `above-maximum`, `not-a-multiple`, + /// `not-an-option`, `out-of-range`, `incomplete`, `unrepresentable`. + std::string code{}; + /// @brief The message, resolved through the session's translations + /// (key `morph.forms.error.`), else the English text. + std::string message{}; + /// @brief Member-wise equality. + /// @param other The other error. + /// @return `true` when code and message match. + bool operator==(FieldError const& other) const = default; +}; + +/// @brief One field's encoded value. +struct WireValue { + /// @brief The JSON literal the body carries, byte for byte. + std::string json{}; + /// @brief The exact value rules compare (`std::monostate` for arrays and objects). + Scalar scalar{}; + /// @brief Member-wise equality. + /// @param other The other value. + /// @return `true` when literal and scalar match. + bool operator==(WireValue const& other) const = default; +}; + +/// @brief A draft's encoding: `std::nullopt` when blank, else the value or why it does not encode. +using Encoding = std::optional>; + +/// @brief The session options a value conversion reads. +struct ValueContext { + /// @brief How numbers are typed and shown. The views must outlive the context. + render::NumericLocale locale{}; + /// @brief The display zone for date-times, in minutes east of UTC. + int displayOffsetMinutes = 0; + /// @brief The host's catalog, for error messages; empty means none. + render::TranslationProvider translations{}; + /// @brief The locale to resolve messages in. + std::string bcp47 = "en"; +}; + +/// @brief The error for @p code, its message resolved through @p context's translations. +/// @param code A `FieldError::code`. +/// @param context The session's value context. +/// @return The error. +[[nodiscard]] inline FieldError fieldError(std::string_view code, ValueContext const& context) { + constexpr std::array, 9> kEnglish{{ + {"malformed", "Not a valid value"}, + {"too-precise", "Too many decimal places"}, + {"below-minimum", "Below the minimum"}, + {"above-maximum", "Above the maximum"}, + {"not-a-multiple", "Not an allowed step"}, + {"not-an-option", "Not one of the options"}, + {"out-of-range", "Too large to send exactly"}, + {"incomplete", "A required part is missing"}, + {"unrepresentable", "Nested too deeply to edit here"}, + }}; + auto const found = std::ranges::find(kEnglish, code, &std::pair::first); + std::string_view const english = found == kEnglish.end() ? std::string_view{"Not a valid value"} : found->second; + std::string const key = "morph.forms.error." + std::string{code}; + return FieldError{std::string{code}, render::resolveText(context.translations, context.bcp47, std::nullopt, key, english)}; +} + +/// @brief The scalar a JSON literal denotes, for rules: a boolean, a string, or a decimal. +/// @param value The literal. +/// @return The scalar; `std::monostate` for null, arrays and objects. +[[nodiscard]] inline Scalar scalarOf(detail::engine::Json const& value) { + if (auto const* const flag = value.get_if()) { + return *flag; + } + if (auto const* const text = value.get_if()) { + return *text; + } + if (auto number = detail::engine::numberText(value)) { + return DecimalText{*std::move(number)}; + } + return std::monostate{}; +} + +namespace detail::engine { + +/// @brief A canonical decimal in @p locale's spelling, without grouping — what a control is seeded with. +/// @param canonical Canonical decimal text. +/// @param locale The display locale. +/// @return The draft text. +[[nodiscard]] inline std::string draftNumber(std::string_view canonical, render::NumericLocale const& locale) { + render::NumericLocale ungrouped = locale; + ungrouped.groupSeparator = {}; + return render::formatCanonicalNumber(canonical, ungrouped); +} + +/// @brief Checks @p canonical against a field's `minimum`, `maximum` and `multipleOf`. +/// @param spec The field. +/// @param canonical The value as canonical decimal text. +/// @param context For the message. +/// @return The violation, or `std::nullopt`. +[[nodiscard]] inline std::optional decimalBounds(FieldSpec const& spec, std::string_view canonical, + ValueContext const& context) { + if (spec.minimum && compareDecimal(canonical, *spec.minimum) == std::strong_ordering::less) { + return fieldError("below-minimum", context); + } + if (spec.maximum && compareDecimal(canonical, *spec.maximum) == std::strong_ordering::greater) { + return fieldError("above-maximum", context); + } + if (spec.multipleOf) { + auto const value = math::parseDecimal(canonical, render::NumericLocale{}); + auto const step = math::parseDecimal(*spec.multipleOf, render::NumericLocale{}); + auto const quotient = (value && step) ? math::checkedDiv(*value, *step) : std::nullopt; + if (!quotient || !quotient->isInteger()) { + return fieldError("not-a-multiple", context); + } + } + return std::nullopt; +} + +/// @brief Encodes an integer draft. +/// @param spec The field. +/// @param text The trimmed draft. +/// @param context For messages. +/// @return The encoding. +[[nodiscard]] inline Encoding encodeInteger(FieldSpec const& spec, std::string_view text, ValueContext const& context) { + if (!isCanonicalInteger(text)) { + return std::unexpected(fieldError("malformed", context)); + } + std::string const normalised = stripLeadingZeros(text); + auto const lower = spec.exactMinimum ? spec.exactMinimum : spec.minimum; + auto const upper = spec.exactMaximum ? spec.exactMaximum : spec.maximum; + if (lower && compareDecimal(normalised, *lower) == std::strong_ordering::less) { + return std::unexpected(fieldError("below-minimum", context)); + } + if (upper && compareDecimal(normalised, *upper) == std::strong_ordering::greater) { + return std::unexpected(fieldError("above-maximum", context)); + } + if (spec.multipleOf) { + if (auto const violation = decimalBounds(FieldSpec{.multipleOf = spec.multipleOf}, normalised, context)) { + return std::unexpected(*violation); + } + } + return WireValue{normalised, DecimalText{normalised}}; +} + +/// @brief Encodes a plain JSON number draft. +/// @param spec The field. +/// @param text The trimmed draft. +/// @param context The locale and messages. +/// @return The encoding. +[[nodiscard]] inline Encoding encodeNumber(FieldSpec const& spec, std::string_view text, ValueContext const& context) { + auto const canonical = render::normalizeLocaleNumber(text, context.locale); + if (!canonical || !isCanonicalDecimal(*canonical)) { + return std::unexpected(fieldError("malformed", context)); + } + auto const dot = canonical->find('.'); + std::size_t const places = dot == std::string::npos ? 0 : canonical->size() - dot - 1; + if (spec.displayDecimals && places > *spec.displayDecimals) { + return std::unexpected(fieldError("too-precise", context)); + } + if (auto const violation = decimalBounds(spec, *canonical, context)) { + return std::unexpected(*violation); + } + std::string const literal = stripLeadingZeros(*canonical); + return WireValue{literal, DecimalText{literal}}; +} + +/// @brief Encodes a Decimal or Quantity draft typed in `spec.units[unit]` as the exact +/// `{num, den, dp}` in the canonical unit, unreduced: the scaled digits times the unit's +/// numerator over `10^decimals` times its denominator. +/// @param spec The field. +/// @param text The trimmed draft. +/// @param unit Index into `spec.units`. +/// @param context The locale and messages. +/// @return The encoding. +[[nodiscard]] inline Encoding encodeExact(FieldSpec const& spec, std::string_view text, std::size_t unit, + ValueContext const& context) { + UnitOption const fallback{spec.unitAscii, spec.unit, spec.decimalPlaces.value_or(0), 1, 1}; + UnitOption const& entry = unit < spec.units.size() ? spec.units.at(unit) : fallback; + auto const canonical = render::normalizeLocaleNumber(text, context.locale); + if (!canonical || !isCanonicalDecimal(*canonical)) { + return std::unexpected(fieldError("malformed", context)); + } + auto const dot = canonical->find('.'); + std::size_t const places = dot == std::string::npos ? 0 : canonical->size() - dot - 1; + if (places > entry.decimals) { + return std::unexpected(fieldError("too-precise", context)); + } + auto const typed = math::parseDecimal(*canonical, render::NumericLocale{}, entry.decimals); + std::int64_t const scale = math::detail::powerOfTen(entry.decimals); + if (!typed || scale == 0) { + return std::unexpected(fieldError("out-of-range", context)); + } + std::int64_t const factor = scale / typed->denominator; + if (math::detail::mulOverflows(typed->numerator, factor)) { + return std::unexpected(fieldError("out-of-range", context)); + } + std::int64_t const scaled = typed->numerator * factor; + if (math::detail::mulOverflows(scaled, entry.num) || math::detail::mulOverflows(scale, entry.den)) { + return std::unexpected(fieldError("out-of-range", context)); + } + std::int64_t const num = scaled * entry.num; + std::int64_t const den = scale * entry.den; + std::uint32_t const canonDp = spec.decimalPlaces.value_or(0); + math::Rational const value{math::Numerator{num}, math::Denominator{den}, math::DecimalPlaces{canonDp}}; + if (spec.minimum && compareRationalToDecimal(value, *spec.minimum) == std::strong_ordering::less) { + return std::unexpected(fieldError("below-minimum", context)); + } + if (spec.maximum && compareRationalToDecimal(value, *spec.maximum) == std::strong_ordering::greater) { + return std::unexpected(fieldError("above-maximum", context)); + } + if (spec.instanceMinimum && value < *spec.instanceMinimum) { + return std::unexpected(fieldError("below-minimum", context)); + } + if (spec.instanceMaximum && value > *spec.instanceMaximum) { + return std::unexpected(fieldError("above-maximum", context)); + } + if (spec.multipleOf) { + auto const step = math::parseDecimal(*spec.multipleOf, render::NumericLocale{}); + auto const quotient = step ? math::checkedDiv(value, *step) : std::nullopt; + if (!quotient || !quotient->isInteger()) { + return std::unexpected(fieldError("not-a-multiple", context)); + } + } + std::string const json = R"({"num":)" + std::to_string(num) + R"(,"den":)" + std::to_string(den) + R"(,"dp":)" + + std::to_string(canonDp) + "}"; + return WireValue{json, value}; +} + +/// @brief Encodes a comma-separated array draft by the item type. +/// @param spec The field. +/// @param text The trimmed draft. +/// @param context For messages. +/// @return The encoding; blank items are dropped, so `" , "` is `[]`. +[[nodiscard]] inline Encoding encodeArray(FieldSpec const& spec, std::string_view text, ValueContext const& context) { + std::string json = "["; + bool first = true; + std::size_t start = 0; + while (start <= text.size()) { + auto const comma = text.find(',', start); + std::string_view const item = trim(text.substr(start, comma == std::string_view::npos ? text.npos : comma - start)); + start = comma == std::string_view::npos ? text.size() + 1 : comma + 1; + if (item.empty()) { + continue; + } + std::string literal; + if (spec.itemType == "integer") { + if (!isCanonicalInteger(item)) { + return std::unexpected(fieldError("malformed", context)); + } + literal = stripLeadingZeros(item); + } else if (spec.itemType == "number") { + if (!isCanonicalDecimal(item)) { + return std::unexpected(fieldError("malformed", context)); + } + literal = stripLeadingZeros(item); + } else if (spec.itemType == "boolean") { + if (item != "true" && item != "false") { + return std::unexpected(fieldError("malformed", context)); + } + literal = std::string{item}; + } else { + literal = quote(item); + } + json += (first ? "" : ",") + literal; + first = false; + } + json += "]"; + return WireValue{json, std::monostate{}}; +} + +} // namespace detail::engine + +/// @brief Encodes one scalar field's draft (every kind but `Object` and `ObjectArray`). +/// +/// The draft is trimmed first. Blank is `std::nullopt` — except a required Boolean, which encodes +/// `false`: an unchecked box is an answer, and a required box always shows one. +/// @param spec The field. +/// @param draft What the user typed, or what a control holds (an option's `valueJson`, `true`/`false`). +/// @param unit For a Decimal or Quantity, the index into `spec.units` the draft is typed in. +/// @param context The locale, display zone and translations. +/// @return The encoding. +[[nodiscard]] inline Encoding encodeScalar(FieldSpec const& spec, std::string_view draft, std::size_t unit, + ValueContext const& context) { + using namespace detail::engine; + std::string_view const text = trim(draft); + if (text.empty()) { + if (spec.kind == FieldKind::Boolean && spec.required) { + return WireValue{"false", false}; + } + return std::nullopt; + } + switch (spec.kind) { + case FieldKind::Text: + case FieldKind::Multiline: + return WireValue{quote(text), std::string{text}}; + case FieldKind::Date: + if (!isIsoDate(text)) { + return std::unexpected(fieldError("malformed", context)); + } + return WireValue{quote(text), std::string{text}}; + case FieldKind::DateTime: { + auto const wall = parseWallClock(text); + if (!wall) { + return std::unexpected(fieldError("malformed", context)); + } + std::string const utc = formatUtc(*wall - std::chrono::minutes{context.displayOffsetMinutes}); + return WireValue{quote(utc), utc}; + } + case FieldKind::Boolean: + if (text == "true" || text == "false") { + return WireValue{std::string{text}, text == "true"}; + } + return std::unexpected(fieldError("malformed", context)); + case FieldKind::Enum: { + auto const found = std::ranges::find(spec.enumOptions, text, &ChoiceOption::valueJson); + if (found == spec.enumOptions.end()) { + return std::unexpected(fieldError("not-an-option", context)); + } + auto const literal = parseJson(text); + return WireValue{std::string{text}, literal ? scalarOf(*literal) : Scalar{}}; + } + case FieldKind::Choice: { + auto const literal = parseJson(text); + if (!literal || literal->is_object() || literal->is_array() || literal->is_null()) { + return std::unexpected(fieldError("malformed", context)); + } + return WireValue{std::string{text}, scalarOf(*literal)}; + } + case FieldKind::Integer: + return encodeInteger(spec, text, context); + case FieldKind::Slider: + return spec.jsonType == "number" ? encodeNumber(spec, text, context) : encodeInteger(spec, text, context); + case FieldKind::Number: + return encodeNumber(spec, text, context); + case FieldKind::Decimal: + case FieldKind::Quantity: + return encodeExact(spec, text, unit, context); + case FieldKind::Array: + return encodeArray(spec, text, context); + default: + return std::unexpected(fieldError("unrepresentable", context)); + } +} + +/// @brief The draft a control shows for the wire value @p value — the inverse of `encodeScalar`, +/// so encoding the result re-encodes @p value (numbers in the display locale, without +/// grouping; instants in the display zone). +/// @param spec The field. +/// @param value The wire value (JSON null and a mismatched shape decode to blank). +/// @param context The locale and display zone. +/// @return The draft text. +[[nodiscard]] inline std::string decodeScalar(FieldSpec const& spec, detail::engine::Json const& value, + ValueContext const& context) { + using namespace detail::engine; + if (value.is_null()) { + return {}; + } + switch (spec.kind) { + case FieldKind::Text: + case FieldKind::Multiline: + case FieldKind::Date: { + auto const* const text = value.get_if(); + return text == nullptr ? std::string{} : *text; + } + case FieldKind::DateTime: { + auto const* const text = value.get_if(); + auto const instant = text == nullptr ? std::nullopt : parseInstant(*text); + return instant ? formatWallClock(*instant + std::chrono::minutes{context.displayOffsetMinutes}) + : std::string{}; + } + case FieldKind::Boolean: { + auto const* const flag = value.get_if(); + return flag == nullptr ? std::string{} : (*flag ? "true" : "false"); + } + case FieldKind::Enum: + case FieldKind::Choice: + return toJson(value); + case FieldKind::Integer: + case FieldKind::Slider: + return numberText(value).value_or(std::string{}); + case FieldKind::Number: { + auto canonical = numberText(value); + if (!canonical) { + return {}; + } + if (spec.displayDecimals) { + auto const dot = canonical->find('.'); + std::size_t const places = dot == std::string::npos ? 0 : canonical->size() - dot - 1; + if (places < *spec.displayDecimals) { + if (dot == std::string::npos) { + *canonical += '.'; + } + canonical->append(*spec.displayDecimals - places, '0'); + } + } + return draftNumber(*canonical, context.locale); + } + case FieldKind::Decimal: + case FieldKind::Quantity: { + auto const num = SchemaReader::signedOf(member(value, "num")); + auto const den = SchemaReader::signedOf(member(value, "den")); + if (!num || !den || *den == 0) { + return {}; + } + math::Rational const exact{math::Numerator{*num}, math::Denominator{*den}, math::DecimalPlaces{0}}; + return draftNumber(decimalText(exact, spec.decimalPlaces.value_or(0)), context.locale); + } + case FieldKind::Array: { + if (!value.is_array()) { + return {}; + } + std::string out; + for (Json const& item : value.get_array()) { + out += (out.empty() ? "" : ", ") + displayText(item); + } + return out; + } + default: + return {}; + } +} + +/// @brief Converts a draft from one unit to another exactly, rounding half away from zero to +/// the target unit's decimals. +/// @param draft The draft, in @p locale's spelling. +/// @param from The unit it is typed in. +/// @param target The unit to convert to. +/// @param locale The display locale (the result is grouped in it). +/// @return The converted draft, or `std::nullopt` when the draft is not a number or the +/// conversion leaves the exact range. +[[nodiscard]] inline std::optional convertDraft(std::string_view draft, UnitOption const& from, + UnitOption const& target, render::NumericLocale const& locale) { + auto const typed = math::parseDecimal(detail::engine::trim(draft), locale); + if (!typed || from.den == 0 || target.num == 0) { + return std::nullopt; + } + math::Rational const toCanonical{math::Numerator{from.num}, math::Denominator{from.den}, math::DecimalPlaces{0}}; + math::Rational const fromCanonical{math::Numerator{target.den}, math::Denominator{target.num}, math::DecimalPlaces{0}}; + auto const canonical = math::checkedMul(*typed, toCanonical); + auto const converted = canonical ? math::checkedMul(*canonical, fromCanonical) : canonical; + if (!converted) { + return std::nullopt; + } + return render::formatCanonicalNumber(detail::engine::decimalText(*converted, target.decimals), locale); +} + +/// @brief The options an options action's reply carries: the reply itself when it is an array, +/// else its first array-valued member; rows without the value member are skipped. +/// @param reply The reply JSON. +/// @param spec Which row members are value and label. +/// @return The options; empty for a reply that is not JSON or has no rows. +[[nodiscard]] inline std::vector parseOptions(std::string_view reply, ChoiceSpec const& spec) { + using namespace detail::engine; + std::vector options{}; + auto const dom = parseJson(reply); + if (!dom) { + return options; + } + Json const* rows = dom->is_array() ? &*dom : nullptr; + if (rows == nullptr && dom->is_object()) { + for (auto const& [key, candidate] : dom->get_object()) { + if (candidate.is_array()) { + rows = &candidate; + break; + } + } + } + if (rows == nullptr) { + return options; + } + for (Json const& row : rows->get_array()) { + Json const* const value = member(row, spec.valueField); + if (value == nullptr) { + continue; + } + Json const* const label = member(row, spec.labelField); + options.push_back(ChoiceOption{toJson(*value), displayText(label != nullptr ? *label : *value)}); + } + return options; +} + +/// @brief The placeholder a control shows when the schema declares none. +/// @param spec The field. +/// @return `x-placeholder` when declared; else `0.000…` for a Decimal or Quantity (at least one +/// place), `0` for an integer, the declared precision for a Number, a hint for an Array, +/// and empty otherwise. +[[nodiscard]] inline std::string defaultPlaceholder(FieldSpec const& spec) { + if (!spec.placeholder.empty()) { + return spec.placeholder; + } + switch (spec.kind) { + case FieldKind::Decimal: + case FieldKind::Quantity: + return "0." + std::string(std::max(1, spec.decimalPlaces.value_or(0)), '0'); + case FieldKind::Integer: + case FieldKind::Slider: + return "0"; + case FieldKind::Number: + if (!spec.displayDecimals) { + return {}; + } + return *spec.displayDecimals == 0 ? std::string{"0"} : "0." + std::string(*spec.displayDecimals, '0'); + case FieldKind::Array: + return "comma-separated (e.g. red, green, blue)"; + default: + return {}; + } +} + +} // namespace morph::forms +``` + +Add `#include `, `#include ` and `#include ` to the include list. Register the header +and the test as listed under **Files**. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][values]"` +Expected: PASS, 10 test cases. + +Mutation check: in `encodeExact`, replace `std::int64_t const den = scale * entry.den;` with +`std::int64_t const den = scale;`. Expected: FAIL in "a Quantity keeps the unreduced scaled digits…" (the gram +literal's `"den"` becomes `10`). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/values.hpp tests/test_forms_engine_values.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): exact encoders and decoders for every scalar field kind + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 6: `FormSession` and `FieldState` — drafts, readiness, body, submission, prefill and reset + +**Files:** +- Create: `include/morph/forms/engine/form_session.hpp` +- Create: `tests/forms_engine_support.hpp` (the fake server and harness every later engine test uses) +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/forms/engine/values.hpp`, add + `include/morph/forms/engine/form_session.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_session.cpp` after `test_forms_engine_values.cpp` +- Test: `tests/test_forms_engine_session.cpp` + +**Interfaces:** +- Consumes: Part 1 — `reactive::Runtime` (`batch`, `untracked`, `owner()`), `reactive::Signal` (`get`, `peek`, + `set`, `mutate`), `reactive::Computed` (`get`, `peek`), `reactive::Effect`, `reactive::Query(Runtime&, + Fetch, Key)` (`pending`, `value`, `error`), `reactive::Mutation(Runtime&, Run)` (`run`, `pending`, `error`, + `lastResult`); `async::Completion`; Tasks 2–5; `forms::i18n::fieldKey`, `explicitFieldKey`, `groupKey`, + `FieldSlot` (`include/morph/forms/i18n.hpp`); `render::resolveText`. +- Produces (`morph::forms`), exactly: + - `Submitter`, `ChoiceFetcher` (the contract's aliases). + - `FormSessionOptions{locale, bcp47, translations, displayOffsetMinutes}` plus the addition + `std::function accepts` — an extra readiness gate over the assembled body + (`Form` puts `ActionValidator` there). + - `FieldState` (non-copyable, non-movable): `spec()`, `path()`, `label()`, `help()`, `placeholder()`, + `text() -> reactive::Signal&`, `unit() -> reactive::Signal&`, + `engaged() -> reactive::Signal&`, `encoded() -> reactive::Computed const&`, + `error() -> reactive::Computed> const&`, `visible()`, `readonly()`, `required()` + (each `-> reactive::Computed const&`), `blank() -> bool` (tracked), `ruleValue() -> RuleValue` (tracked), + `options() -> std::vector const&` (tracked), `optionsPending() -> bool` (tracked), + `switchUnit(std::size_t)`, `load(detail::engine::Json const*)`, `clear()`, `start()`. Task 7 adds the nested + members and rows, Task 8 the Choice options. + - `FormSession`: the contract's members — `FormSession(reactive::Runtime&, FormModel, Submitter, ChoiceFetcher, + FormSessionOptions = {})`, `model()`, `field(std::string_view path) -> FieldState&`, `ready()`, `body()`, + `submit()`, `prefill(std::string_view)`, `reset()`, `pending()`, `lastReply()`, `lastError()` — plus the + additions `runtime()`, `fields()`, `assign(std::string_view path, std::string_view jsonValue)`, + `groupCollapsed(std::size_t) -> reactive::Signal&`, `tabSelection(std::size_t) -> + reactive::Signal&`, `groupTitle(std::size_t) -> std::string`, `submitMode()`. +- Paths accepted by `field()`: a top-level wire name (Task 7 adds `parent.member` and `rows[i].member`). + +How the pieces fit: + +- **A field's draft** is `text` (what the control holds), `unit` (a Quantity's entry unit) and `engaged` (a + blank-capable field was filled once: an `x-blankAs: empty` field, or a collection that had rows). `encoded` is the + pure encoder over those (Task 5), so a field's error, a rule's operand and the body all read one computed. +- **Readiness** is: every required field (static or by `requiredWhen`) encodes, no field has an error, no gating + rule is False (Task 3), and `accepts(body)` when set. `body()` is the assembled JSON while ready, else `nullopt`. +- **Submission** is a `reactive::Mutation` over the `Submitter`. In automatic mode an + Effect submits whenever the form is ready with a body different from the last one submitted or seen. A + programmatic change — `prefill`, `assign`, `reset`, clearing a stale Choice — ends by recording the body it + produced as seen (`body` is a pull-based computed, so it is current inside the batch), and the Effect, finding + that body when it runs, does not submit it; a user edit after it, even in the same flush, produces a different + body and does. A form that is ready as constructed submits once, as the QML renderer's `Component.onCompleted` did. +- **Two-phase construction**: every `FieldState` is built first, then `start()`ed, because a Choice's options key + reads its parents, which may come later in `x-order`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/forms_engine_support.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms_engine_support.hpp +/// @brief A fake server for the forms engine's Submitter and ChoiceFetcher, and the harness +/// (owner, runtime, server) every engine test builds sessions in. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace morph::test::formsengine { + +/// @brief One call the engine made. +struct Call { + std::string action{}; + std::string body{}; +}; + +/// @brief Records submits and options fetches and answers them on the owner. +/// +/// A responder returning a value resolves the call at once (delivered on the next drain); one +/// returning `std::nullopt` leaves it pending until `resolve…`/`reject…` settles it by index. +class FakeServer { +public: + using Responder = std::function(Call const&)>; + + explicit FakeServer(exec::IExecutor& owner) : _owner{&owner} {} + + void replyWith(Responder responder) { _submitResponder = std::move(responder); } + void optionsWith(Responder responder) { _fetchResponder = std::move(responder); } + + [[nodiscard]] forms::Submitter submitter() { + return [this](std::string_view action, std::string body) { + return record(_submits, _submitPromises, _submitResponder, action, std::move(body)); + }; + } + + [[nodiscard]] forms::ChoiceFetcher fetcher() { + return [this](std::string_view action, std::string body) { + return record(_fetches, _fetchPromises, _fetchResponder, action, std::move(body)); + }; + } + + [[nodiscard]] std::vector const& submits() const noexcept { return _submits; } + [[nodiscard]] std::vector const& fetches() const noexcept { return _fetches; } + [[nodiscard]] std::string const& lastBody() const { return _submits.back().body; } + + void resolveSubmit(std::size_t index, std::string reply) { _submitPromises.at(index)->resolve(std::move(reply)); } + void rejectSubmit(std::size_t index, std::string const& message) { + _submitPromises.at(index)->reject(std::make_exception_ptr(std::runtime_error{message})); + } + void resolveFetch(std::size_t index, std::string reply) { _fetchPromises.at(index)->resolve(std::move(reply)); } + void rejectFetch(std::size_t index, std::string const& message) { + _fetchPromises.at(index)->reject(std::make_exception_ptr(std::runtime_error{message})); + } + +private: + using Promise = async::Completion::Promise; + + async::Completion record(std::vector& calls, std::vector>& promises, + Responder const& responder, std::string_view action, std::string body) { + calls.push_back(Call{std::string{action}, std::move(body)}); + auto [completion, promise] = async::Completion::makeSettleable(_owner); + auto const shared = std::make_shared(std::move(promise)); + promises.push_back(shared); + if (responder) { + if (auto reply = responder(calls.back())) { + shared->resolve(*std::move(reply)); + } + } + return std::move(completion); + } + + exec::IExecutor* _owner; + Responder _submitResponder = [](Call const&) { return std::optional{R"({"ok":true})"}; }; + Responder _fetchResponder = [](Call const&) { return std::optional{"[]"}; }; + std::vector _submits{}; + std::vector _fetches{}; + std::vector> _submitPromises{}; + std::vector> _fetchPromises{}; +}; + +/// @brief Reads a schema that must be well-formed. +[[nodiscard]] inline forms::FormModel modelOf(std::string_view action, std::string_view schema) { + auto model = forms::FormModel::fromSchema(action, schema); + INFO((model ? std::string{} : model.error().path + ": " + model.error().message)); + REQUIRE(model.has_value()); + return *std::move(model); +} + +/// @brief The owner, runtime and server a test builds sessions over. Declare sessions after it. +struct Harness { + testing::StepExecutor owner{}; + reactive::Runtime rt{owner}; + FakeServer server{owner}; + + [[nodiscard]] std::unique_ptr session(std::string_view action, std::string_view schema, + forms::FormSessionOptions options = {}) { + return std::make_unique(rt, modelOf(action, schema), server.submitter(), server.fetcher(), + std::move(options)); + } + + /// @brief Runs every posted flush and delivery. + void settle() { owner.runAll(); } + + /// @brief A user edit: sets a field's draft, then settles. + void type(forms::FormSession& session, std::string_view path, std::string_view text) { + session.field(path).text().set(std::string{text}); + settle(); + } +}; + +} // namespace morph::test::formsengine +``` + +Create `tests/test_forms_engine_session.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "forms_engine_support.hpp" + +namespace { + +using morph::forms::FormSessionOptions; +using morph::test::formsengine::Harness; + +constexpr std::string_view kProbe = R"({"type":"object","properties":{ + "slot":{"type":["integer","null"],"x-order":0}, + "mass":{"$ref":"#/$defs/q","x-order":1,"x-decimalPlaces":3, + "x-unitAlternatives":[{"id":"g","display":"g","decimals":1,"num":1,"den":1000}]}, + "when":{"type":["string","null"],"format":"date-time","x-order":2}}, + "$defs":{"q":{"type":["object","null"],"ExtUnits":{"unitAscii":"kg","unitUnicode":"kg"}}}, + "required":["slot","mass","when"]})"; + +constexpr std::string_view kTwoInts = + R"({"properties":{"a":{"type":"integer","x-order":0},"b":{"type":"integer","x-order":1}},"required":["a","b"]})"; + +} // namespace + +TEST_CASE("FormSession: readiness gates on required fields, precision and date syntax; the body is byte-exact", + "[forms-engine][session]") { + Harness harness; + auto const form = harness.session("Probe", kProbe); + CHECK_FALSE(form->ready()); + harness.type(*form, "slot", "4"); + harness.type(*form, "when", "2026-07-05T14:30"); + CHECK_FALSE(form->ready()); + harness.type(*form, "mass", "2650.5"); + REQUIRE(form->ready()); + CHECK(form->body() == R"({"slot":4,"mass":{"num":2650500,"den":1000,"dp":3},"when":"2026-07-05T14:30:00Z"})"); + harness.type(*form, "mass", "2650.5001"); + CHECK_FALSE(form->ready()); + CHECK_FALSE(form->body().has_value()); + CHECK(form->field("mass").error().get()->code == "too-precise"); + harness.type(*form, "mass", "2650.5"); + harness.type(*form, "when", "not-a-date"); + CHECK_FALSE(form->ready()); + harness.type(*form, "when", "2026-07-05T14:30:15"); + CHECK(form->ready()); + form->field("mass").switchUnit(1); + harness.settle(); + CHECK(form->field("mass").text().peek() == "2650500.0"); + CHECK(form->body() == R"({"slot":4,"mass":{"num":26505000,"den":10000,"dp":3},"when":"2026-07-05T14:30:15Z"})"); +} + +TEST_CASE("FormSession: automatic mode submits once when the body becomes valid, and again only when it changes", + "[forms-engine][session]") { + Harness harness; + auto const form = harness.session("TestAction", kTwoInts); + harness.type(*form, "a", "3"); + CHECK(harness.server.submits().empty()); + harness.type(*form, "b", "4"); + REQUIRE(harness.server.submits().size() == 1); + CHECK(harness.server.submits()[0].action == "TestAction"); + CHECK(harness.server.lastBody() == R"({"a":3,"b":4})"); + CHECK(form->lastReply() == R"({"ok":true})"); + harness.type(*form, "b", "4"); + CHECK(harness.server.submits().size() == 1); + harness.type(*form, "b", "5"); + CHECK(harness.server.submits().size() == 2); +} + +TEST_CASE("FormSession: explicit mode submits only on submit(), and only while ready", "[forms-engine][session]") { + Harness harness; + auto const form = harness.session( + "CFR_BookRoom", + R"({"properties":{"name":{"type":"string","x-order":0}},"required":["name"],"x-submitMode":"explicit"})"); + CHECK(form->model().submitMode() == morph::forms::SubmitMode::Explicit); + form->submit(); + harness.settle(); + CHECK(harness.server.submits().empty()); + harness.type(*form, "name", "Alice"); + CHECK(form->ready()); + CHECK(harness.server.submits().empty()); + form->submit(); + harness.settle(); + REQUIRE(harness.server.submits().size() == 1); + CHECK(harness.server.lastBody() == *form->body()); +} + +TEST_CASE("FormSession: a ready form with nothing to type submits as constructed", "[forms-engine][session]") { + Harness harness; + auto const form = + harness.session("Test_UnknownRuleKind", R"({"properties":{"email":{"type":["string","null"],"x-order":0}},"required":[], + "x-rules":[{"kind":"quantumEntangled","fields":["email"]}]})"); + harness.settle(); + CHECK(form->ready()); + CHECK(harness.server.submits().size() == 1); +} + +// Review Focus 1. +TEST_CASE("FormSession: prefilling a complete payload never submits; the next user edit does", "[forms-engine][session]") { + Harness harness; + auto const form = harness.session("TestAction", kTwoInts); + form->prefill(R"({"a":1,"b":2})"); + harness.settle(); + CHECK(form->ready()); + CHECK(harness.server.submits().empty()); + form->assign("b", "7"); + harness.settle(); + CHECK(form->body() == R"({"a":1,"b":7})"); + CHECK(harness.server.submits().empty()); + harness.type(*form, "a", "9"); + REQUIRE(harness.server.submits().size() == 1); + CHECK(harness.server.lastBody() == R"({"a":9,"b":7})"); +} + +TEST_CASE("FormSession: reset clears every draft and does not submit a form that stays ready", + "[forms-engine][session]") { + Harness harness; + auto const form = harness.session("ResetResubmit", R"({"properties":{"note":{"type":["string","null"],"x-order":0}},"required":[]})"); + harness.type(*form, "note", "hello"); + auto const before = harness.server.submits().size(); + form->reset(); + harness.settle(); + CHECK(form->ready()); + CHECK(form->field("note").text().peek().empty()); + CHECK(harness.server.submits().size() == before); +} + +TEST_CASE("FormSession: a stored payload round-trips, absent members start blank, the unit resets", + "[forms-engine][session]") { + Harness harness; + constexpr std::string_view kSample = R"({"$defs":{"double":{"type":"number"},"int64_t":{"type":"integer"}},"properties":{ + "id":{"$ref":"#/$defs/int64_t","x-order":0}, + "density":{"type":["object","null"],"ExtUnits":{"unitAscii":"kg_per_m3","unitUnicode":"kg/m³"},"x-decimalPlaces":2,"x-order":1, + "x-unitAlternatives":[{"id":"g_per_cm3","display":"g/cm³","decimals":5,"num":1000,"den":1}]}, + "temperature":{"$ref":"#/$defs/double","x-order":2}, + "takenAt":{"type":"string","format":"date-time","x-order":3}, + "done":{"type":"boolean","x-order":4}, + "role":{"type":"string","oneOf":[{"title":"A","const":"A"},{"title":"B","const":"B"}],"x-order":5}, + "note":{"type":["string","null"],"x-order":6}, + "tags":{"type":"array","items":{"type":"string"},"x-order":7}}, + "required":["id","density","temperature","takenAt","done","role"]})"; + constexpr std::string_view kStored = R"({"id":9007199254740993,"density":{"num":245050,"den":100,"dp":2},)" + R"("temperature":21.5,"takenAt":"2026-07-20T09:00:00Z","done":true,"role":"B",)" + R"("note":"retest","tags":["a","b"]})"; + auto const form = harness.session("T_Sample", kSample); + form->field("density").switchUnit(1); + form->prefill(kStored); + harness.settle(); + CHECK(form->body() == kStored); + CHECK(harness.server.submits().empty()); + CHECK(form->field("id").text().peek() == "9007199254740993"); + CHECK(form->field("density").text().peek() == "2450.50"); + CHECK(form->field("density").unit().peek() == 0); + CHECK(form->field("takenAt").text().peek() == "2026-07-20T09:00:00"); + CHECK(form->field("role").text().peek() == R"("B")"); + CHECK(form->field("tags").text().peek() == "a, b"); + harness.type(*form, "temperature", "22.00"); + CHECK(form->body()->find(R"("temperature":22.00)") != std::string::npos); + + form->prefill(R"({"id":5})"); + harness.settle(); + CHECK(form->field("temperature").text().peek().empty()); + CHECK_FALSE(form->ready()); + std::string const before = form->field("id").text().peek(); + form->prefill("not json"); + form->prefill("[1,2]"); + harness.settle(); + CHECK(form->field("id").text().peek() == before); + + Harness german; + auto const germanForm = german.session("T_Sample", kSample, FormSessionOptions{.locale = {.decimalSeparator = ",", .groupSeparator = "."}}); + germanForm->prefill(kStored); + german.settle(); + CHECK(germanForm->field("density").text().peek() == "2450,50"); + CHECK(germanForm->field("temperature").text().peek() == "21,5"); + CHECK(germanForm->body() == kStored); + + Harness zoned; + auto const shifted = zoned.session("T_Sample", kSample, FormSessionOptions{.displayOffsetMinutes = 120}); + shifted->prefill(kStored); + zoned.settle(); + CHECK(shifted->field("takenAt").text().peek() == "2026-07-20T11:00:00"); + CHECK(shifted->body()->find(R"("takenAt":"2026-07-20T09:00:00Z")") != std::string::npos); +} + +TEST_CASE("FormSession: x-blankAs empty submits \"\" once the field was filled, and only then", + "[forms-engine][session]") { + constexpr std::string_view kEdit = R"({"type":"object","properties":{ + "id":{"type":"integer","x-order":0}, + "remark":{"type":["string","null"],"x-order":1,"x-blankAs":"empty"}, + "operatorName":{"anyOf":[{"type":"string"},{"type":"null"}],"x-order":2,"x-blankAs":"empty"}, + "plain":{"type":["string","null"],"x-order":3}, + "weight":{"type":["number","null"],"x-order":4,"x-blankAs":"empty"}, + "title":{"type":"string","x-order":5,"x-blankAs":"empty"}},"required":["id","title"]})"; + constexpr std::string_view kStored = R"({"id":7,"remark":"abc","operatorName":"Ann","plain":"keep","weight":1.5,"title":"T"})"; + Harness harness; + auto const form = harness.session("T_EditSample", kEdit); + CHECK(form->field("remark").spec().blankAs == morph::forms::BlankAs::Empty); + CHECK(form->field("weight").spec().blankAs == morph::forms::BlankAs::Omit); // not a string field + form->prefill(kStored); + harness.settle(); + CHECK(harness.server.submits().empty()); + harness.type(*form, "remark", ""); + CHECK(harness.server.lastBody() == R"({"id":7,"remark":"","operatorName":"Ann","plain":"keep","weight":1.5,"title":"T"})"); + form->prefill(kStored); + harness.settle(); + harness.type(*form, "plain", ""); + CHECK(harness.server.lastBody() == R"({"id":7,"remark":"abc","operatorName":"Ann","weight":1.5,"title":"T"})"); + form->prefill(kStored); + harness.settle(); + harness.type(*form, "weight", ""); + CHECK(harness.server.lastBody() == R"({"id":7,"remark":"abc","operatorName":"Ann","plain":"keep","title":"T"})"); + auto const before = harness.server.submits().size(); + harness.type(*form, "title", ""); + CHECK_FALSE(form->ready()); + CHECK(harness.server.submits().size() == before); + + form->reset(); + harness.settle(); + harness.type(*form, "id", "8"); + harness.type(*form, "title", "U"); + CHECK(harness.server.lastBody() == R"({"id":8,"title":"U"})"); + harness.type(*form, "operatorName", "Bo"); + harness.type(*form, "operatorName", ""); + CHECK(harness.server.lastBody() == R"({"id":8,"operatorName":"","title":"U"})"); + + form->prefill(R"({"id":7,"remark":"","title":"T"})"); + harness.settle(); + CHECK(form->body() == R"({"id":7,"remark":"","title":"T"})"); + form->prefill(R"({"id":7,"remark":null,"title":"T"})"); + harness.settle(); + CHECK(form->body() == R"({"id":7,"title":"T"})"); +} + +TEST_CASE("FormSession: booleans are bare literals; a required one answers false, an optional one is omitted", + "[forms-engine][session]") { + Harness harness; + auto const required = harness.session( + "T_Bool", R"({"properties":{"note":{"type":"string","x-order":0},"flag":{"type":"boolean","x-order":1}},"required":["note","flag"]})"); + harness.type(*required, "note", "n"); + CHECK(required->body() == R"({"note":"n","flag":false})"); + harness.type(*required, "flag", "true"); + CHECK(required->body() == R"({"note":"n","flag":true})"); + auto const optional = harness.session("T_NullableBool", R"({"properties":{"flag":{"type":["boolean","null"],"x-order":0}},"required":[]})"); + harness.settle(); + CHECK(optional->body() == "{}"); + harness.type(*optional, "flag", "true"); + CHECK(optional->body() == R"({"flag":true})"); +} + +TEST_CASE("FormSession: an integer through anyOf keeps every digit and refuses text", "[forms-engine][session]") { + Harness harness; + auto const form = harness.session("T_AnyOf", R"({"$defs":{"int64_t":{"type":"integer"},"TagId":{"type":"integer"}},"properties":{ + "optI64":{"anyOf":[{"$ref":"#/$defs/int64_t"},{"type":"null"}],"x-order":0}, + "refI64":{"$ref":"#/$defs/TagId","x-order":1}},"required":[]})"); + harness.type(*form, "optI64", "9007199254740993"); + CHECK(form->body() == R"({"optI64":9007199254740993})"); + harness.type(*form, "optI64", "9223372036854775807"); + harness.type(*form, "refI64", "18446744073709551615"); + CHECK(form->body() == R"({"optI64":9223372036854775807,"refI64":18446744073709551615})"); + harness.type(*form, "optI64", "banana"); + CHECK_FALSE(form->ready()); +} + +// Review Focus 3. +TEST_CASE("FormSession: an id beyond 2^53 is exact through encode, body, a rule and prefill", "[forms-engine][session]") { + Harness harness; + auto const form = harness.session("RcEqualsBigInt", R"({"$defs":{"int64_t":{"type":"integer"}},"properties":{ + "id":{"anyOf":[{"$ref":"#/$defs/int64_t"},{"type":"null"}],"x-order":0}, + "note":{"type":["string","null"],"x-order":1}},"required":[], + "x-rules":[{"kind":"requiredWhen","fields":["note"],"when":{"kind":"equals","fields":["id"],"value":9007199254740993,"valueText":"9007199254740993"}}]})"); + harness.type(*form, "id", "9007199254740992"); + CHECK(form->ready()); + harness.type(*form, "id", "9007199254740993"); + CHECK_FALSE(form->ready()); + CHECK(form->field("note").required().get()); + form->prefill(R"({"id":9007199254740993,"note":"why"})"); + harness.settle(); + CHECK(form->body() == R"({"id":9007199254740993,"note":"why"})"); +} + +TEST_CASE("FormSession: x-rules gate and present live", "[forms-engine][session]") { + Harness harness; + auto const form = harness.session("CFR_BookRoom", R"({"properties":{ + "name":{"type":"string","x-order":0},"checkInN":{"type":"integer","x-order":1},"checkOutN":{"type":"integer","x-order":2}, + "email":{"type":"string","x-order":3},"phone":{"type":"string","x-order":4}, + "promo":{"type":"integer","x-order":5},"discount":{"type":"integer","x-order":6}},"required":["name"], + "x-rules":[{"kind":"greater","fields":["checkOutN","checkInN"]},{"kind":"exactlyOneOf","fields":["email","phone"]}, + {"kind":"requiredWhen","fields":["discount"],"when":{"kind":"engaged","fields":["promo"]}}, + {"kind":"visibleWhen","fields":["discount"],"when":{"kind":"engaged","fields":["promo"]}}]})"); + CHECK_FALSE(form->field("discount").visible().get()); + harness.type(*form, "name", "Alice"); + CHECK_FALSE(form->ready()); + harness.type(*form, "email", "a@b.com"); + CHECK(form->ready()); + CHECK(harness.server.submits().size() == 1); + harness.type(*form, "promo", "5"); + CHECK(form->field("discount").visible().get()); + CHECK(form->field("discount").required().get()); + CHECK_FALSE(form->ready()); + CHECK(harness.server.submits().size() == 1); + harness.type(*form, "discount", "2"); + CHECK(form->ready()); + CHECK(harness.server.submits().size() == 2); + harness.type(*form, "checkInN", "10"); + harness.type(*form, "checkOutN", "3"); + CHECK_FALSE(form->ready()); + harness.type(*form, "checkOutN", "20"); + CHECK(form->ready()); + harness.type(*form, "phone", "555"); + CHECK_FALSE(form->ready()); +} + +TEST_CASE("FormSession: compound when-clauses require, release and negate", "[forms-engine][session]") { + auto const schema = [](std::string_view when) { + return std::string{R"({"properties":{"name":{"type":"string","x-order":0},"promo":{"type":"integer","x-order":1}, + "loyaltyCode":{"type":"integer","x-order":2},"discount":{"type":"integer","x-order":3}},"required":["name"], + "x-rules":[{"kind":"requiredWhen","fields":["discount"],"when":)"} + + std::string{when} + "}]}"; + }; + Harness harness; + auto const both = harness.session("A", schema(R"({"kind":"and","conditions":[{"kind":"engaged","fields":["promo"]},{"kind":"engaged","fields":["loyaltyCode"]}]})")); + harness.type(*both, "name", "Alice"); + harness.type(*both, "promo", "5"); + CHECK(both->ready()); + harness.type(*both, "loyaltyCode", "9"); + CHECK_FALSE(both->ready()); + harness.type(*both, "discount", "2"); + CHECK(both->ready()); + auto const either = harness.session("O", schema(R"({"kind":"or","conditions":[{"kind":"engaged","fields":["promo"]},{"kind":"engaged","fields":["loyaltyCode"]}]})")); + harness.type(*either, "name", "Alice"); + CHECK(either->ready()); + harness.type(*either, "promo", "5"); + CHECK_FALSE(either->ready()); + auto const negated = harness.session("N", schema(R"({"kind":"not","condition":{"kind":"engaged","fields":["promo"]}})")); + harness.type(*negated, "name", "Alice"); + CHECK_FALSE(negated->ready()); + harness.type(*negated, "promo", "5"); + CHECK(negated->ready()); +} + +TEST_CASE("FormSession: an enum starts unselected, encodes the chosen literal and refuses anything else", + "[forms-engine][session]") { + Harness harness; + harness.server.replyWith([](auto const&) { return std::nullopt; }); // record only + auto const form = harness.session("SetMemberRole", R"({"properties":{ + "principal":{"type":"string","x-order":1},"projectId":{"type":["integer","null"],"x-order":0}, + "role":{"type":"string","oneOf":[{"title":"Viewer","const":"Viewer"},{"title":"Member","const":"Member"},{"title":"Manager","const":"Manager"}],"x-order":2}}, + "required":["projectId","principal","role"]})"); + harness.type(*form, "projectId", "1"); + harness.type(*form, "principal", "bob"); + CHECK_FALSE(form->ready()); + CHECK(form->field("role").options().size() == 3); + harness.type(*form, "role", R"("Emperor")"); + CHECK_FALSE(form->ready()); + CHECK(harness.server.submits().empty()); + harness.type(*form, "role", "Manager"); + CHECK_FALSE(form->ready()); + harness.type(*form, "role", R"("Manager")"); + CHECK(form->body() == R"({"projectId":1,"principal":"bob","role":"Manager"})"); + form->reset(); + harness.settle(); + CHECK(form->field("role").text().peek().empty()); + CHECK(form->field("principal").text().peek().empty()); +} + +TEST_CASE("FormSession: an array field encodes a JSON array; blank optional is omitted, a required one gates", + "[forms-engine][session]") { + Harness harness; + constexpr std::string_view kProps = R"("name":{"type":"string","x-order":0},"tags":{"type":"array","items":{"type":"string"},"x-order":1})"; + auto const optional = harness.session("CFR_TagRoom", std::string{R"({"properties":{)"} + std::string{kProps} + R"(},"required":["name"]})"); + harness.type(*optional, "name", "Alice"); + CHECK(optional->body() == R"({"name":"Alice"})"); + harness.type(*optional, "tags", "solo"); + CHECK(optional->body() == R"({"name":"Alice","tags":["solo"]})"); + auto const required = + harness.session("CFR_TagRoom", std::string{R"({"properties":{)"} + std::string{kProps} + R"(},"required":["name","tags"]})"); + harness.type(*required, "name", "Alice"); + CHECK_FALSE(required->ready()); + harness.type(*required, "tags", " , , "); + CHECK(required->body() == R"({"name":"Alice","tags":[]})"); +} + +TEST_CASE("FormSession: labels resolve through the translations; numbers read in the session locale", + "[forms-engine][session]") { + morph::render::TranslationProvider const catalog = [](std::string_view key, std::string_view locale) -> std::optional { + if (locale != "de") { + return std::nullopt; + } + if (key == "Probe.slot.label") { + return "Steckplatz"; + } + if (key == "custom.stem.label") { + return "Übersteuert"; + } + return std::nullopt; + }; + constexpr std::string_view kSchema = R"({"properties":{"slot":{"type":["integer","null"],"x-order":0}, + "mass":{"$ref":"#/$defs/q","x-order":1,"x-decimalPlaces":3,"ExtUnits":{"unitAscii":"kg","unitUnicode":"kg"}}, + "other":{"type":"integer","x-order":2,"x-i18nKey":"custom.stem"}},"$defs":{"q":{"type":["object","null"]}},"required":["mass"]})"; + morph::render::NumericLocale const german{.decimalSeparator = ",", .groupSeparator = "."}; + Harness harness; + auto const form = harness.session("Probe", kSchema, FormSessionOptions{.locale = german, .bcp47 = "de", .translations = catalog}); + CHECK(form->field("slot").label() == "Steckplatz"); + CHECK(form->field("mass").label() == "mass"); + CHECK(form->field("other").label() == "Übersteuert"); + auto const plain = harness.session("Probe", kSchema); + CHECK(plain->field("slot").label() == "slot"); + + harness.type(*form, "mass", "1.050,25"); + CHECK(form->body() == R"({"mass":{"num":1050250,"den":1000,"dp":3}})"); + for (std::string_view const foreign : {"1.5", "1.50", "1234.050,25"}) { + harness.type(*form, "mass", foreign); + CHECK_FALSE(form->ready()); + } + harness.type(*form, "mass", "+1.050,25"); + CHECK(form->body() == R"({"mass":{"num":1050250,"den":1000,"dp":3}})"); + constexpr std::string_view kMinus = "\xE2\x88\x92"; // U+2212 + morph::render::NumericLocale const basque{.decimalSeparator = ",", .groupSeparator = ".", .negativeSign = kMinus}; + auto const signForm = harness.session("Probe", kSchema, FormSessionOptions{.locale = basque}); + harness.type(*signForm, "mass", std::string{kMinus} + "5"); + CHECK(signForm->body() == R"({"mass":{"num":-5000,"den":1000,"dp":3}})"); + harness.type(*signForm, "mass", "-5"); + CHECK(signForm->body() == R"({"mass":{"num":-5000,"den":1000,"dp":3}})"); +} + +TEST_CASE("FormSession: a failed submission is the last error; a later success clears it", "[forms-engine][session]") { + Harness harness; + harness.server.replyWith([](auto const&) { return std::nullopt; }); + auto const form = harness.session("TestAction", kTwoInts); + harness.type(*form, "a", "1"); + harness.type(*form, "b", "2"); + CHECK(form->pending()); + harness.server.rejectSubmit(0, "denied"); + harness.settle(); + CHECK_FALSE(form->pending()); + CHECK(morph::reactive::errorMessage(form->lastError()) == "denied"); + harness.type(*form, "b", "3"); + harness.server.resolveSubmit(1, R"({"sum":4})"); + harness.settle(); + CHECK(form->lastError() == nullptr); + CHECK(form->lastReply() == R"({"sum":4})"); +} + +TEST_CASE("FormSession: field() resolves paths and refuses unknown ones", "[forms-engine][session]") { + Harness harness; + auto const form = harness.session("TestAction", kTwoInts); + CHECK(form->field("a").path() == "a"); + CHECK_THROWS_AS(form->field("nope"), std::out_of_range); + CHECK_THROWS_AS(form->field("a.b"), std::out_of_range); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: `fatal error: 'morph/forms/engine/form_session.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `include/morph/forms/engine/form_session.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/form_session.hpp +/// @brief `FormSession`: the reactive state of one form over a `FormModel` — drafts, encoded +/// values, readiness, presentation rules, Choice options and submission. A controller in +/// the sense of `docs/spec/reactive/control.md`: no toolkit, testable headless. +/// +/// Specified in `docs/spec/forms/engine.md`, "FormSession". + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../attributes.hpp" +#include "../../core/completion.hpp" +#include "../../reactive/control.hpp" +#include "../../reactive/runtime.hpp" +#include "../../reactive/signal.hpp" +#include "../../render/i18n.hpp" +#include "../../render/locale_format.hpp" +#include "../i18n.hpp" +#include "detail/text.hpp" +#include "field_model.hpp" +#include "rules.hpp" +#include "values.hpp" + +namespace morph::forms { + +/// @brief Executes a form's action: the action type and its JSON body in, the JSON reply out. +using Submitter = std::function(std::string_view actionType, std::string bodyJson)>; + +/// @brief Executes a Choice's options action: the action and its JSON request in, the reply out. +using ChoiceFetcher = std::function(std::string_view optionsAction, std::string bodyJson)>; + +/// @brief How a session reads numbers, dates and text. +struct FormSessionOptions { + /// @brief How numbers are typed and shown. The views must outlive the session. + render::NumericLocale locale{}; + /// @brief The locale translations are resolved in. + std::string bcp47 = "en"; + /// @brief The host's catalog; empty means every label is the schema's literal. + render::TranslationProvider translations{}; + /// @brief The display zone for date-times, in minutes east of UTC. + int displayOffsetMinutes = 0; + /// @brief An extra readiness gate over the assembled body; empty means none. `Form` puts + /// the action's own `validate()` here. + std::function accepts{}; +}; + +class FieldState; + +namespace detail::engine { + +/// @brief What every field of one session shares: the runtime, the model, the options, the +/// Choice fetcher, and the hook a programmatic change ends with. +class SessionCore { +public: + /// @param runtime The runtime. Borrowed. + /// @param model The form model. + /// @param choices The Choice fetcher (may be empty when no field is a Choice). + /// @param options The session options. + SessionCore(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, FormModel model, ChoiceFetcher choices, + FormSessionOptions options) + : _rt{&runtime}, + _model{std::move(model)}, + _choices{std::move(choices)}, + _options{std::move(options)}, + _values{_options.locale, _options.displayOffsetMinutes, _options.translations, _options.bcp47} {} + + /// @brief The runtime. + /// @return The runtime passed at construction. + [[nodiscard]] reactive::Runtime& runtime() const noexcept { return *_rt; } + + /// @brief The form model. + /// @return The model. + [[nodiscard]] FormModel const& model() const noexcept { return _model; } + + /// @brief The session options. + /// @return The options. + [[nodiscard]] FormSessionOptions const& options() const noexcept { return _options; } + + /// @brief The options a value conversion reads. + /// @return The value context. + [[nodiscard]] ValueContext const& values() const noexcept { return _values; } + + /// @brief The Choice fetcher. + /// @return The fetcher; may be empty. + [[nodiscard]] ChoiceFetcher const& choices() const noexcept { return _choices; } + + /// @brief Installs what `markProgrammatic` runs (the session records its current body as seen). + /// @param hook The hook. + void onProgrammatic(std::function hook) { _programmatic = std::move(hook); } + + /// @brief Ends a programmatic change: automatic submission does not submit the body it produced. + void markProgrammatic() const { + if (_programmatic) { + _programmatic(); + } + } + + /// @brief Installs the top-level fields rules look up. + /// @param fields The session's fields. Borrowed: they must outlive this core's use. + void adopt(std::vector> const* fields) noexcept { _fields = fields; } + + /// @brief A top-level field by wire name. + /// @param name The wire name. + /// @return The field, or null. + [[nodiscard]] FieldState* topLevel(std::string_view name) const; + + /// @brief The tracked lookup rules evaluate over. + /// @return A lookup reading each named field's encoding. + [[nodiscard]] RuleLookup lookup() const; + +private: + reactive::Runtime* _rt; + FormModel _model; + ChoiceFetcher _choices; + FormSessionOptions _options; + ValueContext _values; + std::function _programmatic; + std::vector> const* _fields = nullptr; +}; + +} // namespace detail::engine +/// @brief The reactive state of one field: its draft and everything derived from it. +class FieldState { +public: + /// @param core The session core. Borrowed: it must outlive the field. + /// @param spec The field's spec. Borrowed from the core's model (or a parent's children). + /// @param path The instance path (`name`, `parent.member`, `rows#key.member`). + /// @param parent The containing Object or ObjectArray field, or null at the top level. + FieldState(detail::engine::SessionCore& core MORPH_LIFETIMEBOUND, FieldSpec const& spec MORPH_LIFETIMEBOUND, + std::string path, FieldState const* parent) + : _core{&core}, + _spec{&spec}, + _path{std::move(path)}, + _parent{parent}, + _text{core.runtime(), std::string{}}, + _unit{core.runtime(), std::size_t{0}}, + _engaged{core.runtime(), false}, + _encoded{core.runtime(), [this] { return computeEncoding(); }}, + _error{core.runtime(), + [this]() -> std::optional { + Encoding const& encoding = _encoded.get(); + if (encoding.has_value() && !encoding->has_value()) { + return encoding->error(); + } + return std::nullopt; + }}, + _visible{core.runtime(), + [this] { + if (_spec->hidden) { + return false; + } + return _parent != nullptr || + visibleVerdict(_core->model().rules(), _spec->name, _core->lookup()) != Tri::False; + }}, + _readonly{core.runtime(), + [this] { + if (_spec->readOnly || (_parent != nullptr && _parent->readonly().get())) { + return true; + } + return _parent == nullptr && + readonlyVerdict(_core->model().rules(), _spec->name, _core->lookup()) == Tri::True; + }}, + _required{core.runtime(), [this] { + return _spec->required || + (_parent == nullptr && + requiredVerdict(_core->model().rules(), _spec->name, _core->lookup()) == Tri::True); + }} { + _label = resolveSlot(i18n::FieldSlot::Label, _spec->title); + _help = resolveSlot(i18n::FieldSlot::Help, _spec->help); + _placeholder = _spec->placeholder.empty() ? defaultPlaceholder(*_spec) + : resolveSlot(i18n::FieldSlot::Placeholder, _spec->placeholder); + } + + ~FieldState() = default; + FieldState(FieldState const&) = delete; + FieldState& operator=(FieldState const&) = delete; + FieldState(FieldState&&) = delete; + FieldState& operator=(FieldState&&) = delete; + + /// @brief The field's spec. + /// @return The spec. + [[nodiscard]] FieldSpec const& spec() const noexcept { return *_spec; } + + /// @brief The instance path. + /// @return `name`, `parent.member`, or `rows#key.member` inside a collection row. + [[nodiscard]] std::string const& path() const noexcept { return _path; } + + /// @brief The label: the explicit key's translation, else the derived key's (top level + /// only), else the schema's title. + /// @return The label. + [[nodiscard]] std::string const& label() const noexcept { return _label; } + + /// @brief The help text, resolved like the label. + /// @return The help text; empty when the schema states none. + [[nodiscard]] std::string const& help() const noexcept { return _help; } + + /// @brief The placeholder: `x-placeholder` resolved like the label, else `defaultPlaceholder`. + /// @return The placeholder. + [[nodiscard]] std::string const& placeholder() const noexcept { return _placeholder; } + + /// @brief The draft: what the control holds. Setting it is a user edit. + /// @return The draft signal. + [[nodiscard]] reactive::Signal& text() noexcept { return _text; } + + /// @brief A Decimal's or Quantity's entry unit, an index into `spec().units`. + /// @return The unit signal. + [[nodiscard]] reactive::Signal& unit() noexcept { return _unit; } + + /// @brief Whether a blank-capable field was filled once (an `x-blankAs: empty` text, or a + /// collection that had rows). + /// @return The engagement signal. + [[nodiscard]] reactive::Signal& engaged() noexcept { return _engaged; } + + /// @brief The draft's encoding. Tracked. + /// @return The encoding computed. + [[nodiscard]] reactive::Computed const& encoded() const noexcept { return _encoded; } + + /// @brief Why the draft does not encode, if it does not. Tracked. + /// @return The error computed. + [[nodiscard]] reactive::Computed> const& error() const noexcept { return _error; } + + /// @brief Whether the field is shown: not `x-hidden`, and no `visibleWhen` is False. + /// @return The visibility computed. + [[nodiscard]] reactive::Computed const& visible() const noexcept { return _visible; } + + /// @brief Whether the field is frozen: `x-readonly`, a frozen parent, or a `readonlyWhen` that is True. + /// @return The read-only computed. + [[nodiscard]] reactive::Computed const& readonly() const noexcept { return _readonly; } + + /// @brief Whether the field must be filled: `required`, or a `requiredWhen` that is True. + /// @return The required computed. + [[nodiscard]] reactive::Computed const& required() const noexcept { return _required; } + + /// @brief Whether nothing is entered. Tracked. + /// @return `true` when blank. + [[nodiscard]] bool blank() const { return detail::engine::trim(_text.get()).empty(); } + + /// @brief What a rule sees of this field. Tracked. + /// @return Blank, Invalid, or Valid with the exact scalar. + [[nodiscard]] RuleValue ruleValue() const { + Encoding const& encoding = _encoded.get(); + if (!encoding.has_value()) { + return RuleValue{}; + } + if (!encoding->has_value()) { + return RuleValue{RuleValue::State::Invalid, {}}; + } + return RuleValue{RuleValue::State::Valid, (*encoding)->scalar}; + } + + /// @brief The selectable options: an Enum's, from the schema. Tracked. + /// @return The options; empty for every other kind. + [[nodiscard]] std::vector const& options() const { return _spec->enumOptions; } + + /// @brief Whether a Choice's options request is in flight. Tracked. + /// @return `true` while fetching. + [[nodiscard]] bool optionsPending() const { return false; } + + /// @brief Switches a Quantity's entry unit, converting the draft exactly (half away from + /// zero to the new unit's decimals). A user action. + /// @param index The new unit, an index into `spec().units`. + void switchUnit(std::size_t index) { + std::size_t const current = _unit.peek(); + if (index >= _spec->units.size() || index == current || current >= _spec->units.size()) { + return; + } + auto converted = convertDraft(_text.peek(), _spec->units.at(current), _spec->units.at(index), + _core->values().locale); + _core->runtime().batch([&] { + _unit.set(index); + if (!detail::engine::trim(_text.peek()).empty()) { + _text.set(converted.value_or(std::string{})); + } + }); + } + + /// @brief Replaces the draft with the decoded wire value; null or absent is blank. Does not + /// mark the change programmatic — the caller does, in the same batch. + /// @param value The wire value, or null. + void load(detail::engine::Json const* value) { + bool const present = value != nullptr && !value->is_null(); + _text.set(present ? decodeScalar(*_spec, *value, _core->values()) : std::string{}); + _unit.set(0); + _engaged.set(_spec->blankAs == BlankAs::Empty && present && value->is_string()); + } + + /// @brief Blanks the draft, the unit and the engagement. + void clear() { + _text.set(std::string{}); + _unit.set(0); + _engaged.set(false); + } + + /// @brief Starts what needs every sibling to exist: an `x-blankAs` field's engagement. + /// Called once, after the session built all its fields. + void start() { + if (_spec->blankAs == BlankAs::Empty) { + _engage = std::make_unique(_core->runtime(), [this] { + if (!detail::engine::trim(_text.get()).empty() && !_engaged.peek()) { + _engaged.set(true); + } + }); + } + } + +private: + [[nodiscard]] std::string resolveSlot(i18n::FieldSlot slot, std::string const& literal) const { + auto const& translations = _core->options().translations; + std::string_view const locale = _core->options().bcp47; + auto const explicitKey = i18n::explicitFieldKey(_spec->i18nKey, slot); + if (_parent == nullptr) { + return render::resolveText(translations, locale, explicitKey, + i18n::fieldKey(_core->model().actionType(), _spec->name, slot), literal); + } + if (translations && explicitKey) { + if (auto hit = translations(*explicitKey, locale)) { + return *std::move(hit); + } + } + return literal; + } + + [[nodiscard]] Encoding computeEncoding() const { + return encodeScalar(*_spec, _text.get(), _unit.get(), _core->values()); + } + + // Declaration order is destruction order reversed: the signals precede the computeds that + // read them, and the effect is last, so nothing is torn down while something reads it. + detail::engine::SessionCore* _core; + FieldSpec const* _spec; + std::string _path; + FieldState const* _parent; + std::string _label; + std::string _help; + std::string _placeholder; + reactive::Signal _text; + reactive::Signal _unit; + reactive::Signal _engaged; + reactive::Computed _encoded; + reactive::Computed> _error; + reactive::Computed _visible; + reactive::Computed _readonly; + reactive::Computed _required; + std::unique_ptr _engage; +}; + +namespace detail::engine { + +inline FieldState* SessionCore::topLevel(std::string_view name) const { + if (_fields == nullptr) { + return nullptr; + } + auto const found = std::ranges::find_if(*_fields, [&](auto const& entry) { return entry->spec().name == name; }); + return found == _fields->end() ? nullptr : found->get(); +} + +inline RuleLookup SessionCore::lookup() const { + return [this](std::string_view name) { + FieldState const* const field = topLevel(name); + return field == nullptr ? RuleValue{} : field->ruleValue(); + }; +} + +} // namespace detail::engine + +/// @brief The reactive state of one form: a controller over a `FormModel`. +/// +/// Non-copyable and non-movable: fields, bindings and in-flight replies point into it. Destroy a +/// mounted view of it before the session. +class FormSession { +public: + /// @param runtime The runtime. Borrowed: it must outlive the session. + /// @param model The form model. + /// @param submit Executes the form's action. Required. + /// @param choices Fetches Choice options; may be empty when no field is a Choice. + /// @param options Locale, translations, display zone, extra readiness gate. + /// @throws std::invalid_argument when @p submit is empty. + FormSession(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, FormModel model, Submitter submit, ChoiceFetcher choices, + FormSessionOptions options = {}) + : _core{std::make_unique(runtime, std::move(model), std::move(choices), std::move(options))}, + _submit{std::move(submit)}, + _assembled{runtime, [this] { return assemble(); }}, + _ready{runtime, + [this] { + std::optional const& assembled = _assembled.get(); + if (!assembled.has_value()) { + return false; + } + auto const& accepts = _core->options().accepts; + return !accepts || accepts(*assembled); + }}, + _body{runtime, [this] { return _ready.get() ? _assembled.get() : std::optional{}; }}, + _mutation{runtime, [this](std::string body) { return _submit(_core->model().actionType(), std::move(body)); }} { + if (!_submit) { + throw std::invalid_argument{"FormSession: a Submitter is required"}; + } + for (FieldSpec const& spec : _core->model().fields()) { + _fields.push_back(std::make_unique(*_core, spec, spec.name, nullptr)); + } + _core->adopt(&_fields); + _core->onProgrammatic([this] { _lastSubmitted = _body.peek(); }); + for (std::size_t i = 0; i < _core->model().groups().size(); ++i) { + _collapsed.push_back(std::make_unique>(runtime, false)); + _tabs.push_back(std::make_unique>(runtime, std::size_t{0})); + } + for (auto const& field : _fields) { + field->start(); + } + _autoSubmit = std::make_unique(runtime, [this] { autoSubmit(); }); + } + + ~FormSession() = default; + FormSession(FormSession const&) = delete; + FormSession& operator=(FormSession const&) = delete; + FormSession(FormSession&&) = delete; + FormSession& operator=(FormSession&&) = delete; + + /// @brief The form model. + /// @return The model. + [[nodiscard]] FormModel const& model() const noexcept { return _core->model(); } + + /// @brief The runtime. + /// @return The runtime passed at construction. + [[nodiscard]] reactive::Runtime& runtime() const noexcept { return _core->runtime(); } + + /// @brief When the form submits. + /// @return The model's submit mode. + [[nodiscard]] SubmitMode submitMode() const noexcept { return _core->model().submitMode(); } + + /// @brief The session options. + /// @return The options. + [[nodiscard]] FormSessionOptions const& options() const noexcept { return _core->options(); } + + /// @brief The top-level fields, in `x-order`. + /// @return The fields. + [[nodiscard]] std::span const> fields() const noexcept { return _fields; } + + /// @brief A top-level field by wire name. + /// @param path The field's wire name. + /// @return The field. + /// @throws std::out_of_range when no field has that path. + [[nodiscard]] FieldState& field(std::string_view path) const { + auto const found = std::ranges::find_if(_fields, [&](auto const& entry) { return entry->spec().name == path; }); + if (found == _fields.end()) { + throw std::out_of_range{"FormSession::field: no field '" + std::string{path} + "'"}; + } + return **found; + } + + /// @brief Whether the form can submit. Tracked. + /// @return `true` when every required field encodes, no field has an error, no gating rule + /// is False, and `options().accepts` (when set) accepts the body. + [[nodiscard]] bool ready() const { return _ready.get(); } + + /// @brief The body the form would submit. Tracked. + /// @return The JSON body, fields in `x-order`, while ready; `std::nullopt` otherwise. + [[nodiscard]] std::optional body() const { return _body.get(); } + + /// @brief Submits the current body; does nothing while not ready. The explicit-mode trigger; + /// in automatic mode it forces a resubmission of an unchanged body. + void submit() { + std::optional const body = _body.peek(); + if (!body.has_value()) { + return; + } + _lastSubmitted = body; + _mutation.run(*body); + } + + /// @brief Loads a stored payload for editing. Every field is replaced (an absent member starts + /// blank), every unit returns to the canonical one, and nothing is submitted. Text that + /// is not a JSON object changes nothing. + /// @param bodyJson The payload. + void prefill(std::string_view bodyJson) { + auto const dom = detail::engine::parseJson(bodyJson); + if (!dom || !dom->is_object()) { + return; + } + runtime().batch([&] { + for (auto const& field : _fields) { + field->load(detail::engine::member(*dom, field->spec().name)); + } + _core->markProgrammatic(); + }); + } + + /// @brief Sets one field from a JSON value, programmatically (nothing is submitted). + /// @param path The field's path (see `field`). + /// @param jsonValue The value as JSON; text that is not JSON changes nothing. + /// @throws std::out_of_range when no field has that path. + void assign(std::string_view path, std::string_view jsonValue) { + FieldState& target = field(path); + auto const dom = detail::engine::parseJson(jsonValue); + if (!dom) { + return; + } + runtime().batch([&] { + target.load(&*dom); + _core->markProgrammatic(); + }); + } + + /// @brief Blanks every field, programmatically (nothing is submitted). + void reset() { + runtime().batch([&] { + for (auto const& field : _fields) { + field->clear(); + } + _core->markProgrammatic(); + }); + } + + /// @brief Whether a submission is in flight. Tracked. + /// @return `true` while at least one is. + [[nodiscard]] bool pending() const { return _mutation.pending(); } + + /// @brief The last successful reply. Tracked. + /// @return The reply JSON, or `std::nullopt` before the first success. + [[nodiscard]] std::optional const& lastReply() const { return _mutation.lastResult(); } + + /// @brief The last failure. Tracked. + /// @return The exception, or null; cleared by the next success. + [[nodiscard]] std::exception_ptr lastError() const { return _mutation.error(); } + + /// @brief An accordion group's collapsed state. + /// @param group Index into `model().groups()`. + /// @return The signal. + /// @throws std::out_of_range for an index past the groups. + [[nodiscard]] reactive::Signal& groupCollapsed(std::size_t group) const { return *_collapsed.at(group); } + + /// @brief A tab run's selected tab, keyed by the run's first group index. + /// @param group Index into `model().groups()` of the run's first tab group. + /// @return The signal holding the selected tab's position in the run. + /// @throws std::out_of_range for an index past the groups. + [[nodiscard]] reactive::Signal& tabSelection(std::size_t group) const { return *_tabs.at(group); } + + /// @brief A group's title, resolved through the translations (key `.group.`). + /// @param group Index into `model().groups()`. + /// @return The title. + [[nodiscard]] std::string groupTitle(std::size_t group) const { + FieldGroupSpec const& spec = detail::engine::elementAt(_core->model().groups(), group); + if (spec.implicit) { + return {}; + } + return render::resolveText(_core->options().translations, _core->options().bcp47, std::nullopt, + i18n::groupKey(_core->model().actionType(), group), spec.title); + } + +private: + [[nodiscard]] std::optional assemble() const { + std::string json = "{"; + bool first = true; + for (auto const& field : _fields) { + Encoding const& encoding = field->encoded().get(); + bool const required = field->required().get(); + std::string literal; + if (!encoding.has_value()) { + if (field->spec().blankAs == BlankAs::Empty && field->engaged().get() && !required) { + literal = R"("")"; + } else if (required) { + return std::nullopt; + } else { + continue; + } + } else if (!encoding->has_value()) { + return std::nullopt; + } else { + literal = (*encoding)->json; + } + json += (first ? "" : ",") + detail::engine::quote(field->spec().name) + ":" + literal; + first = false; + } + if (gatingVerdict(_core->model().rules(), _core->lookup()) == Tri::False) { + return std::nullopt; + } + return json + "}"; + } + + void autoSubmit() { + std::optional const& body = _body.get(); + if (_core->model().submitMode() != SubmitMode::Automatic || !body.has_value() || body == _lastSubmitted) { + return; + } + _lastSubmitted = body; + std::string const toSend = *body; + runtime().untracked([&] { _mutation.run(toSend); }); + } + + std::unique_ptr _core; + Submitter _submit; + std::vector> _fields; + std::vector>> _collapsed; + std::vector>> _tabs; + reactive::Computed> _assembled; + reactive::Computed _ready; + reactive::Computed> _body; + reactive::Mutation _mutation; + std::optional _lastSubmitted; + std::unique_ptr _autoSubmit; +}; + +} // namespace morph::forms +``` + + +The member order is load-bearing and commented in place where it is: in `FieldState`, the signals and `_members` +come before the computeds that read them and the effects come last, so an effect is destroyed before anything it +reads; in `FormSession`, `_fields` precede the computeds over them, and `_autoSubmit` is last. + +Register the header and the test as listed under **Files**. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][session]"` +Expected: PASS, 18 test cases. + +Mutation check: in the `FormSession` constructor, make the `onProgrammatic` hook's body empty (`[this] {}` — keep +the capture so nothing else changes). Expected: FAIL in "prefilling a complete payload never submits; the next user +edit does" (a submission appears after `prefill`). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/form_session.hpp tests/forms_engine_support.hpp \ + tests/test_forms_engine_session.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): FormSession — drafts, readiness, body, submission, prefill and reset + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 7: Nested objects and collections of objects + +"Nothing is unrepresentable": an `Object` field owns a `FieldState` per member, an `ObjectArray` owns `RowState`s +of member fields, and both encode by asking their members — the same encoders a top-level field uses, in the +display locale. A blank optional member is left out; a blank required one makes the container `incomplete`. A +collection encodes `[]` once engaged (a row was added or a payload loaded it) and, when required, also while it has +no rows: an empty list is a value the action accepts. A `truncated` member (Task 4) cannot be edited: blank it is +omitted, holding a loaded value it is `unrepresentable`, and a required truncated collection is `[]`. + +**Files:** +- Modify: `include/morph/forms/engine/form_session.hpp` — `RowState`, the `FieldState` members and rows, and the + path syntax of `FormSession::field` (each change below names the code it replaces) +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_nested.cpp` after `test_forms_engine_session.cpp` +- Test: `tests/test_forms_engine_nested.cpp` + +**Interfaces:** +- Consumes: Task 6's `FieldState`, `FormSession`, `detail::engine::SessionCore`; Task 5's `fieldError`. +- Produces: `RowState` (`key()`, `fields()`, `field(std::string_view)`, `blank()`, `start()`); row keys start at + 1, so `addRow()`'s 0 means "not an editable collection"; + `FieldState::members() -> std::span const>`, `member(std::string_view) -> + FieldState*`, `rows() -> std::vector> const&` (tracked), `addRow() -> std::uint64_t`, + `removeRow(std::uint64_t)`; `FormSession::field` accepting `parent.member` and `rows[i].member`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_nested.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "forms_engine_support.hpp" + +namespace { + +using morph::forms::FormSessionOptions; +using morph::test::formsengine::Harness; + +constexpr std::string_view kIgnition = R"({"type":"object","$defs":{"double":{"type":"number"},"int32_t":{"type":"integer","minimum":-2147483648,"maximum":2147483647}}, + "properties":{ + "method":{"type":"string","oneOf":[{"title":"Furnace","const":"Furnace"},{"title":"Infrared","const":"Infrared"}],"x-order":0}, + "specimen":{"type":"object","x-order":1,"required":["massOfContainer","massOfContainerAndSampleBeforeIgnition","testTemperature"],"properties":{ + "massOfContainer":{"$ref":"#/$defs/double","x-order":0,"ExtUnits":{"unitAscii":"g","unitUnicode":"g"},"x-displayDecimals":1}, + "massOfContainerAndSampleBeforeIgnition":{"$ref":"#/$defs/double","x-order":1,"ExtUnits":{"unitAscii":"g","unitUnicode":"g"},"x-displayDecimals":1}, + "readoutBinderContent":{"anyOf":[{"$ref":"#/$defs/double"},{"type":"null"}],"x-order":2,"ExtUnits":{"unitAscii":"pct","unitUnicode":"%"},"x-displayDecimals":2}, + "testTemperature":{"$ref":"#/$defs/int32_t","x-order":3}}}, + "calibrationFactor":{"$ref":"#/$defs/double","x-order":2}, + "driedSample":{"type":"boolean","x-order":3}, + "testedByNr":{"anyOf":[{"$ref":"#/$defs/int32_t"},{"type":"null"}],"x-order":4}}, + "required":["method","specimen","calibrationFactor","driedSample"]})"; + +constexpr std::string_view kDensity = R"({"type":"object","$defs":{"double":{"type":"number"}, + "lab::PycnometerDetermination":{"type":"object","required":["massPycnometerAndSample","excluded"],"properties":{ + "massPycnometerEmpty":{"anyOf":[{"$ref":"#/$defs/double"},{"type":"null"}],"x-order":0,"ExtUnits":{"unitAscii":"g","unitUnicode":"g"},"x-displayDecimals":2}, + "massPycnometerAndSample":{"$ref":"#/$defs/double","x-order":1,"ExtUnits":{"unitAscii":"g","unitUnicode":"g"},"x-displayDecimals":2}, + "excluded":{"type":"boolean","x-order":2}, + "computedDensity":{"anyOf":[{"$ref":"#/$defs/double"},{"type":"null"}],"x-order":3,"x-readonly":true}}}}, + "properties":{"data":{"type":"object","required":["useSpecificGravity"],"x-order":0,"properties":{ + "useSpecificGravity":{"type":"boolean","x-order":0}, + "testLiquidTemperature":{"anyOf":[{"$ref":"#/$defs/double"},{"type":"null"}],"x-order":1,"x-displayDecimals":1}, + "testLiquidName":{"type":["string","null"],"x-order":2}, + "determination1":{"anyOf":[{"$ref":"#/$defs/lab::PycnometerDetermination"},{"type":"null"}],"x-order":3}, + "determination2":{"anyOf":[{"$ref":"#/$defs/lab::PycnometerDetermination"},{"type":"null"}],"x-order":4}}}}, + "required":["data"]})"; + +constexpr std::string_view kGrading = R"({"$defs":{"double":{"type":"number"},"int64_t":{"type":"integer"}, + "Row":{"type":"object","required":["sieve","passing"],"properties":{ + "note":{"type":["string","null"],"x-order":2}, + "sieve":{"$ref":"#/$defs/double","x-order":0,"ExtUnits":{"unitAscii":"mm","unitUnicode":"mm"}}, + "passing":{"type":["object","null"],"properties":{"num":{"type":"integer"},"den":{"type":"integer"},"dp":{"type":"integer"}},"ExtUnits":{"unitAscii":"pct","unitUnicode":"%"},"x-decimalPlaces":1,"x-order":1}, + "order":{"$ref":"#/$defs/int64_t","x-order":3,"x-readonly":true}}}}, + "properties":{"rows":{"type":"array","items":{"$ref":"#/$defs/Row"},"x-order":0}, + "tags":{"type":"array","items":{"type":"string"},"x-order":1}},"required":["rows"]})"; + +void fillIgnitionScalars(Harness& harness, morph::forms::FormSession& form) { + harness.type(form, "method", R"("Furnace")"); + harness.type(form, "calibrationFactor", "0.25"); + harness.type(form, "driedSample", "true"); +} + +} // namespace + +TEST_CASE("nested: an object encodes member by member with each member's own encoder", "[forms-engine][nested]") { + Harness harness; + auto const form = harness.session("T_Ignition", kIgnition); + fillIgnitionScalars(harness, *form); + CHECK_FALSE(form->ready()); + harness.type(*form, "specimen.massOfContainer", "512.3"); + harness.type(*form, "specimen.massOfContainerAndSampleBeforeIgnition", "2012.8"); + harness.type(*form, "specimen.readoutBinderContent", "5.25"); + harness.type(*form, "specimen.testTemperature", "538"); + REQUIRE(form->ready()); + CHECK(harness.server.lastBody() == + R"({"method":"Furnace","specimen":{"massOfContainer":512.3,"massOfContainerAndSampleBeforeIgnition":2012.8,)" + R"("readoutBinderContent":5.25,"testTemperature":538},"calibrationFactor":0.25,"driedSample":true})"); + CHECK(form->field("specimen.massOfContainer").path() == "specimen.massOfContainer"); +} + +TEST_CASE("nested: a member that does not encode, or a blank required member, keeps the form unready", + "[forms-engine][nested]") { + Harness harness; + auto const form = harness.session("T_Ignition", kIgnition); + fillIgnitionScalars(harness, *form); + harness.type(*form, "specimen.massOfContainerAndSampleBeforeIgnition", "2.0"); + harness.type(*form, "specimen.testTemperature", "538"); + CHECK_FALSE(form->ready()); // massOfContainer is required and blank + CHECK(form->field("specimen").error().get()->code == "incomplete"); + harness.type(*form, "specimen.massOfContainer", "1.25"); + CHECK_FALSE(form->ready()); // finer than x-displayDecimals + harness.type(*form, "specimen.massOfContainer", "abc"); + CHECK_FALSE(form->ready()); + harness.type(*form, "specimen.massOfContainer", "1.2"); + harness.type(*form, "specimen.testTemperature", "1.5"); + CHECK_FALSE(form->ready()); + harness.type(*form, "specimen.testTemperature", "538"); + CHECK(form->ready()); + CHECK(harness.server.submits().size() == 1); +} + +TEST_CASE("nested: members read in the display locale", "[forms-engine][nested]") { + Harness harness; + auto const form = harness.session("T_Ignition", kIgnition, + FormSessionOptions{.locale = {.decimalSeparator = ",", .groupSeparator = "."}}); + harness.type(*form, "method", R"("Furnace")"); + harness.type(*form, "calibrationFactor", "0,25"); + harness.type(*form, "driedSample", "true"); + harness.type(*form, "specimen.massOfContainer", "1.512,3"); + harness.type(*form, "specimen.massOfContainerAndSampleBeforeIgnition", "2012,8"); + harness.type(*form, "specimen.readoutBinderContent", "5,25"); + harness.type(*form, "specimen.testTemperature", "538"); + CHECK(form->body() == + R"({"method":"Furnace","specimen":{"massOfContainer":1512.3,"massOfContainerAndSampleBeforeIgnition":2012.8,)" + R"("readoutBinderContent":5.25,"testTemperature":538},"calibrationFactor":0.25,"driedSample":true})"); +} + +TEST_CASE("nested: two levels deep, blank optional leaves and objects are left out", "[forms-engine][nested]") { + Harness harness; + auto const form = harness.session("T_Density", kDensity); + CHECK_FALSE(form->ready()); // data is required and blank + harness.type(*form, "data.useSpecificGravity", "false"); + harness.type(*form, "data.testLiquidTemperature", "25.0"); + harness.type(*form, "data.testLiquidName", "water"); + harness.type(*form, "data.determination1.massPycnometerEmpty", "1450.10"); + harness.type(*form, "data.determination1.massPycnometerAndSample", "3450.25"); + harness.type(*form, "data.determination1.excluded", "false"); + CHECK(form->body() == + R"({"data":{"useSpecificGravity":false,"testLiquidTemperature":25.0,"testLiquidName":"water",)" + R"("determination1":{"massPycnometerEmpty":1450.10,"massPycnometerAndSample":3450.25,"excluded":false}}})"); + form->reset(); + harness.settle(); + harness.type(*form, "data.useSpecificGravity", "true"); + harness.type(*form, "data.testLiquidName", " "); + CHECK(form->body() == R"({"data":{"useSpecificGravity":true}})"); + harness.type(*form, "data.determination2.massPycnometerEmpty", "10.00"); + CHECK_FALSE(form->ready()); // a partly filled optional object needs its required members + harness.type(*form, "data.determination2.massPycnometerAndSample", "20.00"); + harness.type(*form, "data.determination2.excluded", "true"); + CHECK(form->body() == R"({"data":{"useSpecificGravity":true,"determination2":{"massPycnometerEmpty":10.00,)" + R"("massPycnometerAndSample":20.00,"excluded":true}}})"); +} + +TEST_CASE("nested: a required boolean member of a filled object answers false", "[forms-engine][nested]") { + Harness harness; + auto const form = harness.session("T_Density", kDensity); + harness.type(*form, "data.testLiquidName", "water"); + CHECK(form->body() == R"({"data":{"useSpecificGravity":false,"testLiquidName":"water"}})"); +} + +TEST_CASE("nested: a prefill decodes into the members and round-trips", "[forms-engine][nested]") { + constexpr std::string_view kStored = + R"({"data":{"useSpecificGravity":false,"testLiquidTemperature":25.5,"determination1":{"massPycnometerEmpty":1450.1,)" + R"("massPycnometerAndSample":3450.25,"excluded":true,"computedDensity":2.412}}})"; + Harness harness; + auto const form = harness.session("T_Density", kDensity); + form->prefill(kStored); + harness.settle(); + CHECK(harness.server.submits().empty()); + CHECK(form->field("data.useSpecificGravity").text().peek() == "false"); + CHECK(form->field("data.testLiquidTemperature").text().peek() == "25.5"); + CHECK(form->field("data.determination1.massPycnometerEmpty").text().peek() == "1450.10"); + CHECK(form->field("data.determination1.excluded").text().peek() == "true"); + CHECK(form->field("data.determination2").blank()); + CHECK(form->body() == + R"({"data":{"useSpecificGravity":false,"testLiquidTemperature":25.5,"determination1":{"massPycnometerEmpty":1450.10,)" + R"("massPycnometerAndSample":3450.25,"excluded":true,"computedDensity":2.412}}})"); + form->reset(); + harness.settle(); + CHECK(form->field("data").blank()); + CHECK_FALSE(form->ready()); + + Harness german; + auto const germanForm = german.session("T_Ignition", kIgnition, + FormSessionOptions{.locale = {.decimalSeparator = ",", .groupSeparator = "."}}); + germanForm->prefill(R"({"method":"Infrared","calibrationFactor":0.5,"driedSample":false,"specimen":{"massOfContainer":512.3,)" + R"("massOfContainerAndSampleBeforeIgnition":2012.8,"testTemperature":538}})"); + german.settle(); + CHECK(germanForm->field("specimen.massOfContainer").text().peek() == "512,3"); + CHECK(germanForm->field("specimen.readoutBinderContent").blank()); + CHECK(germanForm->body() == R"({"method":"Infrared","specimen":{"massOfContainer":512.3,)" + R"("massOfContainerAndSampleBeforeIgnition":2012.8,"testTemperature":538},"calibrationFactor":0.5,)" + R"("driedSample":false})"); +} + +TEST_CASE("nested: collection rows encode with each member's encoder; an empty required collection is []", + "[forms-engine][nested]") { + Harness harness; + auto const form = harness.session("T_Grading", kGrading); + CHECK(form->body() == R"({"rows":[]})"); + auto& rows = form->field("rows"); + rows.addRow(); + harness.type(*form, "rows[0].sieve", "31.5"); + CHECK_FALSE(form->ready()); // passing is required and blank + harness.type(*form, "rows[0].passing", "100.0"); + auto const second = rows.addRow(); + harness.type(*form, "rows[1].sieve", "0.063"); + harness.type(*form, "rows[1].passing", "4.2"); + harness.type(*form, "rows[1].note", "fines"); + CHECK(form->body() == R"({"rows":[{"sieve":31.5,"passing":{"num":1000,"den":10,"dp":1}},)" + R"({"sieve":0.063,"passing":{"num":42,"den":10,"dp":1},"note":"fines"}]})"); + harness.type(*form, "rows[1].passing", "1.25"); + CHECK_FALSE(form->ready()); // an over-precise Quantity cell is refused, not rounded + harness.type(*form, "rows[1].passing", "1.2"); + CHECK(form->ready()); + rows.removeRow(rows.rows().front()->key()); + harness.settle(); + REQUIRE(rows.rows().size() == 1); + CHECK(rows.rows().front()->key() == second); + CHECK(form->field("rows[0].note").text().peek() == "fines"); + CHECK(form->field("rows[0].sieve").path() == "rows#" + std::to_string(second) + ".sieve"); +} + +TEST_CASE("nested: loaded cells read as their text; a reset empties the rows", "[forms-engine][nested]") { + Harness harness; + auto const form = harness.session("T_Grading", kGrading); + form->prefill(R"({"rows":[{"sieve":2.5,"passing":{"num":30,"den":10,"dp":1},"order":7}]})"); + harness.settle(); + CHECK(form->body() == R"({"rows":[{"sieve":2.5,"passing":{"num":30,"den":10,"dp":1},"order":7}]})"); + CHECK(form->field("rows[0].passing").text().peek() == "3.0"); + form->reset(); + harness.settle(); + CHECK(form->field("rows").rows().empty()); + CHECK(form->body() == R"({"rows":[]})"); +} + +TEST_CASE("nested: rows inside an object reuse the row encoder", "[forms-engine][nested]") { + Harness harness; + auto const form = harness.session("T_Grading", R"({"$defs":{"Row":{"type":"object","required":["sieve","passing"],"properties":{ + "sieve":{"type":"number","x-order":0}, + "passing":{"type":["object","null"],"ExtUnits":{"unitAscii":"pct","unitUnicode":"%"},"x-decimalPlaces":1,"x-order":1}}}}, + "properties":{"grading":{"type":"object","x-order":0,"required":["label","rows"],"properties":{ + "label":{"type":"string","x-order":0},"rows":{"type":"array","items":{"$ref":"#/$defs/Row"},"x-order":1}}}}, + "required":["grading"]})"); + harness.type(*form, "grading.label", "A"); + CHECK(form->body() == R"({"grading":{"label":"A","rows":[]}})"); + form->field("grading.rows").addRow(); + harness.type(*form, "grading.rows[0].sieve", "8"); + CHECK_FALSE(form->ready()); + harness.type(*form, "grading.rows[0].passing", "55.0"); + CHECK(form->body() == R"({"grading":{"label":"A","rows":[{"sieve":8,"passing":{"num":550,"den":10,"dp":1}}]}})"); +} + +TEST_CASE("nested: an acyclic aggregate is drawn and encoded; an optional one may be left out", + "[forms-engine][nested]") { + Harness harness; + auto const acyclic = harness.session("AcyclicAction", R"({"properties":{"id":{"type":"integer","x-order":0}, + "address":{"type":"object","x-order":1,"required":["street","city"],"properties":{ + "street":{"type":"string","x-order":0},"city":{"type":"string","x-order":1}}}},"required":["id","address"]})"); + harness.type(*acyclic, "id", "7"); + harness.type(*acyclic, "address.street", "Main"); + CHECK_FALSE(acyclic->ready()); + harness.type(*acyclic, "address.city", "Springfield"); + CHECK(acyclic->body() == R"({"id":7,"address":{"street":"Main","city":"Springfield"}})"); + auto const optional = harness.session("OptionalNestedAction", R"({"properties":{"id":{"type":"integer","x-order":0}, + "note":{"type":"string","x-order":1},"address":{"type":"object","x-order":2,"required":["street"], + "properties":{"street":{"type":"string","x-order":0}}}},"required":["id"]})"); + harness.type(*optional, "id", "7"); + CHECK(optional->body() == R"({"id":7})"); +} + +TEST_CASE("nested: a self-referential type stops where it repeats, and a value loaded there is unrepresentable", + "[forms-engine][nested]") { + Harness harness; + auto const form = harness.session("T_Tree", R"({"$defs":{"TreeNode":{"type":"object","required":["name"],"properties":{ + "name":{"type":"string","x-order":0},"child":{"anyOf":[{"$ref":"#/$defs/TreeNode"},{"type":"null"}],"x-order":1}}}}, + "properties":{"root":{"$ref":"#/$defs/TreeNode","x-order":0}},"required":["root"]})"); + REQUIRE(form->field("root").members().size() == 2); + CHECK(form->field("root.child").spec().truncated); + harness.type(*form, "root.name", "top"); + CHECK(harness.server.lastBody() == R"({"root":{"name":"top"}})"); + form->prefill(R"({"root":{"name":"top","child":{"name":"leaf"}}})"); + harness.settle(); + CHECK_FALSE(form->ready()); + CHECK(form->field("root.child").error().get()->code == "unrepresentable"); + + auto const tree = harness.session("SelfAction", R"({"$defs":{"probe::TreeNode":{"type":"object","required":["name","children"],"properties":{ + "children":{"type":"array","items":{"$ref":"#/$defs/probe::TreeNode"},"x-order":1},"name":{"type":"string","x-order":0}}}}, + "properties":{"id":{"type":"integer","x-order":0},"root":{"$ref":"#/$defs/probe::TreeNode","x-order":1}},"required":["id","root"]})"); + harness.type(*tree, "id", "7"); + harness.type(*tree, "root.name", "top"); + CHECK(tree->field("root.children").spec().truncated); // probe::TreeNode is already on the path + CHECK(tree->body() == R"({"id":7,"root":{"name":"top","children":[]}})"); + CHECK(tree->field("root.children").addRow() == 0); // a truncated collection takes no rows + CHECK(tree->field("root.children").rows().empty()); +} + +TEST_CASE("nested: field() walks members and rows and refuses what is not there", "[forms-engine][nested]") { + Harness harness; + auto const form = harness.session("T_Grading", kGrading); + CHECK_THROWS_AS(form->field("rows[0].sieve"), std::out_of_range); + form->field("rows").addRow(); + CHECK(form->field("rows[0].sieve").spec().name == "sieve"); + CHECK_THROWS_AS(form->field("rows[0]"), std::out_of_range); + CHECK_THROWS_AS(form->field("rows[x].sieve"), std::out_of_range); + CHECK_THROWS_AS(form->field("rows[0].nope"), std::out_of_range); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: compile errors `no member named 'members' in 'morph::forms::FieldState'` and `no member named 'addRow'`. + +- [ ] **Step 3: Implement** + +In `include/morph/forms/engine/form_session.hpp`: + +1. After `class FieldState;` add `class RowState;`, and directly before `/// @brief The reactive state of one field: its + draft and everything derived from it.` insert: + +```cpp +/// @brief One collection row: the element's member fields. +class RowState { +public: + /// @param core The session core. Borrowed. + /// @param arraySpec The collection field's spec (its `children` are the members). + /// @param arrayPath The collection field's path. + /// @param key The row's stable key. + /// @param parent The collection field. + RowState(detail::engine::SessionCore& core, FieldSpec const& arraySpec, std::string const& arrayPath, + std::uint64_t key, FieldState const* parent); + ~RowState(); + RowState(RowState const&) = delete; + RowState& operator=(RowState const&) = delete; + RowState(RowState&&) = delete; + RowState& operator=(RowState&&) = delete; + + /// @brief The row's stable key (not its position). + /// @return The key. + [[nodiscard]] std::uint64_t key() const noexcept { return _key; } + + /// @brief The member fields, in `x-order`. + /// @return The fields. + [[nodiscard]] std::span const> fields() const noexcept { return _fields; } + + /// @brief A member by wire name. + /// @param name The wire name. + /// @return The member, or null. + [[nodiscard]] FieldState* field(std::string_view name) const noexcept; + + /// @brief Whether every member is blank. Tracked. + /// @return `true` when nothing is entered in the row. + [[nodiscard]] bool blank() const; + + /// @brief Starts every member; called once the row is built and loaded. + void start(); + +private: + std::uint64_t _key; + std::vector> _fields; +}; +``` + +2. In `FieldState`'s constructor initializer list, after `_engaged{core.runtime(), false},` add + `_rows{core.runtime(), std::vector>{}},`; at the start of the constructor body add: + +```cpp + if (_spec->kind == FieldKind::Object) { + for (FieldSpec const& child : _spec->children) { + _members.push_back(std::make_unique(core, child, _path + "." + child.name, this)); + } + } +``` + +3. Replace `FieldState::blank()` (the one-line body) with: + +```cpp + /// @brief Whether nothing is entered: a blank draft, an Object whose members are all blank, a + /// collection with no rows that was never engaged. Tracked. + /// @return `true` when blank. + // NOLINTNEXTLINE(misc-no-recursion) -- an Object asks its members + [[nodiscard]] bool blank() const { + switch (_spec->kind) { + case FieldKind::Object: + if (_spec->truncated) { + return detail::engine::trim(_text.get()).empty(); + } + return std::ranges::all_of(_members, [](auto const& entry) { return entry->blank(); }); + case FieldKind::ObjectArray: + if (_spec->truncated) { + return detail::engine::trim(_text.get()).empty() && !_engaged.get(); + } + return _rows.get().empty() && !_engaged.get(); + default: + return detail::engine::trim(_text.get()).empty(); + } + } +``` + +4. After `switchUnit`, add: + +```cpp + /// @brief An Object's members, in `x-order`. + /// @return The members; empty for every other kind. + [[nodiscard]] std::span const> members() const noexcept { return _members; } + + /// @brief An Object's member by wire name. + /// @param name The wire name. + /// @return The member, or null. + [[nodiscard]] FieldState* member(std::string_view name) const noexcept { + auto const found = std::ranges::find_if(_members, [&](auto const& entry) { return entry->spec().name == name; }); + return found == _members.end() ? nullptr : found->get(); + } + + /// @brief A collection's rows, in order. Tracked. + /// @return The rows; empty for every other kind. + [[nodiscard]] std::vector> const& rows() const { return _rows.get(); } + + /// @brief Appends a blank row to a collection and engages it. A user action. + /// @return The new row's key; 0 when the field is not a collection, or a truncated one. + std::uint64_t addRow() { + if (_spec->kind != FieldKind::ObjectArray || _spec->truncated) { + return 0; + } + auto row = std::make_shared(*_core, *_spec, _path, _nextRowKey++, this); + row->start(); + std::uint64_t const key = row->key(); + _core->runtime().batch([&] { + _rows.mutate([&](auto& rows) { rows.push_back(std::move(row)); }); + _engaged.set(true); + }); + return key; + } + + /// @brief Removes a collection row by key. A user action. A mounted view that still holds the + /// row keeps its state alive until it unmounts it. + /// @param key The row's key. + void removeRow(std::uint64_t key) { + _rows.mutate([&](auto& rows) { std::erase_if(rows, [&](auto const& row) { return row->key() == key; }); }); + } +``` + +5. Replace `FieldState::load`, `clear` and `start` with: + +```cpp + /// @brief Replaces the draft with the decoded wire value; null or absent is blank. Does not + /// mark the change programmatic — the caller does, in the same batch. + /// @param value The wire value, or null. + // NOLINTNEXTLINE(misc-no-recursion) -- an Object loads its members + void load(detail::engine::Json const* value) { + using detail::engine::Json; + bool const present = value != nullptr && !value->is_null(); + if (_spec->truncated) { + _text.set(present ? detail::engine::toJson(*value) : std::string{}); + _engaged.set(present && value->is_array()); + return; + } + switch (_spec->kind) { + case FieldKind::Object: + for (auto const& entry : _members) { + entry->load(present ? detail::engine::member(*value, entry->spec().name) : nullptr); + } + return; + case FieldKind::ObjectArray: { + std::vector> loaded; + if (present && value->is_array()) { + for (Json const& element : value->get_array()) { + auto row = std::make_shared(*_core, *_spec, _path, _nextRowKey++, this); + for (auto const& cell : row->fields()) { + cell->load(detail::engine::member(element, cell->spec().name)); + } + row->start(); + loaded.push_back(std::move(row)); + } + } + _rows.set(std::move(loaded)); + _engaged.set(present && value->is_array()); + return; + } + default: + _text.set(present ? decodeScalar(*_spec, *value, _core->values()) : std::string{}); + _unit.set(0); + _engaged.set(_spec->blankAs == BlankAs::Empty && present && value->is_string()); + return; + } + } + + /// @brief Blanks the draft, the unit, the engagement, the members and the rows. + // NOLINTNEXTLINE(misc-no-recursion) -- an Object clears its members + void clear() { + _text.set(std::string{}); + _unit.set(0); + _engaged.set(false); + for (auto const& entry : _members) { + entry->clear(); + } + _rows.set({}); + } + + /// @brief Starts what needs every sibling to exist: an `x-blankAs` field's engagement, and + /// every member's own. Called once, after the owner built all its fields. + // NOLINTNEXTLINE(misc-no-recursion) -- an Object starts its members + void start() { + for (auto const& entry : _members) { + entry->start(); + } + if (_spec->blankAs == BlankAs::Empty) { + _engage = std::make_unique(_core->runtime(), [this] { + if (!detail::engine::trim(_text.get()).empty() && !_engaged.peek()) { + _engaged.set(true); + } + }); + } + } +``` + +6. Replace `FieldState::computeEncoding` with (and add `encodeMembers` above it): + +```cpp + // NOLINTNEXTLINE(misc-no-recursion) -- an Object or a row encodes its members + [[nodiscard]] std::expected encodeMembers( + std::span const> members) const { + std::string json = "{"; + bool first = true; + for (auto const& entry : members) { + Encoding const& encoding = entry->encoded().get(); + if (!encoding.has_value()) { + if (entry->spec().required) { + return std::unexpected(fieldError("incomplete", _core->values())); + } + continue; + } + if (!encoding->has_value()) { + return std::unexpected(encoding->error()); + } + json += (first ? "" : ",") + detail::engine::quote(entry->spec().name) + ":" + (*encoding)->json; + first = false; + } + return json + "}"; + } + + // NOLINTNEXTLINE(misc-no-recursion) -- an Object or a row encodes its members + [[nodiscard]] Encoding computeEncoding() const { + if (_spec->truncated) { + std::string_view const draft = detail::engine::trim(_text.get()); + bool const array = _spec->kind == FieldKind::ObjectArray; + if (draft.empty() && !_engaged.get()) { + return (array && _spec->required) ? Encoding{WireValue{"[]", std::monostate{}}} : Encoding{}; + } + if (array && (draft == "[]" || draft.empty())) { + return WireValue{"[]", std::monostate{}}; + } + return std::unexpected(fieldError("unrepresentable", _core->values())); + } + switch (_spec->kind) { + case FieldKind::Object: { + if (blank()) { + return std::nullopt; + } + auto json = encodeMembers(_members); + if (!json) { + return std::unexpected(json.error()); + } + return WireValue{*std::move(json), std::monostate{}}; + } + case FieldKind::ObjectArray: { + auto const& rows = _rows.get(); + if (rows.empty() && !_engaged.get() && !_spec->required) { + return std::nullopt; + } + std::string json = "["; + for (std::size_t i = 0; i < rows.size(); ++i) { + auto row = encodeMembers(rows.at(i)->fields()); + if (!row) { + return std::unexpected(row.error()); + } + json += (i == 0 ? "" : ",") + *row; + } + return WireValue{json + "]", std::monostate{}}; + } + default: + return encodeScalar(*_spec, _text.get(), _unit.get(), _core->values()); + } + } +``` + +7. In `FieldState`'s members, add `std::uint64_t _nextRowKey = 1;` after `_placeholder`, and after + `reactive::Signal _engaged;` add: + +```cpp + reactive::Signal>> _rows; + std::vector> _members; +``` + +8. After the closing `};` of `FieldState`, add the `RowState` definitions: + +```cpp +inline RowState::RowState(detail::engine::SessionCore& core, FieldSpec const& arraySpec, std::string const& arrayPath, + std::uint64_t key, FieldState const* parent) + : _key{key} { + for (FieldSpec const& member : arraySpec.children) { + _fields.push_back( + std::make_unique(core, member, arrayPath + "#" + std::to_string(key) + "." + member.name, parent)); + } +} + +inline RowState::~RowState() = default; + +inline FieldState* RowState::field(std::string_view name) const noexcept { + auto const found = std::ranges::find_if(_fields, [&](auto const& entry) { return entry->spec().name == name; }); + return found == _fields.end() ? nullptr : found->get(); +} + +inline bool RowState::blank() const { + return std::ranges::all_of(_fields, [](auto const& entry) { return entry->blank(); }); +} + +inline void RowState::start() { + for (auto const& entry : _fields) { + entry->start(); + } +} +``` + +9. Replace `FormSession::field` with: + +```cpp + /// @brief A field by path: `name`, `parent.member`, or `rows[i].member` (a row by position). + /// @param path The path. + /// @return The field. + /// @throws std::out_of_range when no field has that path. + [[nodiscard]] FieldState& field(std::string_view path) const { + FieldState* current = nullptr; + std::span const> scope = _fields; + std::string_view rest = path; + auto const missing = [&] { return std::out_of_range{"FormSession::field: no field '" + std::string{path} + "'"}; }; + while (!rest.empty()) { + auto const dot = rest.find('.'); + std::string_view segment = rest.substr(0, dot); + rest = dot == std::string_view::npos ? std::string_view{} : rest.substr(dot + 1); + std::optional index; + if (auto const bracket = segment.find('['); bracket != std::string_view::npos) { + if (!segment.ends_with(']')) { + throw missing(); + } + std::string_view const digits = segment.substr(bracket + 1, segment.size() - bracket - 2); + std::size_t parsed = 0; + auto const result = std::from_chars(digits.data(), digits.data() + digits.size(), parsed); + if (digits.empty() || result.ec != std::errc{} || result.ptr != digits.data() + digits.size()) { + throw missing(); + } + index = parsed; + segment = segment.substr(0, bracket); + } + auto const found = + std::ranges::find_if(scope, [&](auto const& entry) { return entry->spec().name == segment; }); + if (found == scope.end()) { + throw missing(); + } + current = found->get(); + if (index.has_value()) { + auto const& rows = current->rows(); + if (*index >= rows.size() || rest.empty()) { + throw missing(); + } + scope = rows.at(*index)->fields(); + } else { + scope = current->members(); + if (!rest.empty() && scope.empty()) { + throw missing(); + } + } + } + if (current == nullptr) { + throw missing(); + } + return *current; + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine]"` +Expected: PASS — the 11 new `[nested]` cases and every earlier `[forms-engine]` case. + +Mutation check: in `computeEncoding`'s `ObjectArray` case, drop `&& !_spec->required` from the blank test. Expected: +FAIL in "collection rows encode … an empty required collection is []" (the first body is `nullopt`). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/form_session.hpp tests/test_forms_engine_nested.cpp tests/CMakeLists.txt +git commit -m "wip(forms): nested objects and collections of objects in FormSession + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 8: Choice options — a Query keyed on the parents, latest wins, stale selections cleared + +A Choice's options are a `reactive::Query` whose key is the options request body: +`{}` for an independent Choice, `{"parent":,…}` once every `x-optionsDependsOn` parent encodes, idle +(`nullopt`) while one does not. So a parent change refetches, a reply for a superseded parent value is dropped (the +query's latest-wins), and an idle query has no options. A selection no longer among the options is cleared — a +programmatic change, so it never submits — but only once the query has answered for the current parents: a +selection prefilled before its options arrive survives the fetch. + +**Files:** +- Modify: `include/morph/forms/engine/form_session.hpp` — `FieldState` (constructor, `options`, `optionsPending`, + `start`, three private helpers, four members) and the three places a `FieldState` is constructed +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_choice.cpp` after `test_forms_engine_nested.cpp` +- Test: `tests/test_forms_engine_choice.cpp` + +**Interfaces:** +- Consumes: Part 1's `reactive::Query(Runtime&, Fetch, Key)` with `pending()`, `value()`, `error()`; + Task 5's `parseOptions`; Task 6/7's `FieldState`. +- Produces: `FieldState(SessionCore&, FieldSpec const&, std::string path, std::vector> + const* siblings, FieldState const* parent)` (the constructor gains `siblings`); `FieldState::options()` returning + a Choice's fetched options; `FieldState::optionsPending()`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_choice.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "forms_engine_support.hpp" + +namespace { + +using morph::test::formsengine::Harness; +using morph::test::formsengine::modelOf; + +constexpr std::string_view kShipTo = R"({"properties":{ + "country":{"type":["integer","null"],"x-order":0,"x-optionsAction":"ListCountries","x-optionValue":"id","x-optionLabel":"name"}, + "city":{"type":["integer","null"],"x-order":1,"x-optionsAction":"ListCities","x-optionValue":"id","x-optionLabel":"name","x-optionsDependsOn":["country"]}}, + "required":["country","city"]})"; + +/// Countries 1 and 2; cities 10 in country 1 and nothing else. +[[nodiscard]] std::optional geography(morph::test::formsengine::Call const& call) { + if (call.action == "ListCountries") { + return R"([{"id":1,"name":"One"},{"id":2,"name":"Two"}])"; + } + if (call.body == R"({"country":1})") { + return R"([{"id":10,"name":"Ten"}])"; + } + return "[]"; +} + +[[nodiscard]] std::vector labels(morph::forms::FieldState const& field) { + std::vector out; + for (auto const& option : field.options()) { + out.push_back(option.label); + } + return out; +} + +} // namespace + +TEST_CASE("choice: only an independent Choice fetches on construction, with an empty body", "[forms-engine][choice]") { + Harness harness; + auto const form = harness.session("ShipTo", kShipTo); + REQUIRE(harness.server.fetches().size() == 1); + CHECK(harness.server.fetches()[0].action == "ListCountries"); + CHECK(harness.server.fetches()[0].body == "{}"); +} + +TEST_CASE("choice: a dependent Choice refetches with its parents' literals when a parent changes", + "[forms-engine][choice]") { + Harness harness; + harness.server.optionsWith(geography); + auto const form = harness.session("ShipTo", kShipTo); + harness.type(*form, "country", "1"); + REQUIRE(harness.server.fetches().size() == 2); + CHECK(harness.server.fetches()[1].action == "ListCities"); + CHECK(harness.server.fetches()[1].body == R"({"country":1})"); + harness.type(*form, "country", ""); + CHECK(harness.server.fetches().size() == 2); // a blank parent is no request +} + +TEST_CASE("choice: a selection the new options no longer back is cleared, without submitting", + "[forms-engine][choice]") { + Harness harness; + harness.server.optionsWith(geography); + auto const form = harness.session("ShipTo", kShipTo); + harness.type(*form, "country", "1"); + harness.type(*form, "city", "10"); + CHECK(form->field("city").text().peek() == "10"); + auto const submitted = harness.server.submits().size(); + harness.type(*form, "country", "2"); + CHECK(form->field("city").text().peek().empty()); + CHECK(harness.server.submits().size() == submitted); +} + +// Review Focus 2. +TEST_CASE("choice: a reply for a superseded parent value is dropped, and a prefilled selection survives its refetch", + "[forms-engine][choice]") { + Harness harness; + harness.server.optionsWith([](auto const&) { return std::nullopt; }); // settle by hand + auto const form = harness.session("ShipTo", kShipTo); + harness.server.resolveFetch(0, R"([{"id":1,"name":"A"},{"id":2,"name":"B"}])"); + harness.settle(); + CHECK(labels(form->field("country")) == std::vector{"A", "B"}); + harness.type(*form, "country", "1"); + harness.type(*form, "country", "2"); + REQUIRE(harness.server.fetches().size() == 3); + CHECK(form->field("city").optionsPending()); + harness.server.resolveFetch(1, R"([{"id":10,"name":"Old"}])"); + harness.settle(); + CHECK(form->field("city").options().empty()); // the reply for country 1 is stale + harness.server.resolveFetch(2, R"([{"id":20,"name":"New"}])"); + harness.settle(); + CHECK(labels(form->field("city")) == std::vector{"New"}); + + form->prefill(R"({"country":1,"city":10})"); + harness.settle(); + REQUIRE(harness.server.fetches().size() == 4); + CHECK(form->field("city").text().peek() == "10"); // kept while its options are in flight + harness.server.resolveFetch(3, R"([{"id":10,"name":"Old"}])"); + harness.settle(); + CHECK(form->field("city").text().peek() == "10"); + CHECK(form->body() == R"({"country":1,"city":10})"); +} + +TEST_CASE("choice: a failed fetch keeps the selection; an answered one without it clears it", + "[forms-engine][choice]") { + Harness harness; + harness.server.optionsWith([](auto const&) { return std::nullopt; }); + auto const form = harness.session("Probe", R"({"properties":{"slot":{"type":"integer","x-optionsAction":"ListSlots","x-order":0}},"required":["slot"]})"); + form->prefill(R"({"slot":4})"); + harness.settle(); + harness.server.rejectFetch(0, "options unavailable"); + harness.settle(); + CHECK(form->field("slot").text().peek() == "4"); + CHECK(form->body() == R"({"slot":4})"); + + Harness answered; + answered.server.optionsWith([](auto const&) { return std::optional{R"([{"id":5,"name":"Five"}])"}; }); + auto const other = answered.session("Probe", R"({"properties":{"slot":{"type":"integer","x-optionsAction":"ListSlots","x-order":0}},"required":["slot"]})"); + answered.settle(); + other->prefill(R"({"slot":4})"); + answered.settle(); + CHECK(other->field("slot").text().peek().empty()); +} + +TEST_CASE("choice: option values and labels keep ids beyond 2^53 distinct", "[forms-engine][choice]") { + Harness harness; + constexpr std::string_view kRows = R"({"rows":[{"id":9007199254740993,"name":"Alpha"},{"id":9007199254740992,"name":"Beta"}]})"; + harness.server.optionsWith([&](auto const&) { return std::optional{kRows}; }); + auto const byName = harness.session("Probe", R"({"properties":{"slot":{"type":["integer","null"],"x-order":0,"x-optionsAction":"ListRows","x-optionValue":"id","x-optionLabel":"name"}},"required":["slot"]})"); + auto const byId = harness.session("Probe", R"({"properties":{"slot":{"type":["integer","null"],"x-order":0,"x-optionsAction":"ListRows","x-optionValue":"id","x-optionLabel":"id"}},"required":["slot"]})"); + harness.settle(); + auto const& options = byName->field("slot").options(); + REQUIRE(options.size() == 2); + CHECK(options[0].valueJson == "9007199254740993"); + CHECK(options[1].valueJson == "9007199254740992"); + CHECK(labels(byId->field("slot")) == std::vector{"9007199254740993", "9007199254740992"}); + harness.type(*byName, "slot", options[0].valueJson); + CHECK(byName->body() == R"({"slot":9007199254740993})"); +} + +TEST_CASE("choice: a prefilled selection is shown once its options arrive", "[forms-engine][choice]") { + Harness harness; + harness.server.optionsWith([](auto const&) { + return std::optional{R"([{"id":9007199254740993,"name":"Big"},{"id":2,"name":"Two"}])"}; + }); + auto const form = harness.session("T_Choice", R"({"properties":{"sample":{"type":"integer","x-optionsAction":"ListSamples","x-order":0}},"required":["sample"]})"); + form->prefill(R"({"sample":9007199254740993})"); + harness.settle(); + CHECK(form->body() == R"({"sample":9007199254740993})"); + auto const& options = form->field("sample").options(); + REQUIRE(options.size() == 2); + CHECK(options[0].valueJson == form->field("sample").text().peek()); + CHECK(options[0].label == "Big"); +} + +TEST_CASE("choice: a form with no Choice needs no fetcher; one with a Choice gets its options", + "[forms-engine][choice]") { + Harness harness; + morph::forms::FormSession const plain{ + harness.rt, + modelOf("CreateNote", + R"({"properties":{"title":{"type":"string","x-order":0},"count":{"type":"integer","x-order":1}},"required":["title"]})"), + harness.server.submitter(), morph::forms::ChoiceFetcher{}}; + CHECK(plain.fields().size() == 2); + CHECK(harness.server.fetches().empty()); + + harness.server.optionsWith([](auto const&) { return std::optional{R"([{"id": 4, "name": "Bravo"}])"}; }); + auto const choice = harness.session( + "MoveCard", + R"({"properties":{"columnId":{"type":"integer","x-order":0,"x-optionsAction":"ListColumns","x-optionValue":"id","x-optionLabel":"name"}},"required":["columnId"]})"); + harness.settle(); + CHECK(labels(choice->field("columnId")) == std::vector{"Bravo"}); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][choice]"` +Expected: FAIL in "only an independent Choice fetches on construction…" (`harness.server.fetches().size() == 1` is `0 == 1`); +every other `[choice]` case fails on its first fetch or options assertion. + +- [ ] **Step 3: Implement** + +In `include/morph/forms/engine/form_session.hpp`: + +1. `FieldState`'s constructor takes the siblings a Choice's `dependsOn` names. Change its parameter list and + documentation to: + +```cpp + /// @param core The session core. Borrowed: it must outlive the field. + /// @param spec The field's spec. Borrowed from the core's model (or a parent's children). + /// @param path The instance path (`name`, `parent.member`, `rows#key.member`). + /// @param siblings The fields `x-optionsDependsOn` names are looked up among. Borrowed. + /// @param parent The containing Object or ObjectArray field, or null at the top level. + FieldState(detail::engine::SessionCore& core MORPH_LIFETIMEBOUND, FieldSpec const& spec MORPH_LIFETIMEBOUND, + std::string path, std::vector> const* siblings, FieldState const* parent) +``` + + add `_siblings{siblings},` to the initializer list after `_path{std::move(path)},`, and update the three + constructions: in the `FieldState` constructor body + `std::make_unique(core, child, _path + "." + child.name, &_members, this)`; in `RowState`'s + constructor `std::make_unique(core, member, arrayPath + "#" + std::to_string(key) + "." + member.name, + &_fields, parent)`; in `FormSession`'s constructor `std::make_unique(*_core, spec, spec.name, &_fields, + nullptr)`. + +2. Replace `options()` and `optionsPending()` with: + +```cpp + /// @brief The selectable options: an Enum's from the schema, a Choice's as last fetched for + /// its current parents. Tracked. + /// @return The options; empty for every other kind, and for a Choice with nothing fetched. + [[nodiscard]] std::vector const& options() const { + if (_fetched) { + return _fetched->get(); + } + return _spec->enumOptions; + } + + /// @brief Whether a Choice's options request is in flight. Tracked. + /// @return `true` while fetching. + [[nodiscard]] bool optionsPending() const { return _optionsQuery != nullptr && _optionsQuery->pending(); } +``` + +3. In `start()`, after the `_engage` block, add: + +```cpp + if (_spec->kind == FieldKind::Choice && _spec->choice.has_value() && _core->choices()) { + reactive::Runtime& runtime = _core->runtime(); + _optionsQuery = std::make_unique>( + runtime, + [this](std::string const& request) { + _issued = request; + return _core->choices()(_spec->choice->optionsAction, request); + }, + [this] { return optionsRequest(); }); + _fetched = std::make_unique>>(runtime, [this] { + std::optional const& reply = _optionsQuery->value(); + return reply.has_value() ? parseOptions(*reply, *_spec->choice) : std::vector{}; + }); + _clearStale = std::make_unique(runtime, [this] { clearStaleSelection(); }); + } +``` + +4. Add to `FieldState`'s private section, after `resolveSlot`: + +```cpp + [[nodiscard]] FieldState* sibling(std::string_view name) const { + if (_siblings == nullptr) { + return nullptr; + } + auto const found = + std::ranges::find_if(*_siblings, [&](auto const& entry) { return entry->spec().name == name; }); + return found == _siblings->end() ? nullptr : found->get(); + } + + /// The options request for the current parents, or nullopt while one does not encode. + [[nodiscard]] std::optional optionsRequest() const { + std::string body = "{"; + bool first = true; + for (std::string const& parent : _spec->choice->dependsOn) { + FieldState const* const source = sibling(parent); + if (source == nullptr) { + return std::nullopt; + } + Encoding const& encoding = source->encoded().get(); + if (!encoding.has_value() || !encoding->has_value()) { + return std::nullopt; + } + body += (first ? "" : ",") + detail::engine::quote(parent) + ":" + (*encoding)->json; + first = false; + } + return body + "}"; + } + + /// Clears a selection the options no longer back — once the query has answered for the + /// current parents, so a value set before its options arrive survives the fetch. + void clearStaleSelection() { + if (_optionsQuery->pending() || _optionsQuery->error() != nullptr) { + return; + } + std::optional const request = optionsRequest(); + if (request.has_value() && request != _issued) { + return; + } + std::vector const& current = _fetched->get(); + std::string_view const selected = detail::engine::trim(_text.get()); + if (selected.empty() || + std::ranges::any_of(current, [&](ChoiceOption const& option) { return option.valueJson == selected; })) { + return; + } + _core->runtime().batch([&] { + _text.set(std::string{}); + _core->markProgrammatic(); + }); + } +``` + +5. In `FieldState`'s members: add `std::vector> const* _siblings;` after `_path`, + `std::optional _issued;` after `_nextRowKey`, and directly before `_engage`: + +```cpp + std::unique_ptr> _optionsQuery; + std::unique_ptr>> _fetched; + std::unique_ptr _clearStale; +``` + +`_clearStale` reads `_fetched` and `_optionsQuery`, which are declared before it, so it is destroyed first. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine]"` +Expected: PASS — the 8 new `[choice]` cases and every earlier `[forms-engine]` case. + +Mutation check: in `clearStaleSelection`, change the first guard to `if (_optionsQuery->error() != nullptr)` (drop +the `pending()` test). Expected: FAIL in "a reply for a superseded parent value is dropped, and a prefilled selection +survives its refetch" — while the prefill's refetch is in flight the query still holds the previous options +(`[New]`), which do not contain `10`, so the selection is cleared. Restore it. The `request != _issued` guard +covers the other order — a flush that reaches the clearing Effect before the query's own has issued for new parents; +`Query`'s Effect subscribes first, so no test can force that order today, and the guard is kept for the case where +a later change reorders the subscriptions. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/form_session.hpp tests/test_forms_engine_choice.cpp tests/CMakeLists.txt +git commit -m "wip(forms): Choice options as a Query keyed on the parents, stale selections cleared + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 9: The shared corpora through the engine + +The rule corpus, the instance-bounds corpus and the conformance fixtures already pin the schema contract against +the compiled evaluators. Here each one also drives a real `FormSession`, so the engine is held to the same rows. The +two data files move to `tests/data/`, where the engine's reader and the QML suite both read them; the QML copies +stay in place, unread, until the QML renderer is retired. + +**Files:** +- Create: `tests/data/rule_corpus.json` (a copy of `src/qt/forms/tests/data/rule_corpus.json`, `readme` updated) +- Create: `tests/data/instance_bounds.json` (a copy of `src/qt/forms/tests/data/instance_bounds.json`, `readme` + updated) +- Modify: `tests/CMakeLists.txt` — the two `MORPH_FORMS_*` compile definitions and the comment above them +- Modify: `src/qt/forms/CMakeLists.txt` — the same two definitions on `morph_forms_qml_tests` (lines 80–82) +- Modify: `tests/test_forms_rule_corpus.cpp`, `tests/forms_rule_corpus.hpp`, `tests/test_forms_instance_constraints.cpp`, + `src/qt/forms/tests/tst_DynamicFormRuleCorpus.qml`, `src/qt/forms/tests/tst_DynamicFormInstanceBounds.qml`, + `src/qt/forms/tests/tst_main.cpp` — the comments naming the data files' location and readers +- Modify: `tests/test_forms_engine_rules.cpp` — append the corpus cases +- Modify: `tests/test_forms_rule_agreement.cpp`, `tests/test_forms_instance_constraints.cpp`, + `tests/test_forms_conformance_corpus.cpp` — append the engine cases +- Test: the four files above + +**Interfaces:** +- Consumes: `morph::test::rulecorpus::{corpusActions, findAction, Draft}` (`tests/forms_rule_corpus.hpp`); + `boundsCorpus()` (anonymous namespace in `tests/test_forms_instance_constraints.cpp:503`); the `CF*` fixtures + (`tests/test_forms_conformance_corpus.cpp`); `RuleAgreementAction` (`tests/test_forms_rule_agreement.cpp:39`); + Tasks 6–8. +- Produces: nothing new in the library. + +- [ ] **Step 1: Write the failing test** + +Move the data first, so the new cases and the old readers point at one place: + +```bash +mkdir -p tests/data +cp src/qt/forms/tests/data/rule_corpus.json tests/data/rule_corpus.json +cp src/qt/forms/tests/data/instance_bounds.json tests/data/instance_bounds.json +``` + +In `tests/data/rule_corpus.json`, replace the first six `readme` lines (through `"different case lists. See …"`) +with: + +```json + "The shared x-rules corpus. ONE file, THREE readers:", + " tests/test_forms_rule_corpus.cpp -- morph::forms::allRulesSatisfied", + " tests/test_forms_engine_rules.cpp -- the forms engine's FormSession", + " src/qt/forms/tests/tst_DynamicFormRuleCorpus.qml -- a real DynamicForm", + "No reader owns the cases, so the evaluators cannot be pinned to different", + "case lists. See docs/spec/forms/forms.md, 'Two evaluators, one corpus'.", +``` + +In `tests/data/instance_bounds.json`, replace the first three `readme` lines with: + +```json + "The per-instance constraints corpus. ONE file, THREE readers:", + " tests/test_forms_instance_constraints.cpp -- InstanceConstraints::checkValue, and the forms engine", + " src/qt/forms/tests/tst_DynamicFormInstanceBounds.qml -- a real DynamicForm", +``` + +In `tests/CMakeLists.txt`, replace the comment and definitions block that starts `# The shared x-rules corpus. One +file, two readers:` with: + +```cmake +# The shared x-rules and instance-bounds corpora. Each is one file with several +# readers -- this suite (compiled evaluators and the forms engine) and the QML +# suite -- all pointed at it from CMake so none can quietly grow its own copy. +target_compile_definitions(morph_tests PRIVATE + MORPH_FORMS_RULE_CORPUS="${CMAKE_CURRENT_SOURCE_DIR}/data/rule_corpus.json" + MORPH_FORMS_INSTANCE_BOUNDS="${CMAKE_CURRENT_SOURCE_DIR}/data/instance_bounds.json") +``` + +In `src/qt/forms/CMakeLists.txt`, change the two definitions on `morph_forms_qml_tests` to +`"${PROJECT_SOURCE_DIR}/tests/data/rule_corpus.json"` and `"${PROJECT_SOURCE_DIR}/tests/data/instance_bounds.json"`, +and in the comment above them replace `this suite and tests/test_forms_rule_corpus.cpp` with +`this suite, tests/test_forms_rule_corpus.cpp and tests/test_forms_engine_rules.cpp`. + +In the comments of `tests/test_forms_rule_corpus.cpp` (lines 13–16), `tests/forms_rule_corpus.hpp` (lines 18–21), +`tests/test_forms_instance_constraints.cpp` (line 438), `src/qt/forms/tests/tst_main.cpp` (lines 24–28), +`src/qt/forms/tests/tst_DynamicFormRuleCorpus.qml` (line 12) and `src/qt/forms/tests/tst_DynamicFormInstanceBounds.qml` +(line 18), replace `src/qt/forms/tests/data/` and `data/` (as the location of the two files) with `tests/data/`, and +where a comment says "one file, two readers" name the engine's reader as the third. + +Append to `tests/test_forms_engine_rules.cpp` (add `#include `, `#include `, `#include `, +`#include "forms_engine_support.hpp"` and `#include "forms_rule_corpus.hpp"` to its includes): + +```cpp +// ──── The shared corpus, through the forms engine ────────────────────────────── + +namespace { + +using morph::test::formsengine::Harness; +using morph::test::rulecorpus::Draft; + +struct EngineRow { + std::string id{}; + std::string action{}; + Draft state{}; + bool ready = false; + std::map> visible{}; + std::map> readonly{}; +}; + +struct EngineCorpus { + std::map> schemas{}; + std::vector rows{}; +}; + +[[nodiscard]] EngineCorpus const& engineCorpus() { + static EngineCorpus const parsed = [] { + std::ifstream file{MORPH_FORMS_RULE_CORPUS}; + REQUIRE(file.is_open()); + std::ostringstream buffer; + buffer << file.rdbuf(); + auto const dom = engine::parseJson(buffer.str()); + REQUIRE(dom.has_value()); + EngineCorpus corpus{}; + for (auto const& [name, schema] : engine::member(*dom, "schemas")->get_object()) { + corpus.schemas.emplace(name, schema.get()); + } + for (auto const& entry : engine::member(*dom, "cases")->get_array()) { + EngineRow row{}; + row.id = *engine::stringAt(entry, "id"); + row.action = *engine::stringAt(entry, "action"); + row.ready = engine::member(entry, "ready")->get(); + for (auto const& [field, text] : engine::member(entry, "state")->get_object()) { + row.state.emplace(field, text.get()); + } + for (std::string_view const key : {"visible", "readonly"}) { + if (auto const* const flags = engine::member(entry, key)) { + for (auto const& [field, flag] : flags->get_object()) { + (key == "visible" ? row.visible : row.readonly).emplace(field, flag.get()); + } + } + } + corpus.rows.push_back(std::move(row)); + } + REQUIRE(corpus.rows.size() == 46); + return corpus; + }(); + return parsed; +} + +struct EngineVerdict { + bool ready = false; + std::map> visible{}; + std::map> readonly{}; +}; + +[[nodiscard]] EngineVerdict runRow(EngineRow const& row) { + Harness harness; + auto const form = harness.session("Test_" + row.action, engineCorpus().schemas.at(row.action)); + for (auto const& [name, text] : row.state) { + form->field(name).text().set(text); + } + harness.settle(); + EngineVerdict verdict{}; + verdict.ready = form->ready(); + for (auto const& [name, ignored] : row.visible) { + verdict.visible.emplace(name, form->field(name).visible().get()); + } + for (auto const& [name, ignored] : row.readonly) { + verdict.readonly.emplace(name, form->field(name).readonly().get()); + } + return verdict; +} + +} // namespace + +TEST_CASE("rule corpus: the engine reads every corpus schema", "[forms-engine][rules][corpus]") { + for (auto const& [name, schema] : engineCorpus().schemas) { + INFO(name); + CHECK(morph::forms::FormModel::fromSchema(name, schema).has_value()); + } +} + +TEST_CASE("rule corpus: every row's engine verdicts are the ones the corpus records", "[forms-engine][rules][corpus]") { + std::size_t presentation = 0; + for (auto const& row : engineCorpus().rows) { + EngineVerdict const verdict = runRow(row); + INFO("row " << row.id); + CHECK(verdict.ready == row.ready); + CHECK(verdict.visible == row.visible); + CHECK(verdict.readonly == row.readonly); + presentation += row.visible.size() + row.readonly.size(); + } + CHECK(presentation == 4); // the corpus's visibleWhen and readonlyWhen rows were all asserted +} + +TEST_CASE("rule corpus: the engine and allRulesSatisfied agree on every row", "[forms-engine][rules][corpus]") { + for (auto const& row : engineCorpus().rows) { + auto const* const typed = morph::test::rulecorpus::findAction(row.action); + REQUIRE(typed != nullptr); + INFO("row " << row.id); + CHECK(runRow(row).ready == typed->satisfied(row.state)); + } +} +``` + +Append to `tests/test_forms_rule_agreement.cpp` (add `#include "forms_engine_support.hpp"`): + +```cpp +TEST_CASE("the forms engine reaches the compiled verdicts on the emitted schema", "[forms][rules][agreement][forms-engine]") { + using morph::test::formsengine::Harness; + auto const& schema = morph::forms::schemaJson(); + { + Harness harness; + auto const form = harness.session("Test_RuleAgreement", schema); + harness.type(*form, "flag", "true"); + CHECK_FALSE(form->ready()); + harness.type(*form, "reason", "because"); + CHECK(form->ready()); + } + { + Harness harness; + auto const form = harness.session("Test_RuleAgreement", schema); + harness.type(*form, "flag", "false"); + CHECK(form->ready()); + } + { + Harness harness; + auto const form = harness.session("Test_RuleAgreement", schema); + harness.type(*form, "id", "9007199254740992"); + CHECK(form->ready()); + harness.type(*form, "id", "9007199254740993"); + CHECK_FALSE(form->ready()); + harness.type(*form, "reason", "because"); + CHECK(form->ready()); + } +} +``` + +Append to `tests/test_forms_instance_constraints.cpp` (add `#include "forms_engine_support.hpp"`): + +```cpp +TEST_CASE("Every corpus row's engine verdict is the one the corpus records", "[forms][instance-constraints][forms-engine]") { + using morph::test::formsengine::Harness; + for (auto const& row : boundsCorpus().cases) { + Harness harness; + auto const form = harness.session("Test_ICCapture", boundsCorpus().schemas.at(row.schema)); + harness.type(*form, "analysisVersionId", "7"); // every row's identity field + harness.type(*form, "value", row.value); + INFO("row " << row.id); + CHECK(form->ready() == row.ready); + } +} + +TEST_CASE("The decorated schema drives the engine's entry precision", "[forms][instance-constraints][forms-engine]") { + using morph::test::formsengine::modelOf; + CHECK(modelOf("ICCapture", boundsCorpus().schemas.at("decorated")).find("value")->decimalPlaces == 1U); + CHECK(modelOf("ICCapture", boundsCorpus().schemas.at("compiled")).find("value")->decimalPlaces == 3U); +} +``` + +Append to `tests/test_forms_conformance_corpus.cpp` (add `#include "forms_engine_support.hpp"`): + +```cpp +// The same fixtures, through the forms engine: the renderer contract the QML +// renderer's tst_conformance.qml mirrored by hand, now read from the emitted +// schemas themselves. + +TEST_CASE("Conformance corpus through the engine: x-order and the required gate", "[conformance][forms-engine]") { + morph::test::formsengine::Harness harness; + auto const form = harness.session("CFScalarsAndRequired", morph::forms::schemaJson()); + REQUIRE(form->fields().size() == 3); + CHECK(form->fields()[0]->spec().name == "count"); + CHECK(form->fields()[1]->spec().name == "label"); + CHECK(form->fields()[2]->spec().name == "note"); + CHECK_FALSE(form->ready()); + harness.type(*form, "count", "3"); + CHECK_FALSE(form->ready()); + harness.type(*form, "label", "widget-7"); + CHECK(form->body() == R"({"count":3,"label":"widget-7"})"); +} + +TEST_CASE("Conformance corpus through the engine: exact Quantity payloads and unit switching", "[conformance][forms-engine]") { + morph::test::formsengine::Harness harness; + auto const form = harness.session("CFQuantityAlternatives", morph::forms::schemaJson()); + harness.type(*form, "mass", "2650.5"); + CHECK(form->body() == R"({"mass":{"num":2650500,"den":1000,"dp":3}})"); + form->field("mass").switchUnit(1); + harness.settle(); + CHECK(form->field("mass").text().peek() == "2650500.0"); + CHECK(form->body() == R"({"mass":{"num":26505000,"den":10000,"dp":3}})"); + harness.type(*form, "mass", "2650500.01"); + CHECK_FALSE(form->ready()); // more places than grams take +} + +TEST_CASE("Conformance corpus through the engine: Choice, Timestamp and the shared $def", "[conformance][forms-engine]") { + morph::test::formsengine::Harness harness; + auto const choice = harness.session("CFChoiceField", morph::forms::schemaJson()); + auto const& spec = choice->field("widgetId").spec(); + REQUIRE(spec.choice.has_value()); + CHECK(spec.choice->optionsAction == "CFListWidgets"); + CHECK(spec.choice->valueField == "id"); + CHECK(spec.choice->labelField == "name"); + CHECK(harness.server.fetches().size() == 1); + CHECK(harness.server.fetches()[0].body == "{}"); + + auto const stamp = harness.session("CFTimestampField", morph::forms::schemaJson()); + CHECK(stamp->field("when").spec().kind == morph::forms::FieldKind::DateTime); + CHECK_FALSE(stamp->ready()); + harness.type(*stamp, "when", "2026-07-20T09:00"); + CHECK(stamp->body() == R"({"when":"2026-07-20T09:00:00Z"})"); + + auto const shared = harness.session("CFSharedDefFields", morph::forms::schemaJson()); + CHECK(shared->field("massA").spec().unit == "kg"); + CHECK(shared->field("massB").spec().unit == "kg"); + CHECK(shared->field("massA").spec().decimalPlaces == 3U); + CHECK(shared->field("massB").spec().decimalPlaces == 3U); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake -S . -B build/reactive && cmake --build build/reactive --target morph_tests && +./build/reactive/tests/morph_tests "[forms-engine],[corpus],[instance-constraints],[conformance],[agreement]"` +Expected: every pre-existing case passes against the moved files; among the new cases, the first failure is the one +the engine gets wrong — if all pass at once, that is the expected outcome for a correct Tasks 3–8, and Step 4's +mutation check is the evidence these cases measure something. + +- [ ] **Step 3: Implement** + +Nothing in the library: the cases exercise Tasks 3–8. Fix any engine defect the corpus rows expose in the header +it belongs to, and say which row exposed it in the commit body. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run the Step 2 command. Expected: PASS. + +Mutation check: in `rules.hpp`'s `RuleExpr::membership`, change the `AtLeastOneOf` arm's `if (definite >= 1)` to +`if (definite >= 2)`. Expected: FAIL in "rule corpus: every row's engine verdicts…" for `atLeastOneOf/one`, and in +"the engine and allRulesSatisfied agree on every row" for the same row. Restore it. + +Also confirm the QML suite still reads the moved files (on a `build/all` with `MORPH_BUILD_FORMS_QML=ON`): +`ctest --test-dir build/all -R forms_qml_logic --output-on-failure` — expected PASS, and its log names no +`unreadable or empty` failure. + +- [ ] **Step 5: Commit** + +```bash +git add tests/data tests/CMakeLists.txt src/qt/forms/CMakeLists.txt tests/test_forms_rule_corpus.cpp \ + tests/forms_rule_corpus.hpp tests/test_forms_instance_constraints.cpp src/qt/forms/tests/tst_main.cpp \ + src/qt/forms/tests/tst_DynamicFormRuleCorpus.qml src/qt/forms/tests/tst_DynamicFormInstanceBounds.qml \ + tests/test_forms_engine_rules.cpp tests/test_forms_rule_agreement.cpp tests/test_forms_conformance_corpus.cpp +git commit -m "wip(forms): the rule, instance-bounds and conformance corpora through the engine + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 10: `bridgeSubmitter` and `bridgeChoiceFetcher` + +The spec has these execute "with `Bridge::execute` (the bridge's JSON entry point)". `Bridge` has no JSON entry +point: the type-erased path is `BridgeHandler::executeJson` +(`include/morph/core/bridge.hpp:2312`), and `ActionExecuteRegistry` keys its executors by +`(modelId, actionId, sharing)` with no way to ask which model serves an action. So the registry learns two things +at registration — the models serving each action id, and how to make a handler of each model — and the router +behind both factories keeps one `BridgeHandler` per model it has routed to. + +**Files:** +- Modify: `include/morph/core/bridge.hpp` — `class Bridge;` forward declaration above `ActionExecuteRegistry`; + `#include `; `ActionExecuteRegistry::HandlerFactory`, `modelsServing`, `makeHandler`, two private maps; and + the end of the out-of-line `ActionExecuteRegistry::registerAction` (after the two `_executors[…]` assignments) +- Modify: `docs/spec/core/bridge.md` — the `ActionExecuteRegistry` section and its API table +- Create: `include/morph/forms/engine/bridge_submitter.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/forms/engine/form_session.hpp`, add + `include/morph/forms/engine/bridge_submitter.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_bridge.cpp` after `test_forms_engine_choice.cpp` +- Test: `tests/test_forms_engine_bridge.cpp` + +**Interfaces:** +- Consumes: `ActionExecuteRegistry::instance()`, `execute(modelId, actionId, void*, bodyJson)`, + `BridgeHandler(Bridge&, IExecutor*)` (`include/morph/core/bridge.hpp:61`, `:100`, `:2137`); + `async::Completion::makeSettleable`; Task 6's `Submitter`, `ChoiceFetcher`, `FormSession`. +- Produces: the contract's `forms::bridgeSubmitter(bridge::Bridge&, exec::IExecutor&) -> Submitter` and + `forms::bridgeChoiceFetcher(bridge::Bridge&, exec::IExecutor&) -> ChoiceFetcher`; in `morph::bridge`, the + additions `ActionExecuteRegistry::HandlerFactory`, `modelsServing(std::string_view actionId) const -> + std::vector` and `makeHandler(std::string_view modelId, Bridge&, exec::IExecutor* guiExec) const -> + std::shared_ptr`; `forms::detail::engine::BridgeRouter`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_bridge.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "forms_engine_support.hpp" +#include "test_support.hpp" + +// File-scope, not anonymous-namespaced: glaze's reflection needs linkage. +// NOLINTBEGIN(misc-use-internal-linkage) +struct FeSum { + int a = 0; + int b = 0; +}; +struct FeSumResult { + int sum = 0; +}; +struct FeColour { + std::int64_t id = 0; + std::string name{}; +}; +struct FeListColours {}; +struct FeColours { + std::vector rows{}; +}; +// Two action types registered under one id by two models: the ambiguity the router refuses. +struct FeTwinA {}; +struct FeTwinB {}; +struct FeTwinResult { + int from = 0; +}; +struct FeMathModel { + FeSumResult execute(FeSum const& action) { return FeSumResult{.sum = action.a + action.b}; } + FeColours execute(FeListColours const&) { + return FeColours{.rows = {FeColour{.id = 9007199254740993, .name = "Ochre"}, FeColour{.id = 2, .name = "Teal"}}}; + } + FeTwinResult execute(FeTwinA const&) { return FeTwinResult{.from = 1}; } +}; +struct FeOtherModel { + FeTwinResult execute(FeTwinB const&) { return FeTwinResult{.from = 2}; } +}; +// NOLINTEND(misc-use-internal-linkage) + +BRIDGE_REGISTER_MODEL(FeMathModel, "Test_FormsEngine_Math") +BRIDGE_REGISTER_ACTION(FeMathModel, FeSum, "Test_FormsEngine_Sum") +BRIDGE_REGISTER_ACTION(FeMathModel, FeListColours, "Test_FormsEngine_ListColours") +BRIDGE_REGISTER_ACTION(FeMathModel, FeTwinA, "Test_FormsEngine_Twin") +BRIDGE_REGISTER_MODEL(FeOtherModel, "Test_FormsEngine_Other") +BRIDGE_REGISTER_ACTION(FeOtherModel, FeTwinB, "Test_FormsEngine_Twin") + +namespace { + +/// A LocalBackend bridge whose owner drives a reactive runtime. +struct LocalStack { + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor owner{}; + morph::bridge::Bridge bridge{std::make_unique(pool), owner}; + morph::reactive::Runtime rt{owner}; +}; + +[[nodiscard]] std::optional await(LocalStack& stack, morph::async::Completion completion, + std::exception_ptr* error = nullptr) { + std::optional reply; + bool done = false; + completion + .then([&](std::string const& json) { + reply = json; + done = true; + }) + .onError([&](std::exception_ptr failure) { + if (error != nullptr) { + *error = std::move(failure); + } + done = true; + }); + REQUIRE(morph::testing::pumpOwnerUntil(stack.owner, [&] { return done; })); + return reply; +} + +} // namespace + +TEST_CASE("bridgeSubmitter routes an action type to the model that registers it", "[forms-engine][bridge]") { + LocalStack stack; + auto const submit = morph::forms::bridgeSubmitter(stack.bridge, stack.owner); + CHECK(await(stack, submit("Test_FormsEngine_Sum", R"({"a":3,"b":4})")) == R"({"sum":7})"); + CHECK(await(stack, submit("Test_FormsEngine_Sum", R"({"a":1,"b":1})")) == R"({"sum":2})"); +} + +TEST_CASE("bridgeSubmitter fails an action no model serves, and one two models serve", "[forms-engine][bridge]") { + LocalStack stack; + auto const submit = morph::forms::bridgeSubmitter(stack.bridge, stack.owner); + std::exception_ptr unknown; + CHECK_FALSE(await(stack, submit("Test_FormsEngine_Nope", "{}"), &unknown).has_value()); + CHECK(morph::reactive::errorMessage(unknown) == "no registered model serves action 'Test_FormsEngine_Nope'"); + std::exception_ptr ambiguous; + CHECK_FALSE(await(stack, submit("Test_FormsEngine_Twin", "{}"), &ambiguous).has_value()); + CHECK(morph::reactive::errorMessage(ambiguous) == + "action 'Test_FormsEngine_Twin' is served by more than one model: Test_FormsEngine_Math, Test_FormsEngine_Other"); + auto const& registry = morph::bridge::ActionExecuteRegistry::instance(); + CHECK(registry.modelsServing("Test_FormsEngine_Sum") == std::vector{"Test_FormsEngine_Math"}); + CHECK(registry.modelsServing("Test_FormsEngine_Nope").empty()); +} + +TEST_CASE("bridgeChoiceFetcher returns the options action's reply, ids exact", "[forms-engine][bridge]") { + LocalStack stack; + auto const fetch = morph::forms::bridgeChoiceFetcher(stack.bridge, stack.owner); + CHECK(await(stack, fetch("Test_FormsEngine_ListColours", "{}")) == + R"({"rows":[{"id":9007199254740993,"name":"Ochre"},{"id":2,"name":"Teal"}]})"); +} + +TEST_CASE("a FormSession over a real bridge fetches its options and submits its body", "[forms-engine][bridge]") { + LocalStack stack; + morph::forms::FormSession form{ + stack.rt, + morph::test::formsengine::modelOf( + "Test_FormsEngine_Sum", + R"({"properties":{"a":{"type":"integer","x-order":0},"b":{"type":"integer","x-order":1}, + "colour":{"type":["integer","null"],"x-order":2,"x-optionsAction":"Test_FormsEngine_ListColours"}},"required":["a","b"]})"), + morph::forms::bridgeSubmitter(stack.bridge, stack.owner), morph::forms::bridgeChoiceFetcher(stack.bridge, stack.owner)}; + REQUIRE(morph::testing::pumpOwnerUntil(stack.owner, [&] { return form.field("colour").options().size() == 2; })); + CHECK(form.field("colour").options().front().valueJson == "9007199254740993"); + form.field("a").text().set("3"); + form.field("b").text().set("4"); + REQUIRE(morph::testing::pumpOwnerUntil(stack.owner, [&] { return form.lastReply().has_value(); })); + CHECK(form.lastReply() == R"({"sum":7})"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: `fatal error: 'morph/forms/engine/bridge_submitter.hpp' file not found`. + +- [ ] **Step 3: Implement** + +In `include/morph/core/bridge.hpp`, add `#include ` to the standard includes, and directly above +`class ActionExecuteRegistry {` add: + +```cpp +class Bridge; +``` + +In `ActionExecuteRegistry`'s public section, after `contains`, add: + +```cpp + /// @brief Makes a handler of one registered model on a bridge, type-erased so that + /// `execute` accepts it. + using HandlerFactory = std::function(Bridge&, ::morph::exec::IExecutor*)>; + + /// @brief The model ids that register an executor for @p actionId, in registration order. + /// + /// The routing question a schema-driven client asks: it knows an action type from a schema + /// and needs the model whose handler executes it. Two models may register one id; the caller + /// decides what that means. + /// @param actionId The action type id. + /// @return The model ids; empty when no model registers @p actionId. + [[nodiscard]] std::vector modelsServing(std::string_view actionId) const { + ::morph::model::detail::noteRegistryRead(this == &instance()); + auto const found = _modelsByAction.find(actionId); + return found == _modelsByAction.end() ? std::vector{} : found->second; + } + + /// @brief Makes a `BridgeHandler` of the model registered as @p modelId. + /// @param modelId The model id. + /// @param bridge The bridge the handler registers on. Borrowed: it must outlive the handler. + /// @param guiExec The executor the handler delivers completions on (see `BridgeHandler`). + /// @return The handler, owning; pass `.get()` to `execute`. + /// @throws std::runtime_error when no model is registered as @p modelId. + [[nodiscard]] std::shared_ptr makeHandler(std::string_view modelId, Bridge& bridge, + ::morph::exec::IExecutor* guiExec) const { + ::morph::model::detail::noteRegistryRead(this == &instance()); + auto const found = _handlerFactories.find(modelId); + if (found == _handlerFactories.end()) { + throw std::runtime_error("unknown model for makeHandler: " + std::string{modelId}); + } + return found->second(bridge, guiExec); + } +``` + +and in its private section, after `_executors`: + +```cpp + // Action id -> the model ids registering it, in registration order. + std::map, std::less<>> _modelsByAction; + // Model id -> how to make a NoSharing handler of it. + std::map> _handlerFactories; +``` + +At the end of the out-of-line `ActionExecuteRegistry::registerAction` (after the `AllowShared` executor is filed), +add: + +```cpp + auto& models = _modelsByAction.try_emplace(std::string{actionId}).first->second; + if (std::ranges::find(models, modelId) == models.end()) { + models.emplace_back(modelId); + } + _handlerFactories.try_emplace(std::string{modelId}, [](Bridge& bridge, ::morph::exec::IExecutor* guiExec) { + return std::shared_ptr{std::make_shared>(bridge, guiExec)}; + }); +``` + +Create `include/morph/forms/engine/bridge_submitter.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/bridge_submitter.hpp +/// @brief `bridgeSubmitter` and `bridgeChoiceFetcher`: the forms engine's production +/// `Submitter` and `ChoiceFetcher`, routing an action type to the model that registers it. +/// +/// Specified in `docs/spec/forms/engine.md`, "Submitters". + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../attributes.hpp" +#include "../../core/bridge.hpp" +#include "../../core/completion.hpp" +#include "../../core/executor.hpp" +#include "form_session.hpp" + +namespace morph::forms { + +namespace detail::engine { + +/// @brief Executes JSON bodies by action type: finds the model registering the type and keeps one +/// handler per model it has routed to. +class BridgeRouter { +public: + /// @param bridge The bridge. Borrowed: it must outlive the router. + /// @param callbacks The executor completions deliver on — the reactive runtime's owner. Borrowed. + BridgeRouter(bridge::Bridge& bridge MORPH_LIFETIMEBOUND, exec::IExecutor& callbacks MORPH_LIFETIMEBOUND) + : _bridge{&bridge}, _callbacks{&callbacks} {} + + /// @brief Executes @p bodyJson as @p actionType. + /// @param actionType The action type id. + /// @param bodyJson The JSON body. + /// @return The reply; a failed completion when no model, or more than one, registers the type. + [[nodiscard]] async::Completion execute(std::string_view actionType, std::string const& bodyJson) { + auto& registry = bridge::ActionExecuteRegistry::instance(); + std::vector const models = registry.modelsServing(actionType); + if (models.empty()) { + return failed("no registered model serves action '" + std::string{actionType} + "'"); + } + if (models.size() > 1) { + std::string names; + for (std::string const& model : models) { + names += (names.empty() ? "" : ", ") + model; + } + return failed("action '" + std::string{actionType} + "' is served by more than one model: " + names); + } + std::shared_ptr& handler = _handlers.try_emplace(models.front()).first->second; + if (!handler) { + handler = registry.makeHandler(models.front(), *_bridge, _callbacks); + } + return registry.execute(models.front(), actionType, handler.get(), bodyJson); + } + +private: + [[nodiscard]] async::Completion failed(std::string const& message) const { + auto [completion, promise] = async::Completion::makeSettleable(_callbacks); + promise.reject(std::make_exception_ptr(std::runtime_error{message})); + return std::move(completion); + } + + bridge::Bridge* _bridge; + exec::IExecutor* _callbacks; + std::map, std::less<>> _handlers; +}; + +} // namespace detail::engine + +/// @brief The production `Submitter`: executes a form's action through @p bridge. +/// +/// The action type selects the model registering it (`ActionExecuteRegistry::modelsServing`); +/// the body goes through that model's handler, so validation, journaling and backends behave as +/// for any typed call. Handlers are made on first use and kept while any copy of the returned +/// function lives; destroy those copies (the sessions holding them) on the owner, before the bridge. +/// @param bridge The bridge. Borrowed: it must outlive every copy of the result. +/// @param callbacks The executor replies deliver on — the reactive runtime's owner. Borrowed. +/// @return The submitter. +[[nodiscard]] inline Submitter bridgeSubmitter(bridge::Bridge& bridge, exec::IExecutor& callbacks) { + auto router = std::make_shared(bridge, callbacks); + return [router](std::string_view actionType, std::string bodyJson) { return router->execute(actionType, bodyJson); }; +} + +/// @brief The production `ChoiceFetcher`: executes an options action through @p bridge, routed +/// like `bridgeSubmitter`. +/// @param bridge The bridge. Borrowed: it must outlive every copy of the result. +/// @param callbacks The executor replies deliver on — the reactive runtime's owner. Borrowed. +/// @return The fetcher. +[[nodiscard]] inline ChoiceFetcher bridgeChoiceFetcher(bridge::Bridge& bridge, exec::IExecutor& callbacks) { + auto router = std::make_shared(bridge, callbacks); + return [router](std::string_view optionsAction, std::string bodyJson) { + return router->execute(optionsAction, bodyJson); + }; +} + +} // namespace morph::forms +``` + +In `docs/spec/core/bridge.md`, at the end of the `## ActionExecuteRegistry` section (before `### Why the key carries +the sharing policy`), add: + +```markdown +**Routing by action id.** `registerAction` also records, per action id, the model ids that register it +(`modelsServing`), and per model id a factory for a `BridgeHandler` (`makeHandler`). Together +they answer what a schema-driven client asks — it has an action type from a schema and needs the handler that +executes it — without the client naming a model type. `morph::forms::bridgeSubmitter` is that client: it refuses +an id no model serves, and one two models serve, rather than guessing. +``` + +and to the `### ActionExecuteRegistry` API table add: + +```markdown +| `modelsServing` | `std::vector modelsServing(string_view actionId) const` | Model ids registering `actionId`, in registration order; empty when none. | +| `makeHandler` | `std::shared_ptr makeHandler(string_view modelId, Bridge&, exec::IExecutor* guiExec) const` | A `BridgeHandler` of the model registered as `modelId`, type-erased for `execute`. Throws `runtime_error` for an unknown id. | +``` + +Register the header and the test as listed under **Files**. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][bridge],[execute-json]"` +Expected: PASS — 4 new cases and the existing `[execute-json]` cases. + +Mutation check: in `BridgeRouter::execute`, delete the `if (models.size() > 1) { … }` block. Expected: FAIL in +"bridgeSubmitter fails an action no model serves, and one two models serve" (the Twin call is routed to the first +model and succeeds). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/core/bridge.hpp docs/spec/core/bridge.md include/morph/forms/engine/bridge_submitter.hpp \ + tests/test_forms_engine_bridge.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): bridgeSubmitter and bridgeChoiceFetcher, routed through the action registry + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 10b: `handlerSubmitter` and `handlerChoiceFetcher` — forms on the app's own handlers + +`bridgeSubmitter` keeps a handler of its own per model, which is right for models whose state lives in a database. +But under `LocalBackend` every `BridgeHandler` of a non-shared model is its **own model instance**, so a form whose +submissions must land in the instance the rest of the screen reads (the forms demo's in-memory `LabModel`), or on a +shared instance another handler attached (polls' `AllowShared` `PollModel`), must submit through **that** handler. +These two factories route each action type to the first of the given handlers that serves it, through +`BridgeHandler::executeJson` (`include/morph/core/bridge.hpp:2312`) and `servesAction` (`:2330`). + +**Files:** +- Create: `include/morph/forms/engine/handler_submitter.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/forms/engine/bridge_submitter.hpp`, add + `include/morph/forms/engine/handler_submitter.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_handler_submitter.cpp` after `test_forms_engine_bridge.cpp` +- Test: `tests/test_forms_engine_handler_submitter.cpp` + +**Interfaces:** +- Consumes: `BridgeHandler::executeJson(std::string_view, std::string_view)`, `servesAction(std::string_view)`; + `async::Completion::makeSettleable`; Task 6's `Submitter`, `ChoiceFetcher`. +- Produces (added to the interface contract's Part 5): + `template Submitter forms::handlerSubmitter(exec::IExecutor& callbacks, Handlers&... handlers)` + and `template ChoiceFetcher forms::handlerChoiceFetcher(exec::IExecutor& callbacks, + Handlers&... handlers)` — each `Handlers` a `bridge::BridgeHandler`; the handlers must outlive the returned + function; an action no handler serves resolves the completion with `std::invalid_argument`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_handler_submitter.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// File-scope, not anonymous-namespaced: glaze's reflection needs linkage. +// NOLINTBEGIN(misc-use-internal-linkage) +struct HsBump { + int by = 0; +}; +struct HsRead {}; +struct HsCount { + int value = 0; +}; +struct HsEcho { + std::string text; +}; + +// In-memory state: each BridgeHandler is its own instance under LocalBackend. +struct HsCounterModel { + int count = 0; + HsCount execute(HsBump action) { + count += action.by; + return HsCount{count}; + } + HsCount execute(HsRead) const { return HsCount{count}; } +}; + +struct HsEchoModel { + HsEcho execute(HsEcho action) { return action; } +}; +// NOLINTEND(misc-use-internal-linkage) + +BRIDGE_REGISTER_MODEL(HsCounterModel, "Test_HsCounterModel") +BRIDGE_REGISTER_ACTION(HsCounterModel, HsBump, "Test_HsBump") +BRIDGE_REGISTER_ACTION(HsCounterModel, HsRead, "Test_HsRead") +BRIDGE_REGISTER_MODEL(HsEchoModel, "Test_HsEchoModel") +BRIDGE_REGISTER_ACTION(HsEchoModel, HsEcho, "Test_HsEcho") + +namespace { + +struct Wiring { + morph::exec::ThreadPoolExecutor pool{1}; + morph::exec::MainThreadExecutor owner; + morph::bridge::Bridge bridge{std::make_unique(pool), owner}; + morph::bridge::BridgeHandler counter{bridge, &owner}; + morph::bridge::BridgeHandler echo{bridge, &owner}; +}; + +struct Outcome { + std::optional reply; + std::exception_ptr error; +}; + +Outcome settle(morph::exec::MainThreadExecutor& owner, morph::async::Completion completion) { + auto outcome = std::make_shared(); + auto done = std::make_shared>(false); + completion + .then([outcome, done](std::string const& reply) { + outcome->reply = reply; + done->store(true); + }) + .onError([outcome, done](std::exception_ptr const& error) { + outcome->error = error; + done->store(true); + }); + for (int step = 0; step < 400 && !done->load(); ++step) { + owner.runFor(std::chrono::milliseconds{5}); + } + REQUIRE(done->load()); + return *outcome; +} + +} // namespace + +TEST_CASE("forms::handlerSubmitter: submissions land in the given handler's instance", "[forms-engine][handler]") { + Wiring wiring; + auto const submit = morph::forms::handlerSubmitter(wiring.owner, wiring.counter, wiring.echo); + CHECK(settle(wiring.owner, submit("Test_HsBump", R"({"by":2})")).reply == std::optional{R"({"value":2})"}); + CHECK(settle(wiring.owner, submit("Test_HsBump", R"({"by":3})")).reply == std::optional{R"({"value":5})"}); + // The typed path on the same handler reads the same instance. + CHECK(settle(wiring.owner, wiring.counter.executeJson("Test_HsRead", "{}")).reply == + std::optional{R"({"value":5})"}); +} + +TEST_CASE("forms::handlerSubmitter: each action routes to the handler that serves it", "[forms-engine][handler]") { + Wiring wiring; + auto const submit = morph::forms::handlerSubmitter(wiring.owner, wiring.counter, wiring.echo); + CHECK(settle(wiring.owner, submit("Test_HsEcho", R"({"text":"hi"})")).reply == + std::optional{R"({"text":"hi"})"}); +} + +TEST_CASE("forms::handlerSubmitter: an action no handler serves is a failed completion", "[forms-engine][handler]") { + Wiring wiring; + auto const submit = morph::forms::handlerSubmitter(wiring.owner, wiring.echo); + auto const outcome = settle(wiring.owner, submit("Test_HsBump", R"({"by":1})")); + REQUIRE(outcome.error != nullptr); + CHECK_THROWS_AS(std::rethrow_exception(outcome.error), std::invalid_argument); +} + +TEST_CASE("forms::handlerChoiceFetcher: fetches options through the given handler", "[forms-engine][handler]") { + Wiring wiring; + auto const fetch = morph::forms::handlerChoiceFetcher(wiring.owner, wiring.counter); + CHECK(settle(wiring.owner, fetch("Test_HsRead", "{}")).reply == std::optional{R"({"value":0})"}); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: FAIL — `'morph/forms/engine/handler_submitter.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `include/morph/forms/engine/handler_submitter.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/bridge.hpp" +#include "../../core/completion.hpp" +#include "../../core/executor.hpp" +#include "form_session.hpp" + +/// @file +/// @brief Submitters and choice fetchers bound to an application's own `BridgeHandler`s. +/// +/// Specified in `docs/spec/forms/engine.md`, "Submitters". + +namespace morph::forms { + +namespace detail::engine { + +/// @brief Executes an action through one handler, or declines when the handler does not serve it. +using JsonRoute = + std::function>(std::string_view actionType, std::string const& body)>; + +/// @brief The route through @p handler. +/// @tparam Model The handler's model. +/// @tparam Sharing The handler's sharing policy. +/// @param handler The handler; it must outlive the route. +/// @return A route that serves exactly the actions @p handler's model registers. +template +[[nodiscard]] JsonRoute routeThrough(bridge::BridgeHandler& handler) { + return [&handler](std::string_view actionType, + std::string const& body) -> std::optional> { + if (!handler.servesAction(actionType)) { + return std::nullopt; + } + return handler.executeJson(actionType, body); + }; +} + +/// @brief Runs @p body through the first route that serves @p actionType. +/// @param routes The routes, in priority order. +/// @param callbacks Where a refusal is delivered. +/// @param actionType The registered action id. +/// @param body The JSON body. +/// @return The route's completion, or one failed with `std::invalid_argument` when no route serves the action. +[[nodiscard]] inline async::Completion routeFirst(std::vector const& routes, + exec::IExecutor& callbacks, std::string_view actionType, + std::string const& body) { + for (JsonRoute const& route : routes) { + if (std::optional> completion = route(actionType, body)) { + return std::move(*completion); + } + } + auto [failed, promise] = async::Completion::makeSettleable(&callbacks); + promise.reject(std::make_exception_ptr( + std::invalid_argument{"no handler serves the action \"" + std::string{actionType} + "\""})); + return std::move(failed); +} + +} // namespace detail::engine + +/// @brief A `Submitter` that sends each action through the first of @p handlers whose model registers it. +/// +/// Use it when a form's submissions must reach the model instance the rest of the screen uses — an in-memory +/// model, or a shared instance another handler attached. `bridgeSubmitter` keeps handlers of its own instead. +/// @tparam Handlers `bridge::BridgeHandler` types. +/// @param callbacks The executor a refusal is delivered on: the handlers' GUI executor. +/// @param handlers The handlers, in priority order; they must outlive the returned submitter. +/// @return The submitter. +template + requires(sizeof...(Handlers) > 0) +[[nodiscard]] Submitter handlerSubmitter(exec::IExecutor& callbacks, Handlers&... handlers) { + std::vector routes{detail::engine::routeThrough(handlers)...}; + return [routes = std::move(routes), &callbacks](std::string_view actionType, std::string bodyJson) { + return detail::engine::routeFirst(routes, callbacks, actionType, bodyJson); + }; +} + +/// @brief A `ChoiceFetcher` that runs each options action through the first of @p handlers that serves it. +/// @tparam Handlers `bridge::BridgeHandler` types. +/// @param callbacks The executor a refusal is delivered on: the handlers' GUI executor. +/// @param handlers The handlers, in priority order; they must outlive the returned fetcher. +/// @return The fetcher. +template + requires(sizeof...(Handlers) > 0) +[[nodiscard]] ChoiceFetcher handlerChoiceFetcher(exec::IExecutor& callbacks, Handlers&... handlers) { + std::vector routes{detail::engine::routeThrough(handlers)...}; + return [routes = std::move(routes), &callbacks](std::string_view optionsAction, std::string bodyJson) { + return detail::engine::routeFirst(routes, callbacks, optionsAction, bodyJson); + }; +} + +} // namespace morph::forms +``` + +Task 16 writes the "Submitting through the application's handlers" paragraph of `docs/spec/forms/engine.md` +("Submitters"): when to choose `handlerSubmitter` over `bridgeSubmitter`, the priority order, and the +`std::invalid_argument` refusal. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][handler]"` +Expected: PASS. Mutation check: in `handlerSubmitter`, build the routes from a fresh +`bridge::BridgeHandler` instead of the given one (or, generally, drop `servesAction`'s check so the +first handler takes every action) — expected FAIL in "submissions land in the given handler's instance" (a second +instance reports `{"value":2}` then `{"value":3}`) or in "each action routes to the handler that serves it". +Restore. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/handler_submitter.hpp tests/test_forms_engine_handler_submitter.cpp \ + tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): handlerSubmitter and handlerChoiceFetcher, bound to the app's own handlers + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 11: `Form` — the typed facade + +`Form` builds its model with `FormModel::forAction()` (Task 4) — `fromSchema` over `schemaJson()`, the +reader a runtime form uses — so a typed and a runtime form of one action are the same model with the same rules. What it adds is typing at the edges: `set<>` +takes the member's own type, `value()` decodes the body into `A`, `ActionValidator` (the action's `validate()`) +joins readiness, and a submission goes through `BridgeHandler::execute` so `lastResult()` is the action's typed +result. + +**Files:** +- Create: `include/morph/forms/engine/typed_form.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/forms/engine/handler_submitter.hpp`, add + `include/morph/forms/engine/typed_form.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_typed.cpp` after `test_forms_engine_handler_submitter.cpp` +- Test: `tests/test_forms_engine_typed.cpp` + +**Interfaces:** +- Consumes: Task 4's `FormModel::forAction()`; `detail::memberWireName()` + (`:629`); `bridge::detail::MemberPointerTraits` (`include/morph/core/bridge.hpp:258`); `model::ActionTraits` + (`typeId`, `fromJson`, `resultToJson`, `Result`), `model::ActionValidator::ready` + (`include/morph/core/registry.hpp:255`); `BridgeHandler::execute`; `async::CallbackScope`; Task 6's + `FormSession`, `FormSessionOptions::accepts`, `FormSession::assign`. +- Produces: the contract's `forms::Form` — `Form(reactive::Runtime&, + bridge::BridgeHandler&, ChoiceFetcher, FormSessionOptions = {})`, `session()`, + `template void set(MemberValue)`, `value() -> std::optional`, `ready()`, + `lastResult() -> std::optional const&` — plus `pending()`, `error()` and the alias + `Form::MemberValue`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_typed.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +// NOLINTBEGIN(misc-use-internal-linkage) +struct FtSum { + int a = 0; + int b = 0; + std::optional note{}; + + // The action's own rule, which the schema cannot state: the form must not submit past it. + [[nodiscard]] bool validate() const { return a < 100; } +}; +struct FtSumResult { + int sum = 0; +}; +struct FtModel { + FtSumResult execute(FtSum const& action) { return FtSumResult{.sum = action.a + action.b}; } +}; +// NOLINTEND(misc-use-internal-linkage) + +BRIDGE_REGISTER_MODEL(FtModel, "Test_FormsEngineTyped_Model") +BRIDGE_REGISTER_ACTION(FtModel, FtSum, "Test_FormsEngineTyped_Sum") + +namespace { + +struct TypedStack { + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor owner{}; + morph::bridge::Bridge bridge{std::make_unique(pool), owner}; + morph::reactive::Runtime rt{owner}; + morph::bridge::BridgeHandler handler{bridge, &owner}; +}; + +} // namespace + +TEST_CASE("Form: the typed model is the runtime model of the same schema", "[forms-engine][typed]") { + TypedStack stack; + morph::forms::Form form{stack.rt, stack.handler, morph::forms::ChoiceFetcher{}}; + auto const runtime = morph::forms::FormModel::fromSchema("Test_FormsEngineTyped_Sum", morph::forms::schemaJson()); + REQUIRE(runtime.has_value()); + auto const& typed = form.session().model(); + CHECK(typed.actionType() == "Test_FormsEngineTyped_Sum"); + REQUIRE(typed.fields().size() == runtime->fields().size()); + for (std::size_t i = 0; i < typed.fields().size(); ++i) { + CHECK(typed.fields()[i].name == runtime->fields()[i].name); + CHECK(typed.fields()[i].kind == runtime->fields()[i].kind); + CHECK(typed.fields()[i].required == runtime->fields()[i].required); + } +} + +TEST_CASE("Form: set<> is a typed, programmatic change; the next edit submits through the handler", + "[forms-engine][typed]") { + TypedStack stack; + morph::forms::Form form{stack.rt, stack.handler, morph::forms::ChoiceFetcher{}}; + form.set<&FtSum::a>(3); + form.set<&FtSum::b>(4); + REQUIRE(form.ready()); + CHECK(form.session().body() == R"({"a":3,"b":4})"); + REQUIRE(form.value().has_value()); + CHECK(form.value()->a == 3); + CHECK_FALSE(form.lastResult().has_value()); // set<> never submits + + form.session().field("b").text().set("5"); + REQUIRE(morph::testing::pumpOwnerUntil(stack.owner, [&] { return form.lastResult().has_value(); })); + CHECK(form.lastResult()->sum == 8); + CHECK(form.session().lastReply() == R"({"sum":8})"); + CHECK(form.error() == nullptr); +} + +TEST_CASE("Form: the action's validate() joins readiness", "[forms-engine][typed]") { + TypedStack stack; + morph::forms::Form form{stack.rt, stack.handler, morph::forms::ChoiceFetcher{}}; + form.set<&FtSum::a>(150); + form.set<&FtSum::b>(1); + CHECK(form.session().field("a").error().get() == std::nullopt); // every field encodes... + CHECK_FALSE(form.ready()); // ...but the action refuses + CHECK_FALSE(form.session().body().has_value()); + form.set<&FtSum::a>(99); + CHECK(form.ready()); + form.set<&FtSum::note>(std::string{"why"}); + CHECK(form.session().body() == R"({"a":99,"b":1,"note":"why"})"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: `fatal error: 'morph/forms/engine/typed_form.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `include/morph/forms/engine/typed_form.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/typed_form.hpp +/// @brief `Form`: a `FormSession` over `schemaJson()`, with typed access at the edges. +/// +/// Specified in `docs/spec/forms/engine.md`, "Typed forms". + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../attributes.hpp" +#include "../../core/bridge.hpp" +#include "../../core/callback_scope.hpp" +#include "../../core/completion.hpp" +#include "../../core/registry.hpp" +#include "../../reactive/runtime.hpp" +#include "../../reactive/signal.hpp" +#include "../forms.hpp" +#include "field_model.hpp" +#include "form_session.hpp" + +namespace morph::forms { + +/// @brief A form over action `A`: a `FormSession` built from `schemaJson()`, submitting +/// through a `BridgeHandler`, with typed `set<>`, `value()` and `lastResult()`. +/// +/// Non-copyable and non-movable: its session and in-flight callbacks point into it. +/// @tparam A The action type (registered with `BRIDGE_REGISTER_ACTION`). +/// @tparam M The model type the handler talks to. +/// @tparam S The handler's sharing policy. +template +class Form { +public: + /// @brief The action's registered result type. + using Result = typename model::ActionTraits::Result; + + /// @brief The type of the member @p Member points to. + /// @tparam Member A pointer to a data member of `A`. + template + using MemberValue = typename bridge::detail::MemberPointerTraits::ValueType; + + /// @param runtime The runtime; its owner must be @p handler's callback executor. Borrowed. + /// @param handler Executes the action. Borrowed: it must outlive the form. + /// @param choices Fetches Choice options; may be empty when `A` has no Choice member. + /// @param options Locale, translations, display zone; an `accepts` gate is kept and joined + /// with `ActionValidator`. + /// @throws std::logic_error from `FormModel::forAction()` when `schemaJson()` does not + /// read as a form model. + Form(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, bridge::BridgeHandler& handler MORPH_LIFETIMEBOUND, + ChoiceFetcher choices, FormSessionOptions options = {}) + : _rt{&runtime}, + _handler{&handler}, + _lastResult{runtime, std::nullopt}, + _session{runtime, FormModel::forAction(), [this](std::string_view, std::string body) { return execute(std::move(body)); }, + std::move(choices), withValidator(std::move(options))}, + _value{runtime, [this]() -> std::optional { + std::optional const body = _session.body(); + return body.has_value() ? decode(*body) : std::nullopt; + }} {} + + ~Form() = default; + Form(Form const&) = delete; + Form& operator=(Form const&) = delete; + Form(Form&&) = delete; + Form& operator=(Form&&) = delete; + + /// @brief The session: fields, readiness, body, submission. + /// @return The session. + [[nodiscard]] FormSession& session() noexcept { return _session; } + + /// @brief Sets one member, encoded with its field's encoder; a programmatic change, so + /// nothing is submitted. + /// @tparam Member A pointer to a data member of `A`, e.g. `&Deposit::amount`. + /// @param value The member's value. + template + void set(MemberValue value) { + static_assert(std::is_same_v::ClassType, A>, + "Form::set<>: the member must belong to A"); + _session.assign(detail::memberWireName(), glz::write_json(value).value_or(std::string{"null"})); + } + + /// @brief The body decoded into `A`. Tracked. + /// @return The action while the form is ready, else `std::nullopt`. + [[nodiscard]] std::optional value() const { return _value.get(); } + + /// @brief Whether the form can submit: the session is ready, which includes `A`'s own + /// `validate()`. Tracked. + /// @return `true` when ready. + [[nodiscard]] bool ready() const { return _session.ready(); } + + /// @brief The last successful result. Tracked. + /// @return The typed result, or `std::nullopt` before the first success. + [[nodiscard]] std::optional const& lastResult() const { return _lastResult.get(); } + + /// @brief Whether a submission is in flight. Tracked. + /// @return `true` while one is. + [[nodiscard]] bool pending() const { return _session.pending(); } + + /// @brief The last failure. Tracked. + /// @return The exception, or null. + [[nodiscard]] std::exception_ptr error() const { return _session.lastError(); } + +private: + [[nodiscard]] static std::optional decode(std::string const& body) { + try { + return model::ActionTraits::fromJson(body); + } catch (...) { + return std::nullopt; + } + } + + [[nodiscard]] static FormSessionOptions withValidator(FormSessionOptions options) { + options.accepts = [own = std::move(options.accepts)](std::string_view body) { + if (own && !own(body)) { + return false; + } + std::optional const action = decode(std::string{body}); + return action.has_value() && model::ActionValidator::ready(*action); + }; + return options; + } + + [[nodiscard]] async::Completion execute(std::string const& body) { + auto [completion, promise] = async::Completion::makeSettleable(&_rt->owner()); + auto relay = std::make_shared::Promise>(std::move(promise)); + std::optional action = decode(body); + if (!action) { + relay->reject(std::make_exception_ptr(std::invalid_argument{"Form: the body does not decode"})); + return std::move(completion); + } + _handler->execute(*std::move(action)) + .then(_relay, + [this, relay](Result const& result) { + try { + std::string reply = model::ActionTraits::resultToJson(result); + _lastResult.set(result); + relay->resolve(std::move(reply)); + } catch (...) { + relay->reject(std::current_exception()); + } + }) + .onError(_relay, [relay](std::exception_ptr error) { relay->reject(std::move(error)); }); + return std::move(completion); + } + + reactive::Runtime* _rt; + bridge::BridgeHandler* _handler; + reactive::Signal> _lastResult; + // Before the session: a session ready as constructed submits from its constructor, and the + // handler callbacks that submission attaches are gated by this scope. + async::CallbackScope _relay; + FormSession _session; + reactive::Computed> _value; +}; + +} // namespace morph::forms +``` + +Register the header and the test as listed under **Files**. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][typed]"` +Expected: PASS, 3 test cases. + +Mutation check: in `withValidator`, replace `return action.has_value() && model::ActionValidator::ready(*action);` +with `return action.has_value();`. Expected: FAIL in "the action's validate() joins readiness". Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/typed_form.hpp tests/test_forms_engine_typed.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): Form, the typed facade over FormSession + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 12: `formView`, `Overrides` and `FieldView` — the form rendered through the view tree + +**Files:** +- Create: `include/morph/forms/engine/overrides.hpp`, `include/morph/forms/engine/form_view.hpp` +- Create: `tests/forms_engine_view_support.hpp` (the node outline and the RecordingBackend vocabulary engine view + tests share) +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/forms/engine/typed_form.hpp`, add + `include/morph/forms/engine/overrides.hpp` and `include/morph/forms/engine/form_view.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_view.cpp` after `test_forms_engine_typed.cpp` +- Test: `tests/test_forms_engine_view.cpp` + +**Interfaces:** +- Consumes: Part 2 — `ui::Node`, the node aggregates and builders (`ui::text`, `button`, `textInput`, `checkbox`, + `select`, `column`, `row`, `grid`, `panel`, `tabs`, `busy`, `dateTimeInput`, `slider`, `forEach`), + `ui::Prop` (`evaluate()`), `ui::Common`, `ui::Key`, `ui::SelectOption`, `ui::GridCell`, `ui::Tab`, + `ui::TextRole`, `ui::TextInputMode`, `ui::SelectStyle`, `ui::DateMode`; `ui::Mounted(reactive::Runtime&, + IViewBackend&, Node)`; `ui::testing::RecordingBackend` (`all`, `find`, `prop`, `exists`, `edit`, `click`, + `toggle`, `choose`, `setDateTime`, `slide`). Part 1 — `reactive::errorMessage`. Tasks 4–8. +- Produces (`morph::forms`), exactly: + - `Overrides` — the contract's `byField(std::string path, Render)`, `byWidget(std::string hint, Render)`, + `byUnit(std::string unit, Render)`, `byKind(FieldKind, Render)`, `Render = std::function`, + plus `resolve(FieldSpec const&) const -> Render const*` (field path → widget hint → unit (`unitAscii`) → kind). + - `FieldView` — `spec()`, `state()`, `session()`, `defaultControl()`; copyable, safe to keep inside a node. + - `FormViewOptions{overrides, gridColumns = 2, submitLabel = "Submit"}` plus the addition + `std::optional flatGridColumns` (the implicit group's columns: 1 at the default of 2, else `gridColumns`). + - `formView(FormSession&, FormViewOptions = {}) -> ui::Node`. + - In `detail::engine`: `statusText(FormSession const&)`, `resultText(FormSession const&)`, `fieldCell`, + `fieldControl` (Tasks 13–15 reuse them). + +What each kind draws, and the frame around it: + +| Kind | Control | +|---|---| +| Text, Array | `TextInput` (SingleLine) | +| Multiline | `TextInput` (Multiline) | +| Integer, Number, Decimal | `TextInput`, then `Text(unit)` when the field has one | +| Quantity | `Row{TextInput, Select(units)}`; `Text(unit)` instead of the `Select` without alternatives | +| Boolean | `Checkbox` | +| Enum | `Select` (Radio for `x-widget: radio`), options from the schema, nothing selected until chosen | +| Choice | `Row{Select, Busy}` — the options query's rows, `Busy` while it fetches | +| DateTime / Date | `DateTimeInput` (DateTime in the display zone / Date) | +| Slider | `Slider` over `x-min`/`x-max`/`x-step` | +| Object | `Panel(label)` around the member cells; a truncated one is a muted `Text` | +| ObjectArray | `Column{ForEach rows → Panel(label n){member cells, Button "Remove"}, Button "Add"}` | + +A **cell** is `Column{Text(label, " *" while required), control, [Text(help, Muted)], Text(error, Error)}`, its +`visible` bound to the field's and its `enabled` to `!readonly`. The **form** is `Column{Text(action type, +Heading), group runs, Text(status, Muted), [Button(submitLabel) bound to ready], Text(last reply or error)}`; group +runs are a `Grid` per implicit group, a `Panel` per section, a collapsible `Panel` per accordion, and one `Tabs` per +run of consecutive tab groups. Every binding captures the session and its fields by pointer — mount the view after +the session and destroy it before — and a collection row's nodes hold that row's state, so a removed row lives until +its view unmounts. + +- [ ] **Step 1: Write the failing test** + +Create `tests/forms_engine_view_support.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms_engine_view_support.hpp +/// @brief A deterministic outline of a `ui::Node` tree (the golden form views compare against), +/// and the RecordingBackend kind names engine view tests look widgets up by. + +#include +#include +#include +#include +#include +#include + +namespace morph::test::formsview { + +/// The RecordingBackend kind names (one per widget interface) these tests look up. +inline constexpr std::string_view kTextInput = "TextInput"; +inline constexpr std::string_view kButton = "Button"; +inline constexpr std::string_view kCheckbox = "Checkbox"; +inline constexpr std::string_view kSelect = "Select"; +inline constexpr std::string_view kDateTimeInput = "DateTimeInput"; +inline constexpr std::string_view kSlider = "Slider"; +inline constexpr std::string_view kPanel = "Panel"; + +namespace detail { + +[[nodiscard]] inline std::string roleName(ui::TextRole role) { + switch (role) { + case ui::TextRole::Muted: + return " (muted)"; + case ui::TextRole::Heading: + return " (heading)"; + case ui::TextRole::Error: + return " (error)"; + case ui::TextRole::Success: + return " (success)"; + default: + return ""; + } +} + +[[nodiscard]] inline std::string flags(ui::Common const& common) { + std::string out; + if (!common.visible.evaluate()) { + out += " hidden"; + } + if (!common.enabled.evaluate()) { + out += " disabled"; + } + return out; +} + +// NOLINTNEXTLINE(misc-no-recursion) -- a tree outline recurses into children +inline void outline(ui::Node const& node, std::size_t depth, std::string const& prefix, std::string& out) { + std::string const indent(depth * 2, ' '); + auto line = [&](std::string const& text, ui::Common const& common) { + out += indent + prefix + text + flags(common) + "\n"; + }; + std::visit( + [&](auto const& data) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + line("Text \"" + data.text.evaluate() + "\"" + roleName(data.role.evaluate()), data.common); + } else if constexpr (std::is_same_v) { + line("Button \"" + data.label.evaluate() + "\"", data.common); + } else if constexpr (std::is_same_v) { + std::string const placeholder = data.placeholder.evaluate(); + line(std::string{"TextInput"} + (placeholder.empty() ? "" : " \"" + placeholder + "\"") + + (data.mode == ui::TextInputMode::Multiline ? " multiline" : ""), + data.common); + } else if constexpr (std::is_same_v) { + line("Checkbox", data.common); + } else if constexpr (std::is_same_v) { + std::string labels; + for (auto const& option : data.options.evaluate()) { + labels += (labels.empty() ? "" : "|") + option.label; + } + line("Select [" + labels + "]" + (data.style == ui::SelectStyle::Radio ? " radio" : ""), data.common); + } else if constexpr (std::is_same_v || std::is_same_v) { + line(std::is_same_v ? "Column" : "Row", data.common); + for (auto const& child : data.children) { + outline(child, depth + 1, "", out); + } + } else if constexpr (std::is_same_v) { + line("Grid " + std::to_string(data.columns), data.common); + for (auto const& cell : data.cells) { + outline(cell.node, depth + 1, "[" + std::to_string(cell.span) + "] ", out); + } + } else if constexpr (std::is_same_v) { + line("Panel \"" + data.title.evaluate() + "\"" + (data.collapsible ? " collapsible" : ""), data.common); + outline(data.child, depth + 1, "", out); + } else if constexpr (std::is_same_v) { + line("Tabs", data.common); + for (auto const& tab : data.tabs) { + out += std::string((depth + 1) * 2, ' ') + "Tab \"" + tab.label + "\"\n"; + outline(tab.node, depth + 2, "", out); + } + } else if constexpr (std::is_same_v) { + line("Dialog \"" + data.title.evaluate() + "\"", data.common); + outline(data.child, depth + 1, "", out); + } else if constexpr (std::is_same_v) { + line("Busy", data.common); + } else if constexpr (std::is_same_v) { + line(data.mode == ui::DateMode::Date ? "DateTimeInput date" : "DateTimeInput datetime", data.common); + } else if constexpr (std::is_same_v) { + line("Slider " + std::to_string(data.minimum) + ".." + std::to_string(data.maximum) + "/" + + std::to_string(data.step), + data.common); + } else if constexpr (std::is_same_v) { + line("ForEach", data.common); + } else if constexpr (std::is_same_v) { + std::string labels; + for (auto const& column : data.columns) { + labels += (labels.empty() ? "" : "|") + column.label; + } + line("Table [" + labels + "]", data.common); + } else if constexpr (std::is_same_v) { + std::string labels; + for (auto const& item : data.items) { + labels += (labels.empty() ? "" : "|") + item.label.evaluate(); + } + line("Menu [" + labels + "]", data.common); + } else if constexpr (std::is_same_v) { + line("Switch", data.common); + } else { + line("Other", data.common); + } + }, + node->kind); +} + +} // namespace detail + +/// @brief The tree under @p node, one line per node, two spaces per level: the kind, its +/// identifying text, ` hidden`/` disabled` from its current bindings. A ForEach's rows +/// and a Switch's cases are mounted lazily and are not expanded. +/// @param node The root. +/// @return The outline. +[[nodiscard]] inline std::string outline(ui::Node const& node) { + std::string out; + detail::outline(node, 0, "", out); + return out; +} + +} // namespace morph::test::formsview +``` + +Create `tests/test_forms_engine_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "forms_engine_support.hpp" +#include "forms_engine_view_support.hpp" + +namespace { + +using morph::forms::FieldKind; +using morph::forms::FieldView; +using morph::forms::FormViewOptions; +using morph::test::formsengine::Harness; +using morph::test::formsview::outline; +namespace ui = morph::ui; +namespace view = morph::test::formsview; + +constexpr std::string_view kLayout = R"({"properties":{ + "a":{"type":["integer","null"],"x-order":0,"title":"A","x-section":0,"x-colspan":4}, + "b":{"type":["integer","null"],"x-order":1,"title":"B","x-section":0,"x-colspan":8}, + "c":{"type":["integer","null"],"x-order":2,"title":"C","x-section":1,"x-colspan":3}, + "d":{"type":["integer","null"],"x-order":3,"title":"D","x-section":2,"x-colspan":2}, + "n":{"type":["integer","null"],"x-order":4,"title":"N","x-section":3}, + "e":{"type":["integer","null"],"x-order":5,"title":"E"}},"required":[], + "x-layout":{"groups":[{"title":"Identity","kind":"section","fields":["a","b"]},{"title":"One","kind":"tab","fields":["c"]}, + {"title":"Two","kind":"tab","fields":["d"]},{"title":"More","kind":"accordion","fields":["n"]}]}})"; + +constexpr std::string_view kExplicit = R"({"properties":{ + "count":{"type":"integer","x-order":0,"title":"Count","description":"How many"}, + "note":{"type":["string","null"],"x-order":1,"title":"Note"}},"required":["count"],"x-submitMode":"explicit"})"; + +/// A field cell as the golden outlines print it. +[[nodiscard]] std::string cell(std::string_view prefix, std::string_view indent, std::string_view label, + std::string_view control) { + std::string const deeper = std::string{indent} + " "; + return std::string{indent} + std::string{prefix} + "Column\n" + deeper + "Text \"" + std::string{label} + "\"\n" + deeper + + std::string{control} + "\n" + deeper + "Text \"\" (error) hidden\n"; +} + +} // namespace + +TEST_CASE("formView: sections, tab runs, accordions and the implicit group, each on its grid", "[forms-engine][view]") { + Harness harness; + harness.server.replyWith([](auto const&) { return std::nullopt; }); + auto const form = harness.session("T_Layout", kLayout); + std::string const expected = std::string{"Column\n"} + " Text \"T_Layout\" (heading)\n" + " Panel \"Identity\"\n" + + " Grid 2\n" + cell("[2] ", " ", "A", "TextInput \"0\"") + + cell("[2] ", " ", "B", "TextInput \"0\"") + " Tabs\n" + " Tab \"One\"\n" + + " Grid 2\n" + cell("[2] ", " ", "C", "TextInput \"0\"") + " Tab \"Two\"\n" + + " Grid 2\n" + cell("[2] ", " ", "D", "TextInput \"0\"") + + " Panel \"More\" collapsible\n" + " Grid 2\n" + + cell("[1] ", " ", "N", "TextInput \"0\"") + " Grid 1\n" + + cell("[1] ", " ", "E", "TextInput \"0\"") + + " Text \"✓ executes automatically as you type\" (muted)\n" + + " Text \"\" (success) hidden\n"; + CHECK(outline(morph::forms::formView(*form)) == expected); +} + +TEST_CASE("formView: a host grid widens every group's grid; spans clamp to it", "[forms-engine][view]") { + Harness harness; + auto const form = harness.session("T_Layout", kLayout); + std::string const twelve = outline(morph::forms::formView(*form, FormViewOptions{.gridColumns = 12})); + CHECK(twelve.find(" Grid 12\n [4] Column\n") != std::string::npos); + CHECK(twelve.find(" [8] Column\n") != std::string::npos); + CHECK(twelve.find(" [3] Column\n") != std::string::npos); + CHECK(twelve.find(" Grid 12\n [1] Column\n") != std::string::npos); // the implicit group follows the host grid + std::string const flatTwo = + outline(morph::forms::formView(*form, FormViewOptions{.gridColumns = 2, .flatGridColumns = 2})); + CHECK(flatTwo.find(" Grid 2\n [1] Column\n") != std::string::npos); + auto const wide = harness.session("T_Flat", R"({"properties":{"a":{"type":"integer","x-order":0,"title":"A","x-colspan":20}},"required":[]})"); + CHECK(outline(morph::forms::formView(*wide, FormViewOptions{.gridColumns = 12})).find("[12] Column") != std::string::npos); + CHECK(outline(morph::forms::formView(*wide)).find("[1] Column") != std::string::npos); +} + +TEST_CASE("formView: explicit mode adds a Submit button bound to readiness; the cell shows help and a live *", + "[forms-engine][view]") { + Harness harness; + harness.server.replyWith([](auto const&) { return std::nullopt; }); + auto const form = harness.session("T_Chrome", kExplicit); + std::string const expected = std::string{"Column\n"} + " Text \"T_Chrome\" (heading)\n" + " Grid 1\n" + + " [1] Column\n" + " Text \"Count *\"\n" + " TextInput \"0\"\n" + + " Text \"How many\" (muted)\n" + " Text \"\" (error) hidden\n" + + cell("[1] ", " ", "Note", "TextInput") + + " Text \"fill the required (*) fields\" (muted)\n" + " Button \"Submit\" disabled\n" + + " Text \"\" (success) hidden\n"; + CHECK(outline(morph::forms::formView(*form)) == expected); + harness.type(*form, "count", "abc"); + CHECK(outline(morph::forms::formView(*form)).find("Text \"Not a valid value\" (error)\n") != std::string::npos); +} + +TEST_CASE("formView: the mounted Submit button follows readiness and submits on click", "[forms-engine][view]") { + Harness harness; + auto const form = harness.session("CFR_BookRoom", R"({"properties":{"name":{"type":"string","x-order":0}},"required":["name"],"x-submitMode":"explicit"})"); + ui::testing::RecordingBackend backend; + ui::Mounted const mounted{harness.rt, backend, morph::forms::formView(*form)}; + harness.settle(); + auto const submit = backend.find(view::kButton, "label", "Submit"); + REQUIRE(submit.has_value()); + CHECK(backend.prop(*submit, "enabled") == "false"); + backend.click(*submit); + harness.settle(); + CHECK(harness.server.submits().empty()); + backend.edit(backend.all(view::kTextInput).front(), "Alice"); + harness.settle(); + CHECK(backend.prop(*submit, "enabled") == "true"); + CHECK(harness.server.submits().empty()); + backend.click(*submit); + harness.settle(); + REQUIRE(harness.server.submits().size() == 1); + CHECK(harness.server.lastBody() == R"({"name":"Alice"})"); + CHECK(backend.find("Text", "text", R"(ok: {"ok":true})").has_value()); + + auto const automatic = harness.session("CFR_BookRoom", R"({"properties":{"name":{"type":"string","x-order":0}},"required":["name"]})"); + CHECK(outline(morph::forms::formView(*automatic)).find("Button") == std::string::npos); +} + +TEST_CASE("formView: each kind draws its control", "[forms-engine][view]") { + Harness harness; + harness.server.optionsWith([](auto const&) { return std::nullopt; }); + auto const form = harness.session("T_Kinds", R"({"$defs":{"q":{"type":["object","null"],"ExtUnits":{"unitAscii":"kg","unitUnicode":"kg"}}},"properties":{ + "mass":{"$ref":"#/$defs/q","x-order":0,"title":"Mass","x-decimalPlaces":3,"x-unitAlternatives":[{"id":"g","display":"g","decimals":1,"num":1,"den":1000}]}, + "density":{"type":"number","x-order":1,"title":"Density","ExtUnits":{"unitAscii":"kg/m³","unitUnicode":"kg/m³"},"x-displayDecimals":3}, + "done":{"type":"boolean","x-order":2,"title":"Done"}, + "role":{"type":"string","oneOf":[{"title":"Viewer","const":"Viewer"},{"title":"Manager","const":"Manager"}],"x-order":3,"title":"Role"}, + "mode":{"type":"integer","x-order":4,"title":"Mode","x-widget":"radio","x-optionsAction":"ListModes"}, + "when":{"type":"string","format":"date-time","x-order":5,"title":"When"}, + "day":{"type":"string","format":"date","x-order":6,"title":"Day"}, + "level":{"type":"integer","x-order":7,"title":"Level","x-widget":"slider","x-min":0,"x-max":100,"x-step":5}, + "tags":{"type":"array","items":{"type":"string"},"x-order":8,"title":"Tags"}, + "notes":{"type":"string","x-order":9,"title":"Notes","x-widget":"textarea"}},"required":[]})"); + std::string const drawn = outline(morph::forms::formView(*form)); + CHECK(drawn.find(" Row\n TextInput \"0.000\"\n Select [kg|g]\n") != std::string::npos); + CHECK(drawn.find(" Row\n TextInput \"0.000\"\n Text \"kg/m³\" (muted)\n") != std::string::npos); + CHECK(drawn.find(" Checkbox\n") != std::string::npos); + CHECK(drawn.find(" Select [Viewer|Manager]\n") != std::string::npos); + CHECK(drawn.find(" Select [] radio\n Busy\n") != std::string::npos); + CHECK(drawn.find(" DateTimeInput datetime\n") != std::string::npos); + CHECK(drawn.find(" DateTimeInput date\n") != std::string::npos); + CHECK(drawn.find(" Slider 0..100/5\n") != std::string::npos); + CHECK(drawn.find(" TextInput \"comma-separated (e.g. red, green, blue)\"\n") != std::string::npos); + CHECK(drawn.find(" TextInput multiline\n") != std::string::npos); +} + +TEST_CASE("formView: mounted controls drive the session", "[forms-engine][view]") { + Harness harness; + auto const form = harness.session("T_Mounted", R"({"$defs":{"q":{"type":["object","null"],"ExtUnits":{"unitAscii":"kg","unitUnicode":"kg"}}},"properties":{ + "mass":{"$ref":"#/$defs/q","x-order":0,"x-decimalPlaces":3,"x-unitAlternatives":[{"id":"g","display":"g","decimals":1,"num":1,"den":1000}]}, + "done":{"type":"boolean","x-order":1}, + "role":{"type":"string","oneOf":[{"title":"Viewer","const":"Viewer"},{"title":"Manager","const":"Manager"}],"x-order":2}, + "when":{"type":"string","format":"date-time","x-order":3}, + "level":{"type":"integer","x-order":4,"x-widget":"slider","x-min":0,"x-max":100,"x-step":5}},"required":["mass","done","role","when","level"]})"); + ui::testing::RecordingBackend backend; + ui::Mounted const mounted{harness.rt, backend, morph::forms::formView(*form)}; + harness.settle(); + backend.edit(backend.all(view::kTextInput).front(), "2650.5"); + // The unit selector keys its options by position; RecordingBackend::chooseIndex drives menus and tabs only. + backend.choose(backend.all(view::kSelect).at(0), ui::Key{std::int64_t{1}}); // grams + harness.settle(); + CHECK(form->field("mass").text().peek() == "2650500.0"); + CHECK(backend.prop(backend.all(view::kTextInput).front(), "text") == "2650500.0"); + backend.toggle(backend.all(view::kCheckbox).front()); + backend.choose(backend.all(view::kSelect).at(1), ui::Key{std::string{R"("Manager")"}}); + backend.setDateTime(backend.all(view::kDateTimeInput).front(), + morph::time::Timestamp{morph::time::DateTime{std::chrono::sys_days{std::chrono::July / 5 / 2026} + + std::chrono::hours{14} + std::chrono::minutes{30}}}); + backend.slide(backend.all(view::kSlider).front(), 45); + harness.settle(); + CHECK(form->body() == R"({"mass":{"num":26505000,"den":10000,"dp":3},"done":true,"role":"Manager",)" + R"("when":"2026-07-05T14:30:00Z","level":45})"); +} + +TEST_CASE("formView: visibility and read-only follow the rules on the mounted cells", "[forms-engine][view]") { + Harness harness; + auto const form = harness.session("T_Rules", R"({"properties":{ + "promo":{"type":"integer","x-order":0,"title":"Promo"},"discount":{"type":"integer","x-order":1,"title":"Discount"}, + "total":{"type":"integer","x-order":2,"title":"Total"}},"required":[], + "x-rules":[{"kind":"visibleWhen","fields":["discount"],"when":{"kind":"engaged","fields":["promo"]}}, + {"kind":"readonlyWhen","fields":["total"],"when":{"kind":"engaged","fields":["promo"]}}, + {"kind":"requiredWhen","fields":["total"],"when":{"kind":"engaged","fields":["promo"]}}]})"); + std::string const before = outline(morph::forms::formView(*form)); + CHECK(before.find(" [1] Column hidden\n Text \"Discount\"\n") != std::string::npos); + CHECK(before.find(" Text \"Total\"\n") != std::string::npos); + harness.type(*form, "promo", "5"); + std::string const after = outline(morph::forms::formView(*form)); + CHECK(after.find(" [1] Column\n Text \"Discount\"\n") != std::string::npos); + CHECK(after.find(" [1] Column disabled\n Text \"Total *\"\n") != std::string::npos); +} + +TEST_CASE("formView: switching tabs keeps what was typed and submits nothing", "[forms-engine][view]") { + Harness harness; + auto const form = harness.session("TabResubmit", R"({"properties":{"a":{"type":"integer","x-order":0,"x-section":0}, + "b":{"type":["integer","null"],"x-order":1,"x-section":1}},"required":["a"], + "x-layout":{"groups":[{"title":"One","kind":"tab","fields":["a"]},{"title":"Two","kind":"tab","fields":["b"]}]}})"); + ui::testing::RecordingBackend backend; + ui::Mounted const mounted{harness.rt, backend, morph::forms::formView(*form)}; + harness.settle(); + backend.edit(backend.all(view::kTextInput).front(), "1"); + harness.settle(); + REQUIRE(harness.server.submits().size() == 1); + form->tabSelection(0).set(1); + harness.settle(); + form->tabSelection(0).set(0); + harness.settle(); + CHECK(backend.prop(backend.all(view::kTextInput).front(), "text") == "1"); + CHECK(form->field("a").text().peek() == "1"); + CHECK(harness.server.submits().size() == 1); +} + +TEST_CASE("formView: labels fall back to the wire key, follow x-order and come from translations", + "[forms-engine][view]") { + morph::render::TranslationProvider const catalog = [](std::string_view key, std::string_view) -> std::optional { + if (key == "AccessibilityProbe.group.0") { + return "Erste"; + } + return std::nullopt; + }; + Harness harness; + auto const form = harness.session("AccessibilityProbe", R"({"properties":{"second":{"type":"string","x-order":1,"x-section":0}, + "first":{"type":"integer","x-order":0,"x-section":0}},"required":["first","second"], + "x-layout":{"groups":[{"title":"First","kind":"section","fields":["first","second"]}]}})", + morph::forms::FormSessionOptions{.translations = catalog}); + std::string const drawn = outline(morph::forms::formView(*form)); + CHECK(drawn.find("Panel \"Erste\"") != std::string::npos); + auto const first = drawn.find("Text \"first *\""); + auto const second = drawn.find("Text \"second *\""); + REQUIRE(first != std::string::npos); + REQUIRE(second != std::string::npos); + CHECK(first < second); +} + +TEST_CASE("Overrides: field beats widget beats unit beats kind; a miss draws the built-in control", + "[forms-engine][view][overrides]") { + auto const marker = [](std::string text) { + return [text](FieldView&) { return ui::text(ui::Text{.text = text}); }; + }; + morph::forms::Overrides overrides; + overrides.byKind(FieldKind::Integer, marker("kind")).byUnit("kg", marker("unit")); + overrides.byWidget("slider", marker("widget")).byField("mass", marker("field")); + Harness harness; + auto const form = harness.session("Probe", R"({"properties":{ + "mass":{"type":"integer","x-order":0,"x-widget":"slider","x-min":0,"x-max":9,"ExtUnits":{"unitAscii":"kg"}}, + "level":{"type":"integer","x-order":1,"x-widget":"slider","x-min":0,"x-max":9,"ExtUnits":{"unitAscii":"kg"}}, + "weight":{"type":"integer","x-order":2,"ExtUnits":{"unitAscii":"kg"}}, + "count":{"type":"integer","x-order":3},"note":{"type":"string","x-order":4}},"required":[]})"); + std::string const drawn = outline(morph::forms::formView(*form, FormViewOptions{.overrides = overrides})); + auto const order = [&](std::string_view needle) { return drawn.find(needle); }; + CHECK(order("Text \"field\"") < order("Text \"widget\"")); + CHECK(order("Text \"widget\"") < order("Text \"unit\"")); + CHECK(order("Text \"unit\"") < order("Text \"kind\"")); + CHECK(order("Text \"kind\"") < order("Text \"note\"")); + CHECK(drawn.find("Slider") == std::string::npos); + CHECK(drawn.find("TextInput\n") != std::string::npos); // note: no override, the built-in control + morph::forms::Overrides other; + other.byField("elsewhere", marker("field")); + CHECK(other.resolve(form->field("mass").spec()) == nullptr); +} + +TEST_CASE("Overrides: an override receives the field and drives the session like the built-in control", + "[forms-engine][view][overrides]") { + Harness harness; + auto const form = harness.session("T_Reading", R"({"properties":{"density":{"type":"number","x-order":0,"title":"Density", + "ExtUnits":{"unitAscii":"kg/m³","unitUnicode":"kg/m³"},"x-displayDecimals":3}},"required":["density"]})"); + std::string seenUnit; + std::optional seenDecimals; + morph::forms::Overrides overrides; + overrides.byUnit("kg/m³", [&](FieldView& field) { + seenUnit = field.spec().unit; + seenDecimals = field.spec().displayDecimals; + morph::forms::FieldState* state = &field.state(); + return ui::column(ui::Column{.children = {ui::text(ui::Text{.text = "custom"}), field.defaultControl()}, + .common = {.visible = [state] { return state->visible().get(); }}}); + }); + ui::testing::RecordingBackend backend; + ui::Mounted const mounted{harness.rt, backend, morph::forms::formView(*form, FormViewOptions{.overrides = overrides})}; + harness.settle(); + CHECK(seenUnit == "kg/m³"); + CHECK(seenDecimals == 3U); + CHECK(backend.find("Text", "text", "custom").has_value()); + backend.edit(backend.all(view::kTextInput).front(), "1.25"); + harness.settle(); + CHECK(form->body() == R"({"density":1.25})"); +} + +TEST_CASE("formView: an object is a panel of member cells; a collection adds and removes rows", + "[forms-engine][view][nested]") { + Harness harness; + auto const form = harness.session("T_Grading", R"({"$defs":{"Row":{"type":"object","required":["sieve"],"properties":{ + "sieve":{"type":"number","x-order":0,"title":"Sieve"}}}},"properties":{ + "address":{"type":"object","x-order":0,"title":"Address","required":["street"],"properties":{"street":{"type":"string","x-order":0,"title":"Street"}}}, + "rows":{"type":"array","items":{"$ref":"#/$defs/Row"},"x-order":1,"title":"Rows"}},"required":["rows"]})"); + std::string const drawn = outline(morph::forms::formView(*form)); + CHECK(drawn.find(" Panel \"Address\"\n Column\n Column\n Text \"Street *\"\n") != + std::string::npos); + CHECK(drawn.find(" Column\n ForEach\n Button \"Add\"\n") != std::string::npos); + + ui::testing::RecordingBackend backend; + ui::Mounted const mounted{harness.rt, backend, morph::forms::formView(*form)}; + harness.settle(); + auto const add = backend.find(view::kButton, "label", "Add"); + REQUIRE(add.has_value()); + backend.click(*add); + backend.click(*add); + harness.settle(); + auto inputs = backend.all(view::kTextInput); + REQUIRE(inputs.size() == 3); // street, two sieves + backend.edit(inputs.at(1), "31.5"); + backend.edit(inputs.at(2), "0.063"); + harness.settle(); + CHECK(form->body() == R"({"rows":[{"sieve":31.5},{"sieve":0.063}]})"); + auto const firstRemove = backend.find(view::kButton, "label", "Remove"); + REQUIRE(firstRemove.has_value()); + backend.click(*firstRemove); + harness.settle(); + CHECK(form->body() == R"({"rows":[{"sieve":0.063}]})"); + CHECK(backend.all(view::kTextInput).size() == 2); +} + +// Review Focus 5. +TEST_CASE("formView: a row removed while its view is mounted is torn down after its bindings", + "[forms-engine][view][nested]") { + Harness harness; + auto const form = harness.session("T_Rows", R"({"$defs":{"Row":{"type":"object","properties":{"v":{"type":"integer","x-order":0}}}}, + "properties":{"rows":{"type":"array","items":{"$ref":"#/$defs/Row"},"x-order":0}},"required":[]})"); + ui::testing::RecordingBackend backend; + ui::Mounted const mounted{harness.rt, backend, morph::forms::formView(*form)}; + harness.settle(); + auto& rows = form->field("rows"); + auto const key = rows.addRow(); + harness.settle(); + auto const input = backend.all(view::kTextInput).front(); + // Remove the row and edit its (still mounted) input in the same turn: the row's state must + // outlive every binding that reads it. ASan is the observer here. + rows.removeRow(key); + backend.edit(input, "7"); + harness.settle(); + CHECK_FALSE(backend.exists(input)); + CHECK(form->body() == R"({"rows":[]})"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: `fatal error: 'morph/forms/engine/form_view.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `include/morph/forms/engine/overrides.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/overrides.hpp +/// @brief `Overrides`: an application's replacements for the control `formView` draws for a +/// field, chosen by field path, widget hint, unit or kind. +/// +/// Specified in `docs/spec/forms/engine.md`, "Overrides". + +#include +#include +#include +#include +#include + +#include "../../ui/view.hpp" +#include "field_model.hpp" + +namespace morph::forms { + +class FieldView; + +/// @brief Replacement controls, resolved per field in the order field path → widget hint → +/// unit → kind, else the built-in control. +/// +/// An override replaces the control only: the cell around it — label, help, error, visibility — +/// is still drawn. `FieldView::defaultControl()` gives the built-in control to wrap. +class Overrides { +public: + /// @brief Draws a field's control. + using Render = std::function; + + /// @brief Overrides one field. + /// @param path The field's spec path (`FieldSpec::path`: `name`, `parent.member`, `rows[].member`). + /// @param render The control. + /// @return `*this`, for chaining. + Overrides& byField(std::string path, Render render) { + _byField.insert_or_assign(std::move(path), std::move(render)); + return *this; + } + + /// @brief Overrides every field with an `x-widget` hint. + /// @param hint The hint, e.g. `"slider"`. + /// @param render The control. + /// @return `*this`, for chaining. + Overrides& byWidget(std::string hint, Render render) { + _byWidget.insert_or_assign(std::move(hint), std::move(render)); + return *this; + } + + /// @brief Overrides every field in a unit. + /// @param unit The unit's `ExtUnits.unitAscii`. + /// @param render The control. + /// @return `*this`, for chaining. + Overrides& byUnit(std::string unit, Render render) { + _byUnit.insert_or_assign(std::move(unit), std::move(render)); + return *this; + } + + /// @brief Overrides every field of a kind. + /// @param kind The kind. + /// @param render The control. + /// @return `*this`, for chaining. + Overrides& byKind(FieldKind kind, Render render) { + _byKind.insert_or_assign(kind, std::move(render)); + return *this; + } + + /// @brief The override for @p spec, if any. + /// @param spec The field. + /// @return The first match in field → widget → unit → kind order, or null. + [[nodiscard]] Render const* resolve(FieldSpec const& spec) const { + if (auto const found = _byField.find(spec.path); found != _byField.end()) { + return &found->second; + } + if (!spec.widget.empty()) { + if (auto const found = _byWidget.find(spec.widget); found != _byWidget.end()) { + return &found->second; + } + } + if (!spec.unitAscii.empty()) { + if (auto const found = _byUnit.find(spec.unitAscii); found != _byUnit.end()) { + return &found->second; + } + } + if (auto const found = _byKind.find(spec.kind); found != _byKind.end()) { + return &found->second; + } + return nullptr; + } + +private: + std::map> _byField; + std::map> _byWidget; + std::map> _byUnit; + std::map _byKind; +}; + +} // namespace morph::forms +``` + +Create `include/morph/forms/engine/form_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/form_view.hpp +/// @brief `formView`: a `FormSession` rendered as a `ui::Node` tree, so one form draws on every +/// frontend; `FieldView`, what an override is handed. +/// +/// Specified in `docs/spec/forms/engine.md`, "Rendering". + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../reactive/control.hpp" +#include "../../ui/view.hpp" +#include "../../util/datetime.hpp" +#include "../layout.hpp" +#include "detail/text.hpp" +#include "field_model.hpp" +#include "form_session.hpp" +#include "overrides.hpp" + +namespace morph::forms { + +/// @brief How `formView` lays a form out. +struct FormViewOptions { + /// @brief Replacement controls. + Overrides overrides{}; + /// @brief Columns of every section's, accordion's and tab's grid; `x-colspan` clamps to it. + int gridColumns = 2; + /// @brief The explicit-mode button's label. + std::string submitLabel = "Submit"; + /// @brief Columns of the implicit group's grid; unset is 1 while `gridColumns` is 2, else `gridColumns`. + std::optional flatGridColumns{}; +}; + +/// @brief A field's spec and state, handed to an override. Copyable; safe to keep inside a node +/// for as long as the session lives. +class FieldView { +public: + /// @param session The session. Borrowed. + /// @param state The field. Borrowed. + /// @param options The view options the form is drawn with. + FieldView(FormSession& session, FieldState& state, std::shared_ptr options) noexcept + : _session{&session}, _state{&state}, _options{std::move(options)} {} + + /// @brief The field's spec. + /// @return The spec. + [[nodiscard]] FieldSpec const& spec() const noexcept { return _state->spec(); } + + /// @brief The field's state: draft, encoding, error, visibility, options. + /// @return The state. + [[nodiscard]] FieldState& state() const noexcept { return *_state; } + + /// @brief The session the field belongs to. + /// @return The session. + [[nodiscard]] FormSession& session() const noexcept { return *_session; } + + /// @brief The control `formView` draws for this field without overrides. + /// @return The control. + [[nodiscard]] ui::Node defaultControl() const; + +private: + FormSession* _session; + FieldState* _state; + std::shared_ptr _options; +}; + +namespace detail::engine { + +/// @brief The shared view options a form's nodes hold. +using ViewOptions = std::shared_ptr; + +/// @brief The status line under a form. +/// @param session The session. +/// @return `fill the required (*) fields` while not ready; else a line saying how it submits. +[[nodiscard]] inline std::string statusText(FormSession const& session) { + if (!session.ready()) { + return "fill the required (*) fields"; + } + return session.submitMode() == SubmitMode::Explicit ? "✓ ready -- press Submit" : "✓ executes automatically as you type"; +} + +/// @brief The last reply or failure. +/// @param session The session. +/// @return `error: `, `ok: `, or empty before the first answer. +[[nodiscard]] inline std::string resultText(FormSession const& session) { + if (std::exception_ptr const error = session.lastError()) { + return "error: " + reactive::errorMessage(error); + } + std::optional const& reply = session.lastReply(); + return reply.has_value() ? "ok: " + *reply : std::string{}; +} + +[[nodiscard]] ui::Node fieldCell(FormSession& session, FieldState& state, ViewOptions const& options); + +/// @brief A draft wall clock (`YYYY-MM-DDTHH:MM[:SS]`, or a `YYYY-MM-DD` date) as the instant it denotes. +/// @param text The draft. +/// @param date Whether the field is a Date. +/// @param offsetMinutes The display zone. +/// @return The instant, or `std::nullopt` while the draft is not a complete one. +[[nodiscard]] inline std::optional draftInstant(std::string_view text, bool date, int offsetMinutes) { + std::optional local; + if (date) { + if (isIsoDate(text)) { + local = parseWallClock(std::string{text} + "T00:00"); + } + } else { + local = parseWallClock(text); + } + if (!local) { + return std::nullopt; + } + auto const utc = *local - std::chrono::minutes{date ? 0 : offsetMinutes}; + return time::Timestamp{time::DateTime{std::chrono::time_point_cast(utc)}}; +} + +/// @brief The draft a date-time control writes for an instant (blank for none). +/// @param value The instant. +/// @param date Whether the field is a Date. +/// @param offsetMinutes The display zone. +/// @return `YYYY-MM-DDTHH:MM:SS` in the zone, or `YYYY-MM-DD`. +[[nodiscard]] inline std::string instantDraft(std::optional const& value, bool date, int offsetMinutes) { + if (!value || !value->hasValue()) { + return {}; + } + auto const instant = std::chrono::floor((**value).value) + + std::chrono::minutes{date ? 0 : offsetMinutes}; + std::string text = formatWallClock(instant); + return date ? text.substr(0, 10) : text; +} + +/// @brief The built-in control for one field (see the table in `docs/spec/forms/engine.md`). +/// @param session The session. +/// @param state The field. +/// @param options The view options (members and rows draw their own cells with them). +/// @return The control. +// NOLINTNEXTLINE(misc-no-recursion) -- an Object or ObjectArray draws its members' cells +[[nodiscard]] inline ui::Node fieldControl(FormSession& session, FieldState& state, ViewOptions const& options) { + FieldState* const field = &state; + FieldSpec const& spec = state.spec(); + auto const textInput = [field](ui::TextInputMode mode) { + return ui::textInput(ui::TextInput{.value = [field] { return field->text().get(); }, + .onChange = [field](std::string text) { field->text().set(std::move(text)); }, + .placeholder = field->placeholder(), + .mode = mode}); + }; + auto const unitSuffix = [&](ui::Node input) { + if (spec.unit.empty()) { + return input; + } + return ui::row(ui::Row{.children = {std::move(input), ui::text(ui::Text{.text = spec.unit, .role = ui::TextRole::Muted})}, + .gap = 1}); + }; + auto const optionSelect = [field](ui::SelectStyle style) { + return ui::select(ui::Select{ + .options = + [field] { + std::vector options; + for (ChoiceOption const& option : field->options()) { + options.push_back(ui::SelectOption{ui::Key{option.valueJson}, option.label}); + } + return options; + }, + .selected = [field]() -> std::optional { + std::string_view const selected = trim(field->text().get()); + return selected.empty() ? std::nullopt : std::optional{ui::Key{std::string{selected}}}; + }, + .onSelect = + [field](ui::Key key) { + if (auto const* const value = std::get_if(&key)) { + field->text().set(*value); + } + }, + .style = style}); + }; + switch (spec.kind) { + case FieldKind::Multiline: + return textInput(ui::TextInputMode::Multiline); + case FieldKind::Integer: + case FieldKind::Number: + case FieldKind::Decimal: + return unitSuffix(textInput(ui::TextInputMode::SingleLine)); + case FieldKind::Quantity: { + if (spec.units.size() <= 1) { + return unitSuffix(textInput(ui::TextInputMode::SingleLine)); + } + std::vector units; + for (std::size_t i = 0; i < spec.units.size(); ++i) { + units.push_back(ui::SelectOption{ui::Key{static_cast(i)}, spec.units.at(i).display}); + } + return ui::row(ui::Row{ + .children = {textInput(ui::TextInputMode::SingleLine), + ui::select(ui::Select{ + .options = std::move(units), + .selected = [field]() -> std::optional { + return ui::Key{static_cast(field->unit().get())}; + }, + .onSelect = + [field](ui::Key key) { + if (auto const* const index = std::get_if(&key); index != nullptr && *index >= 0) { + field->switchUnit(static_cast(*index)); + } + }})}, + .gap = 1}); + } + case FieldKind::Boolean: + return ui::checkbox(ui::Checkbox{.label = std::string{}, + .checked = [field] { return trim(field->text().get()) == "true"; }, + .onToggle = [field](bool checked) { field->text().set(checked ? "true" : "false"); }}); + case FieldKind::Enum: + return optionSelect(spec.widget == "radio" ? ui::SelectStyle::Radio : ui::SelectStyle::Dropdown); + case FieldKind::Choice: + return ui::row(ui::Row{ + .children = {optionSelect(spec.widget == "radio" ? ui::SelectStyle::Radio : ui::SelectStyle::Dropdown), + ui::busy(ui::Busy{.active = [field] { return field->optionsPending(); }, .label = std::string{}})}, + .gap = 1}); + case FieldKind::DateTime: + case FieldKind::Date: { + bool const date = spec.kind == FieldKind::Date; + int const offset = date ? 0 : session.options().displayOffsetMinutes; + return ui::dateTimeInput(ui::DateTimeInput{ + .value = [field, date, offset] { return draftInstant(trim(field->text().get()), date, offset); }, + .onChange = [field, date, offset](std::optional value) { + field->text().set(instantDraft(value, date, offset)); + }, + .mode = date ? ui::DateMode::Date : ui::DateMode::DateTime, + .offsetMinutes = offset}); + } + case FieldKind::Slider: + return ui::slider(ui::Slider{.value = + [field] { + std::string_view const text = trim(field->text().get()); + std::int64_t value = field->spec().sliderMin; + std::from_chars(text.data(), text.data() + text.size(), value); + return value; + }, + .minimum = spec.sliderMin, + .maximum = spec.sliderMax, + .step = spec.sliderStep, + .onChange = [field](std::int64_t value) { field->text().set(std::to_string(value)); }}); + case FieldKind::Object: { + if (spec.truncated) { + return ui::text(ui::Text{.text = "nested too deeply to edit here", .role = ui::TextRole::Muted}); + } + std::vector cells; + for (auto const& member : state.members()) { + cells.push_back(fieldCell(session, *member, options)); + } + return ui::panel(ui::Panel{.title = state.label(), .padding = 1, .child = ui::column(ui::Column{.children = std::move(cells)})}); + } + case FieldKind::ObjectArray: { + if (spec.truncated) { + return ui::text(ui::Text{.text = "nested too deeply to edit here", .role = ui::TextRole::Muted}); + } + FormSession* const owner = &session; + ViewOptions const shared = options; + auto rows = ui::forEach>( + [field] { return field->rows(); }, + [](std::shared_ptr const& row) { return ui::Key{static_cast(row->key())}; }, + [field, owner, shared](reactive::Signal> const& slot) { + std::shared_ptr const row = slot.peek(); + std::vector cells; + for (auto const& member : row->fields()) { + cells.push_back(fieldCell(*owner, *member, shared)); + } + std::uint64_t const key = row->key(); + cells.push_back(ui::button(ui::Button{.label = "Remove", .onClick = [field, key] { field->removeRow(key); }})); + // The row is held by the ForEach's slot for this key and, explicitly, by the title + // binding — the row view's first binding, destroyed after every binding inside the + // panel — so the row's state outlives everything that reads it after the session + // has let it go. + return ui::panel(ui::Panel{ + .title = [field, row] { + auto const& rows = field->rows(); + auto const found = std::ranges::find(rows, row); + return field->label() + " " + std::to_string(found - rows.begin() + 1); + }, + .padding = 1, + .child = ui::column(ui::Column{.children = std::move(cells)})}); + }); + return ui::column(ui::Column{ + .children = {std::move(rows), ui::button(ui::Button{.label = "Add", .onClick = [field] { field->addRow(); }})}}); + } + default: + return textInput(ui::TextInputMode::SingleLine); + } +} + +/// @brief One field's cell: label (with ` *` while required), control (an override's, else the +/// built-in), help, error; visible and enabled as the field is. +/// @param session The session. +/// @param state The field. +/// @param options The view options. +/// @return The cell. +// NOLINTNEXTLINE(misc-no-recursion) -- nested controls draw cells +inline ui::Node fieldCell(FormSession& session, FieldState& state, ViewOptions const& options) { + FieldState* const field = &state; + ui::Node control; + if (Overrides::Render const* const render = options->overrides.resolve(state.spec())) { + FieldView view{session, state, options}; + control = (*render)(view); + } else { + control = fieldControl(session, state, options); + } + std::vector children; + children.push_back(ui::text(ui::Text{.text = [field] { return field->label() + (field->required().get() ? " *" : ""); }})); + children.push_back(std::move(control)); + if (!state.help().empty()) { + children.push_back(ui::text(ui::Text{.text = state.help(), .role = ui::TextRole::Muted})); + } + children.push_back(ui::text(ui::Text{.text = [field] { + auto const& error = field->error().get(); + return error.has_value() ? error->message : std::string{}; + }, + .role = ui::TextRole::Error, + .common = {.visible = [field] { return field->error().get().has_value(); }}})); + return ui::column(ui::Column{.children = std::move(children), + .common = {.visible = [field] { return field->visible().get(); }, + .enabled = [field] { return !field->readonly().get(); }}}); +} + +/// @brief The fields of one group on a grid of @p columns, each spanning `x-colspan` clamped to it. +/// @param session The session. +/// @param group The group. +/// @param columns The grid's columns. +/// @param options The view options. +/// @return The grid. +[[nodiscard]] inline ui::Node groupGrid(FormSession& session, FieldGroupSpec const& group, int columns, + ViewOptions const& options) { + std::vector cells; + for (std::string const& name : group.fields) { + FieldState& state = session.field(name); + cells.push_back(ui::GridCell{.node = fieldCell(session, state, options), + .span = std::clamp(state.spec().colspan, 1, std::max(1, columns))}); + } + return ui::grid(ui::Grid{.columns = std::max(1, columns), .cells = std::move(cells), .gap = 1}); +} + +} // namespace detail::engine + +inline ui::Node FieldView::defaultControl() const { return detail::engine::fieldControl(*_session, *_state, _options); } + +/// @brief Renders @p session: its fields laid out by group, the status line, the Submit button in +/// explicit mode, and the last reply or error. +/// +/// The nodes bind to the session and its fields by pointer: mount the result after the session +/// and destroy the mount before it. +/// @param session The session. +/// @param options Overrides, grid columns, the Submit label. +/// @return The view. +[[nodiscard]] inline ui::Node formView(FormSession& session, FormViewOptions options = {}) { + using detail::engine::groupGrid; + auto const shared = std::make_shared(std::move(options)); + int const columns = std::max(1, shared->gridColumns); + int const flatColumns = shared->flatGridColumns.value_or(columns == 2 ? 1 : columns); + FormSession* const form = &session; + std::vector children; + children.push_back(ui::text(ui::Text{.text = std::string{session.model().actionType()}, .role = ui::TextRole::Heading})); + auto const groups = session.model().groups(); + for (std::size_t i = 0; i < groups.size();) { + FieldGroupSpec const& group = detail::engine::elementAt(groups, i); + if (group.implicit) { + children.push_back(groupGrid(session, group, flatColumns, shared)); + ++i; + } else if (group.kind == GroupKind::Tab) { + std::size_t const first = i; + std::vector tabs; + while (i < groups.size() && detail::engine::elementAt(groups, i).kind == GroupKind::Tab && + !detail::engine::elementAt(groups, i).implicit) { + tabs.push_back(ui::Tab{.label = session.groupTitle(i), + .node = groupGrid(session, detail::engine::elementAt(groups, i), columns, shared)}); + ++i; + } + reactive::Signal* const selected = &session.tabSelection(first); + children.push_back(ui::tabs(ui::Tabs{.tabs = std::move(tabs), + .selected = [selected] { return selected->get(); }, + .onSelect = [selected](std::size_t index) { selected->set(index); }})); + } else if (group.kind == GroupKind::Accordion) { + reactive::Signal* const collapsed = &session.groupCollapsed(i); + children.push_back(ui::panel(ui::Panel{.title = session.groupTitle(i), + .padding = 1, + .child = groupGrid(session, group, columns, shared), + .collapsible = true, + .collapsed = [collapsed] { return collapsed->get(); }, + .onToggle = [collapsed](bool value) { collapsed->set(value); }})); + ++i; + } else { + children.push_back(ui::panel(ui::Panel{.title = session.groupTitle(i), .padding = 1, .child = groupGrid(session, group, columns, shared)})); + ++i; + } + } + children.push_back(ui::text(ui::Text{.text = [form] { return detail::engine::statusText(*form); }, .role = ui::TextRole::Muted})); + if (session.submitMode() == SubmitMode::Explicit) { + children.push_back(ui::button(ui::Button{.label = shared->submitLabel, + .onClick = [form] { form->submit(); }, + .common = {.enabled = [form] { return form->ready(); }}})); + } + children.push_back(ui::text(ui::Text{ + .text = [form] { return detail::engine::resultText(*form); }, + .role = [form] { return form->lastError() != nullptr ? ui::TextRole::Error : ui::TextRole::Success; }, + .common = {.visible = [form] { return form->lastError() != nullptr || form->lastReply().has_value(); }}})); + return ui::column(ui::Column{.children = std::move(children), .gap = 1}); +} + +} // namespace morph::forms +``` + +Register the headers and the test as listed under **Files**. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][view]"` +Expected: PASS, 13 test cases. Then once under ASan (Task 17's configure) for Review Focus 5: +`./build/clang-asan/tests/morph_tests "a row removed while its view is mounted*"` — expected PASS with no report. + +Mutation check: in "a row removed while its view is mounted…", delete the `rows.removeRow(key);` line. Expected: +FAIL at `CHECK_FALSE(backend.exists(input))` — the case really removes a mounted row. Restore it. The lifetime +itself has ASan as its observer: the row is held by the ForEach slot (Part 2's per-key row signal) and by the panel +title binding, and a clean ASan run of this case is the evidence neither lets go too early. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/overrides.hpp include/morph/forms/engine/form_view.hpp \ + tests/forms_engine_view_support.hpp tests/test_forms_engine_view.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): formView, Overrides and FieldView — forms rendered through the view tree + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 13: Collections — `CollectionModel`, `CollectionSession`, `collectionView` + +A view document (`v-*`, what `views::viewSchemaJson()` emits) becomes a list `Query` on `v-query`, a +`Mutation` for every row and collection action that refetches the list on success, a confirm step for `confirm` +actions, and one editor `FormSession` for `v-rowAction` — reset and prefilled from the row's `bind` entries when a +row is opened, closed and the list refetched when its submission succeeds. Bodies are built from the row's JSON +values (`JsonExact.literal`'s job in QML), so an id past 2^53 is sent digit for digit; cells are formatted from the +exact `{num, den, dp}` with `decimalText`, never through a double. + +**Files:** +- Create: `include/morph/forms/engine/collection.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/forms/engine/form_view.hpp`, add + `include/morph/forms/engine/collection.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_collection.cpp` after `test_forms_engine_view.cpp` +- Test: `tests/test_forms_engine_collection.cpp` + +**Interfaces:** +- Consumes: Part 1's `reactive::Query`, `Mutation`, `MutationOptions{invalidates}`; Part 2's `ui::table`, + `ui::TableColumn`, `ui::TableOptions`, `ui::dialog`, `ui::Dialog`; Tasks 2, 6, 12 (`formView`, + `detail::engine::resultText`). +- Produces (`morph::forms`): `CollectionKind{Collection, MasterDetail}`, `CollectionActionScope{Row, Collection}`, + `CollectionColumn{field, label, hidden, decimalPlaces, unit}`, `CollectionAction{action, label, scope, bind, + confirm}`, `CollectionModel` (`fromSchema(std::string_view viewJson) -> std::expected`, `kind()`, `title()`, `query()`, `rowKey()`, `columns()`, `rowAction()`, `actions()`), + `CollectionRow{key, json, cells}`, `PendingConfirm{action, rowKey}`, `SchemaLookup = + std::function(std::string_view actionType)>`, `CollectionSession` (below), + `CollectionViewOptions{editor, openLabel, confirmTitle, confirmMessage, confirmLabel, cancelLabel}`, and the + contract's `collectionView(CollectionSession&, CollectionViewOptions = {}) -> ui::Node`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_collection.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "forms_engine_support.hpp" +#include "forms_engine_view_support.hpp" + +namespace { + +using morph::forms::CollectionModel; +using morph::forms::CollectionSession; +using morph::test::formsengine::Call; +using morph::test::formsengine::Harness; +namespace ui = morph::ui; +namespace view = morph::test::formsview; + +constexpr std::string_view kView = R"({"v-kind":"collection","v-title":"Rows","v-query":"ListRows","v-rowKey":"id", + "v-columns":[{"field":"id","label":"ID","v-hidden":true},{"field":"name","label":"Name"}, + {"field":"amount","label":"Amount","x-decimalPlaces":1,"ExtUnits":{"unitAscii":"kg","unitUnicode":"kg"}}], + "v-rowAction":{"action":"EditRow","bind":{"id":"id"}}, + "v-actions":[{"action":"DeleteRow","label":"Delete","scope":"row","bind":{"id":"id"},"confirm":true}, + {"action":"CreateRow","label":"New","scope":"collection"}]})"; + +constexpr std::string_view kRows = + R"({"rows":[{"id":1,"name":"First","amount":{"num":15,"den":10,"dp":1}},{"id":2,"name":"Second","amount":{"num":30,"den":10,"dp":1}}]})"; + +[[nodiscard]] std::optional schemas(std::string_view action) { + if (action == "EditRow") { + return R"({"properties":{"id":{"type":"integer","x-order":0},"name":{"type":"string","x-order":1}},"required":["id","name"]})"; + } + return std::nullopt; +} + +/// A collection over the fake server: ListRows answers @p rows, everything else `{"ok":true}`. +struct CollectionHarness : Harness { + explicit CollectionHarness(std::string rows = std::string{kRows}) { + server.replyWith([rows = std::move(rows)](Call const& call) -> std::optional { + return call.action == "ListRows" ? rows : std::string{R"({"ok":true})"}; + }); + } + + [[nodiscard]] std::unique_ptr collection(std::string_view json = kView) { + auto model = CollectionModel::fromSchema(json); + REQUIRE(model.has_value()); + return std::make_unique(rt, *std::move(model), schemas, server.submitter(), server.fetcher()); + } + + [[nodiscard]] std::size_t count(std::string_view action) const { + return static_cast( + std::ranges::count_if(server.submits(), [&](Call const& call) { return call.action == action; })); + } + + [[nodiscard]] std::optional lastBodyOf(std::string_view action) const { + for (auto iter = server.submits().rbegin(); iter != server.submits().rend(); ++iter) { + if (iter->action == action) { + return iter->body; + } + } + return std::nullopt; + } +}; + +} // namespace + +TEST_CASE("CollectionModel: the v-* document reads, and a malformed one is a SchemaError", "[forms-engine][collection]") { + auto const model = CollectionModel::fromSchema(kView); + REQUIRE(model.has_value()); + CHECK(model->kind() == morph::forms::CollectionKind::Collection); + CHECK(model->title() == "Rows"); + CHECK(model->query() == "ListRows"); + CHECK(model->rowKey() == "id"); + REQUIRE(model->columns().size() == 3); + CHECK(model->columns()[0].hidden); + CHECK(model->columns()[2].decimalPlaces == 1U); + CHECK(model->columns()[2].unit == "kg"); + REQUIRE(model->rowAction().has_value()); + CHECK(model->rowAction()->bind == std::vector>{{"id", "id"}}); + REQUIRE(model->actions().size() == 2); + CHECK(model->actions()[0].confirm); + CHECK(model->actions()[1].scope == morph::forms::CollectionActionScope::Collection); + CHECK_FALSE(CollectionModel::fromSchema("[]").has_value()); + CHECK(CollectionModel::fromSchema(R"({"v-kind":"collection"})").error().path == "v-query"); + CHECK(CollectionModel::fromSchema(R"({"v-kind":"grid","v-query":"Q"})").error().path == "v-kind"); +} + +TEST_CASE("CollectionSession: the list loads on construction; cells are exact", "[forms-engine][collection]") { + CollectionHarness harness; + auto const list = harness.collection(); + harness.settle(); + CHECK(harness.count("ListRows") == 1); + REQUIRE(list->rows().size() == 2); + CHECK(list->rows()[0].key == "1"); + CHECK(list->rows()[0].cells == std::vector{"First", "1.5 kg"}); + CHECK(list->rows()[1].cells == std::vector{"Second", "3.0 kg"}); +} + +TEST_CASE("CollectionSession: opening a row prefills only its bound fields and fires nothing", + "[forms-engine][collection]") { + CollectionHarness harness; + auto const list = harness.collection(); + harness.settle(); + REQUIRE(list->editor() != nullptr); + list->open("1"); + harness.settle(); + CHECK(list->openRow() == "1"); + CHECK(list->editor()->field("id").text().peek() == "1"); + CHECK(list->editor()->field("name").text().peek().empty()); + CHECK(harness.count("EditRow") == 0); + CHECK(harness.count("ListRows") == 1); +} + +TEST_CASE("CollectionSession: an edit after opening fires, closes the editor and reloads the list", + "[forms-engine][collection]") { + CollectionHarness harness; + auto const list = harness.collection(); + harness.settle(); + list->open("1"); + harness.settle(); + harness.type(*list->editor(), "name", "Renamed"); + CHECK(harness.lastBodyOf("EditRow") == R"({"id":1,"name":"Renamed"})"); + CHECK_FALSE(list->openRow().has_value()); + CHECK(harness.count("ListRows") == 2); +} + +TEST_CASE("CollectionSession: a confirmed row action fires its bound body and reloads; a cancelled one fires nothing", + "[forms-engine][collection]") { + CollectionHarness harness; + auto const list = harness.collection(); + harness.settle(); + list->fire(0, "2"); + harness.settle(); + REQUIRE(list->pendingConfirm().has_value()); + CHECK(list->pendingConfirm()->rowKey == "2"); + CHECK(harness.count("DeleteRow") == 0); + list->cancelConfirm(); + harness.settle(); + CHECK_FALSE(list->pendingConfirm().has_value()); + CHECK(harness.count("DeleteRow") == 0); + list->fire(0, "1"); + list->confirm(); + harness.settle(); + CHECK(harness.lastBodyOf("DeleteRow") == R"({"id":1})"); + CHECK_FALSE(list->pendingConfirm().has_value()); + CHECK(harness.count("ListRows") == 2); +} + +TEST_CASE("CollectionSession: a collection action fires with an empty body", "[forms-engine][collection]") { + CollectionHarness harness; + auto const list = harness.collection(); + harness.settle(); + list->fire(1); + harness.settle(); + CHECK(harness.lastBodyOf("CreateRow") == "{}"); +} + +TEST_CASE("CollectionSession: nothing typed into one row's editor carries over to the next opening", + "[forms-engine][collection]") { + CollectionHarness harness; + harness.server.replyWith([](Call const& call) -> std::optional { + if (call.action == "ListRows") { + return std::string{kRows}; + } + return call.action == "EditRow" ? std::nullopt : std::optional{R"({"ok":true})"}; + }); + auto const list = harness.collection(); + harness.settle(); + list->open("1"); + harness.settle(); + harness.type(*list->editor(), "name", "Typed into row one"); + CHECK(harness.lastBodyOf("EditRow") == R"({"id":1,"name":"Typed into row one"})"); + auto const edits = harness.count("EditRow"); + list->open("2"); + harness.settle(); + CHECK(list->editor()->field("id").text().peek() == "2"); + CHECK(list->editor()->field("name").text().peek().empty()); + CHECK(harness.count("EditRow") == edits); + list->close(); + list->open("2"); + harness.settle(); + CHECK(list->editor()->field("name").text().peek().empty()); +} + +TEST_CASE("CollectionSession: ids beyond 2^53 stay distinct in keys, cells and bodies", "[forms-engine][collection]") { + CollectionHarness harness{R"({"rows":[{"id":9007199254740993,"name":"Alpha"},{"id":9007199254740992,"name":"Beta"}]})"}; + auto const list = harness.collection(R"({"v-kind":"collection","v-title":"Rows","v-query":"ListRows","v-rowKey":"id", + "v-columns":[{"field":"id","label":"ID"},{"field":"name","label":"Name"}],"v-rowAction":{"action":"EditRow","bind":{"id":"id"}}, + "v-actions":[{"action":"DeleteRow","label":"Delete","scope":"row","bind":{"id":"id"},"confirm":true}]})"); + harness.settle(); + REQUIRE(list->rows().size() == 2); + CHECK(list->rows()[0].key == "9007199254740993"); + CHECK(list->rows()[1].key == "9007199254740992"); + CHECK(list->rows()[0].cells.front() == "9007199254740993"); + list->fire(0, "9007199254740992"); + list->confirm(); + harness.settle(); + CHECK(harness.lastBodyOf("DeleteRow") == R"({"id":9007199254740992})"); + list->fire(0, "9007199254740993"); + list->confirm(); + harness.settle(); + CHECK(harness.lastBodyOf("DeleteRow") == R"({"id":9007199254740993})"); + + CollectionHarness small{R"({"rows":[{"id":7,"name":"Small"}]})"}; + auto const plain = small.collection(R"({"v-kind":"collection","v-query":"ListRows", + "v-actions":[{"action":"DeleteRow","label":"Delete","scope":"row","bind":{"id":"id"}}]})"); + small.settle(); + plain->fire(0, "7"); + small.settle(); + CHECK(small.lastBodyOf("DeleteRow") == R"({"id":7})"); +} + +TEST_CASE("collectionView: a table of the visible columns, the actions, the editor and confirm dialogs", + "[forms-engine][collection][view]") { + CollectionHarness harness; + auto const list = harness.collection(); + harness.settle(); + std::string const drawn = view::outline(morph::forms::collectionView(*list)); + CHECK(drawn.find("Text \"Rows\" (heading)") != std::string::npos); + CHECK(drawn.find("Button \"New\"") != std::string::npos); + CHECK(drawn.find("Table [Name|Amount|]") != std::string::npos); + CHECK(drawn.find("Dialog \"EditRow\"") != std::string::npos); + CHECK(drawn.find("Dialog \"Confirm\"") != std::string::npos); + + ui::testing::RecordingBackend backend; + ui::Mounted const mounted{harness.rt, backend, morph::forms::collectionView(*list)}; + harness.settle(); + CHECK(backend.find("Text", "text", "1.5 kg").has_value()); + auto const firstDelete = backend.find(view::kButton, "label", "Delete"); + REQUIRE(firstDelete.has_value()); + backend.click(*firstDelete); + harness.settle(); + auto const yes = backend.find(view::kButton, "label", "Yes"); + REQUIRE(yes.has_value()); + backend.click(*yes); + harness.settle(); + CHECK(harness.lastBodyOf("DeleteRow") == R"({"id":1})"); + auto const open = backend.find(view::kButton, "label", "Open"); + REQUIRE(open.has_value()); + backend.click(*open); + harness.settle(); + CHECK(list->openRow() == "1"); +} + +TEST_CASE("collectionView: master-detail puts the editor beside the table", "[forms-engine][collection][view]") { + CollectionHarness harness; + std::string json{kView}; + json.replace(json.find("\"collection\""), std::string_view{"\"collection\""}.size(), "\"master-detail\""); + auto const list = harness.collection(json); + harness.settle(); + std::string const drawn = view::outline(morph::forms::collectionView(*list)); + CHECK(drawn.find("Dialog \"EditRow\"") == std::string::npos); + CHECK(drawn.find(" Row\n Table [Name|Amount|]\n Column hidden\n") != std::string::npos); + list->open("2"); + harness.settle(); + CHECK(view::outline(morph::forms::collectionView(*list)).find(" Column\n Column\n Text \"EditRow\" (heading)") != + std::string::npos); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: `fatal error: 'morph/forms/engine/collection.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `include/morph/forms/engine/collection.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/collection.hpp +/// @brief Collections: a `v-*` view document as a reactive list with row and collection actions +/// and a row editor, rendered as a table. +/// +/// Specified in `docs/spec/forms/engine.md`, "Collections". + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../attributes.hpp" +#include "../../reactive/control.hpp" +#include "../../reactive/signal.hpp" +#include "../../ui/view.hpp" +#include "detail/text.hpp" +#include "field_model.hpp" +#include "form_session.hpp" +#include "form_view.hpp" +#include "rules.hpp" + +namespace morph::forms { + +/// @brief How a collection presents its row editor. +enum class CollectionKind : std::uint8_t { + Collection, ///< `v-kind: collection` — the editor is a dialog. + MasterDetail ///< `v-kind: master-detail` — the editor sits beside the table. +}; + +/// @brief Where an action's button sits. +enum class CollectionActionScope : std::uint8_t { + Row, ///< On every row; the body is built from the row's `bind` entries. + Collection ///< Once, beside the title; the body is `{}`. +}; + +/// @brief One `v-columns` entry. +struct CollectionColumn { + /// @brief The row member shown. + std::string field{}; + /// @brief The header. + std::string label{}; + /// @brief `v-hidden`: carried in the rows, not shown. + bool hidden = false; + /// @brief `x-decimalPlaces`: the places a `{num, den}` value is shown with when it carries no `dp`. + std::optional decimalPlaces{}; + /// @brief The unit appended to a `{num, den}` value (`ExtUnits.unitUnicode`, else `unitAscii`). + std::string unit{}; +}; + +/// @brief One `v-rowAction` or `v-actions` entry. +struct CollectionAction { + /// @brief The action type executed. + std::string action{}; + /// @brief The button label (the action type when the schema gives none). + std::string label{}; + /// @brief Row or collection. + CollectionActionScope scope = CollectionActionScope::Row; + /// @brief `{actionField: rowField}` pairs, in document order. + std::vector> bind{}; + /// @brief Whether the action waits for a confirmation. + bool confirm = false; +}; + +/// @brief The immutable model of a `v-*` view document. +class CollectionModel { +public: + /// @brief Reads a view document. + /// @param viewJson The document, in the vocabulary `views::viewSchemaJson()` emits. + /// @return The model, or the error naming the malformed key. + [[nodiscard]] static std::expected fromSchema(std::string_view viewJson) { + using detail::engine::Json; + auto const dom = detail::engine::parseJson(viewJson); + if (!dom || !dom->is_object()) { + return std::unexpected(SchemaError{"", "a view document must be a JSON object"}); + } + CollectionModel model{}; + auto const* const kind = detail::engine::stringAt(*dom, "v-kind"); + if (kind != nullptr && *kind != "collection" && *kind != "master-detail") { + return std::unexpected(SchemaError{"v-kind", "\"v-kind\" must be \"collection\" or \"master-detail\""}); + } + model._kind = (kind != nullptr && *kind == "master-detail") ? CollectionKind::MasterDetail : CollectionKind::Collection; + auto const* const query = detail::engine::stringAt(*dom, "v-query"); + if (query == nullptr) { + return std::unexpected(SchemaError{"v-query", "a view document needs a \"v-query\""}); + } + model._query = *query; + auto const* const title = detail::engine::stringAt(*dom, "v-title"); + model._title = title != nullptr ? *title : model._query; + auto const* const rowKey = detail::engine::stringAt(*dom, "v-rowKey"); + model._rowKey = rowKey != nullptr ? *rowKey : std::string{"id"}; + if (Json const* const columns = detail::engine::member(*dom, "v-columns"); columns != nullptr && columns->is_array()) { + for (Json const& entry : columns->get_array()) { + CollectionColumn column{}; + auto const* const field = detail::engine::stringAt(entry, "field"); + if (field == nullptr) { + return std::unexpected(SchemaError{"v-columns", "a column needs a \"field\""}); + } + column.field = *field; + auto const* const label = detail::engine::stringAt(entry, "label"); + column.label = label != nullptr ? *label : column.field; + Json const* const hidden = detail::engine::member(entry, "v-hidden"); + column.hidden = hidden != nullptr && hidden->is_boolean() && hidden->get(); + if (auto const places = detail::engine::SchemaReader::unsignedOf(detail::engine::member(entry, "x-decimalPlaces"))) { + column.decimalPlaces = static_cast(std::min(*places, math::kMaxDecimalPlaces)); + } + if (Json const* const units = detail::engine::member(entry, "ExtUnits")) { + auto const* const unicode = detail::engine::stringAt(*units, "unitUnicode"); + auto const* const ascii = detail::engine::stringAt(*units, "unitAscii"); + column.unit = unicode != nullptr && !unicode->empty() ? *unicode : (ascii != nullptr ? *ascii : std::string{}); + } + model._columns.push_back(std::move(column)); + } + } + if (Json const* const rowAction = detail::engine::member(*dom, "v-rowAction")) { + auto action = actionOf(*rowAction, "v-rowAction"); + if (!action) { + return std::unexpected(action.error()); + } + model._rowAction = *std::move(action); + } + if (Json const* const actions = detail::engine::member(*dom, "v-actions"); actions != nullptr && actions->is_array()) { + for (Json const& entry : actions->get_array()) { + auto action = actionOf(entry, "v-actions"); + if (!action) { + return std::unexpected(action.error()); + } + model._actions.push_back(*std::move(action)); + } + } + return model; + } + + /// @brief How the row editor is presented. + /// @return The kind. + [[nodiscard]] CollectionKind kind() const noexcept { return _kind; } + + /// @brief `v-title`, or the query's type. + /// @return The title. + [[nodiscard]] std::string_view title() const noexcept { return _title; } + + /// @brief `v-query`: the action whose result rows are listed. + /// @return The action type. + [[nodiscard]] std::string_view query() const noexcept { return _query; } + + /// @brief `v-rowKey`: the row member that identifies a row (`id` by default). + /// @return The member name. + [[nodiscard]] std::string_view rowKey() const noexcept { return _rowKey; } + + /// @brief `v-columns`. + /// @return The columns. + [[nodiscard]] std::span columns() const noexcept { return _columns; } + + /// @brief `v-rowAction`: the editor's action and its bind entries. + /// @return The row action, or `std::nullopt`. + [[nodiscard]] std::optional const& rowAction() const noexcept { return _rowAction; } + + /// @brief `v-actions`. + /// @return The actions. + [[nodiscard]] std::span actions() const noexcept { return _actions; } + +private: + CollectionModel() = default; + + [[nodiscard]] static std::expected actionOf(detail::engine::Json const& node, + std::string const& path) { + auto const* const type = detail::engine::stringAt(node, "action"); + if (type == nullptr) { + return std::unexpected(SchemaError{path, "an action needs an \"action\""}); + } + CollectionAction action{}; + action.action = *type; + auto const* const label = detail::engine::stringAt(node, "label"); + action.label = label != nullptr ? *label : action.action; + auto const* const scope = detail::engine::stringAt(node, "scope"); + action.scope = (scope != nullptr && *scope == "collection") ? CollectionActionScope::Collection : CollectionActionScope::Row; + auto const* const confirm = detail::engine::member(node, "confirm"); + action.confirm = confirm != nullptr && confirm->is_boolean() && confirm->get(); + if (auto const* const bind = detail::engine::member(node, "bind"); bind != nullptr && bind->is_object()) { + for (auto const& [field, rowField] : bind->get_object()) { + if (auto const* const text = rowField.get_if()) { + action.bind.emplace_back(field, *text); + } + } + } + return action; + } + + CollectionKind _kind = CollectionKind::Collection; + std::string _title; + std::string _query; + std::string _rowKey; + std::vector _columns; + std::optional _rowAction; + std::vector _actions; +}; + +/// @brief One listed row. +struct CollectionRow { + /// @brief The row key's exact text. + std::string key{}; + /// @brief The row as JSON, every integer digit kept. + std::string json{}; + /// @brief The visible columns' formatted cells. + std::vector cells{}; + /// @brief Member-wise equality. + /// @param other The other row. + /// @return `true` when key, JSON and cells match. + bool operator==(CollectionRow const& other) const = default; +}; + +/// @brief A confirm-guarded action waiting for an answer. +struct PendingConfirm { + /// @brief Index into `CollectionModel::actions()`. + std::size_t action = 0; + /// @brief The row it was fired on. + std::string rowKey{}; + /// @brief Member-wise equality. + /// @param other The other pending action. + /// @return `true` when action and row match. + bool operator==(PendingConfirm const& other) const = default; +}; + +/// @brief Finds an action's schema JSON by its type id (a registry, a map, a remote descriptor). +using SchemaLookup = std::function(std::string_view actionType)>; + +namespace detail::engine { + +/// @brief A cell's text: a `{num, den}` value exactly at its `dp` (else the column's places) with +/// the column's unit, anything else as `displayText`, absent as empty. +/// @param row The row. +/// @param column The column. +/// @return The text. +[[nodiscard]] inline std::string cellText(Json const& row, CollectionColumn const& column) { + Json const* const value = member(row, column.field); + if (value == nullptr || value->is_null()) { + return {}; + } + if (value->is_object()) { + auto const num = SchemaReader::signedOf(member(*value, "num")); + auto const den = SchemaReader::signedOf(member(*value, "den")); + if (num && den && *den != 0) { + auto const places = SchemaReader::unsignedOf(member(*value, "dp")); + std::uint32_t const decimals = places ? static_cast(*places) : column.decimalPlaces.value_or(0); + std::string text = decimalText(math::Rational{math::Numerator{*num}, math::Denominator{*den}, math::DecimalPlaces{0}}, decimals); + return column.unit.empty() ? text : text + " " + column.unit; + } + } + return displayText(*value); +} + +} // namespace detail::engine + +/// @brief The reactive state of one collection: the list, the actions, the confirm step and the +/// row editor. +/// +/// Non-copyable and non-movable. Destroy a mounted view of it before the session. +class CollectionSession { +public: + /// @param runtime The runtime. Borrowed. + /// @param model The view model. + /// @param schemas Finds the row action's schema; an unknown or malformed one means no editor. + /// @param submit Executes the query and every action. + /// @param choices Fetches the editor's Choice options. + /// @param options The editor's session options. + CollectionSession(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, CollectionModel model, SchemaLookup const& schemas, + Submitter submit, ChoiceFetcher choices, FormSessionOptions options = {}) + : _rt{&runtime}, + _model{std::move(model)}, + _submit{std::move(submit)}, + _openRow{runtime, std::nullopt}, + _pending{runtime, std::nullopt}, + _list{runtime, [this](std::string const& body) { return _submit(_model.query(), body); }, + [] { return std::optional{"{}"}; }}, + _rows{runtime, [this] { return parseRows(); }}, + _actions{runtime, [this](Call call) { return _submit(call.action, std::move(call.body)); }, + reactive::MutationOptions{.invalidates = {&_list}}} { + if (_model.rowAction().has_value() && schemas) { + if (auto const schema = schemas(_model.rowAction()->action)) { + if (auto editorModel = FormModel::fromSchema(_model.rowAction()->action, *schema)) { + _editor = std::make_unique(runtime, *std::move(editorModel), _submit, std::move(choices), + std::move(options)); + _editorWatch = std::make_unique(runtime, [this] { watchEditor(); }); + } + } + } + } + + ~CollectionSession() = default; + CollectionSession(CollectionSession const&) = delete; + CollectionSession& operator=(CollectionSession const&) = delete; + CollectionSession(CollectionSession&&) = delete; + CollectionSession& operator=(CollectionSession&&) = delete; + + /// @brief The view model. + /// @return The model. + [[nodiscard]] CollectionModel const& model() const noexcept { return _model; } + + /// @brief The listed rows, from the last successful query. Tracked. + /// @return The rows. + [[nodiscard]] std::vector const& rows() const { return _rows.get(); } + + /// @brief Whether the list is loading. Tracked. + /// @return `true` while the query is in flight. + [[nodiscard]] bool loading() const { return _list.pending(); } + + /// @brief The list's last failure. Tracked. + /// @return The exception, or null. + [[nodiscard]] std::exception_ptr listError() const { return _list.error(); } + + /// @brief Reloads the list. + void refresh() { _list.refetch(); } + + /// @brief Fires `model().actions()[action]` — on @p rowKey for a row action; a confirm-guarded + /// one waits in `pendingConfirm()`. + /// @param action Index into `model().actions()`. + /// @param rowKey The row, for a row action. + void fire(std::size_t action, std::string_view rowKey = {}) { + CollectionAction const& entry = detail::engine::elementAt(_model.actions(), action); + if (entry.confirm) { + _pending.set(PendingConfirm{action, std::string{rowKey}}); + return; + } + run(entry, rowKey); + } + + /// @brief The action waiting for confirmation. Tracked. + /// @return It, or `std::nullopt`. + [[nodiscard]] std::optional const& pendingConfirm() const { return _pending.get(); } + + /// @brief Runs the pending action. + void confirm() { + std::optional const pending = _pending.peek(); + _pending.set(std::nullopt); + if (pending.has_value()) { + run(detail::engine::elementAt(_model.actions(), pending->action), pending->rowKey); + } + } + + /// @brief Drops the pending action. + void cancelConfirm() { _pending.set(std::nullopt); } + + /// @brief Opens the editor on a row: reset, then prefilled from the row action's bind entries. + /// Programmatic: nothing is submitted. + /// @param rowKey The row. + void open(std::string_view rowKey) { + if (!_editor) { + return; + } + std::string const body = bindBody(*_model.rowAction(), rowKey); + _rt->batch([&] { + _openRow.set(std::string{rowKey}); + _editor->reset(); + _editor->prefill(body); + }); + } + + /// @brief Closes the editor. + void close() { _openRow.set(std::nullopt); } + + /// @brief The row the editor is open on. Tracked. + /// @return Its key, or `std::nullopt`. + [[nodiscard]] std::optional const& openRow() const { return _openRow.get(); } + + /// @brief The row editor. + /// @return The editor, or null when the view has no row action or its schema is unknown. + [[nodiscard]] FormSession* editor() const noexcept { return _editor.get(); } + + /// @brief Whether an action is in flight. Tracked. + /// @return `true` while one is. + [[nodiscard]] bool actionPending() const { return _actions.pending(); } + + /// @brief The last action failure. Tracked. + /// @return The exception, or null. + [[nodiscard]] std::exception_ptr actionError() const { return _actions.error(); } + +private: + struct Call { + std::string action; + std::string body; + }; + + [[nodiscard]] std::vector parseRows() const { + using detail::engine::Json; + std::vector rows; + std::optional const& reply = _list.value(); + auto const dom = reply.has_value() ? detail::engine::parseJson(*reply) : std::nullopt; + if (!dom) { + return rows; + } + Json const* list = dom->is_array() ? &*dom : nullptr; + if (list == nullptr && dom->is_object()) { + for (auto const& [ignored, candidate] : dom->get_object()) { + if (candidate.is_array()) { + list = &candidate; + break; + } + } + } + if (list == nullptr) { + return rows; + } + for (Json const& row : list->get_array()) { + CollectionRow entry{}; + Json const* const key = detail::engine::member(row, _model.rowKey()); + entry.key = key == nullptr ? std::to_string(rows.size()) : detail::engine::displayText(*key); + entry.json = detail::engine::toJson(row); + for (CollectionColumn const& column : _model.columns()) { + if (!column.hidden) { + entry.cells.push_back(detail::engine::cellText(row, column)); + } + } + rows.push_back(std::move(entry)); + } + return rows; + } + + [[nodiscard]] std::string bindBody(CollectionAction const& action, std::string_view rowKey) const { + std::vector const& rows = _rows.peek(); + auto const found = std::ranges::find(rows, rowKey, &CollectionRow::key); + auto const row = found == rows.end() ? std::nullopt : detail::engine::parseJson(found->json); + std::string body = "{"; + bool first = true; + for (auto const& [field, rowField] : action.bind) { + detail::engine::Json const* const value = row ? detail::engine::member(*row, rowField) : nullptr; + body += (first ? "" : ",") + detail::engine::quote(field) + ":" + + (value == nullptr ? std::string{"null"} : detail::engine::toJson(*value)); + first = false; + } + return body + "}"; + } + + void run(CollectionAction const& action, std::string_view rowKey) { + std::string body = action.scope == CollectionActionScope::Collection ? std::string{"{}"} : bindBody(action, rowKey); + _actions.run(Call{action.action, std::move(body)}); + } + + void watchEditor() { + bool const pending = _editor->pending(); + bool const succeeded = _editorWasPending && !pending && _editor->lastError() == nullptr && + _editor->lastReply().has_value(); + _editorWasPending = pending; + if (succeeded) { + _rt->batch([&] { + _openRow.set(std::nullopt); + _list.refetch(); + }); + } + } + + reactive::Runtime* _rt; + CollectionModel _model; + Submitter _submit; + reactive::Signal> _openRow; + reactive::Signal> _pending; + reactive::Query _list; + reactive::Computed> _rows; + reactive::Mutation _actions; + std::unique_ptr _editor; + bool _editorWasPending = false; + std::unique_ptr _editorWatch; +}; + +/// @brief What `collectionView` draws around the table. +struct CollectionViewOptions { + /// @brief The editor form's view options. + FormViewOptions editor{}; + /// @brief The row button that opens the editor. + std::string openLabel = "Open"; + /// @brief The confirm dialog's title. + std::string confirmTitle = "Confirm"; + /// @brief The confirm dialog's question. + std::string confirmMessage = "Are you sure?"; + /// @brief The confirm dialog's accepting button. + std::string confirmLabel = "Yes"; + /// @brief The confirm dialog's declining button. + std::string cancelLabel = "No"; +}; + +/// @brief Renders a collection: the title with the collection actions and a busy marker, a table +/// of the visible columns with each row's Open and row-action buttons, the last action +/// error, the editor (a dialog, or beside the table for master-detail) and the confirm dialog. +/// @param session The collection session; mount the result after it and destroy the mount before it. +/// @param options Labels and the editor's view options. +/// @return The view. +[[nodiscard]] inline ui::Node collectionView(CollectionSession& session, CollectionViewOptions options = {}) { + CollectionSession* const list = &session; + CollectionModel const& model = session.model(); + std::vector header{ui::text(ui::Text{.text = std::string{model.title()}, .role = ui::TextRole::Heading}), + ui::busy(ui::Busy{.active = [list] { return list->loading(); }, .label = std::string{}})}; + for (std::size_t i = 0; i < model.actions().size(); ++i) { + CollectionAction const& action = detail::engine::elementAt(model.actions(), i); + if (action.scope == CollectionActionScope::Collection) { + header.push_back(ui::button(ui::Button{.label = action.label, .onClick = [list, i] { list->fire(i); }})); + } + } + std::vector columns; + for (CollectionColumn const& column : model.columns()) { + if (!column.hidden) { + columns.push_back(ui::TableColumn{.label = column.label}); + } + } + bool const rowButtons = session.editor() != nullptr || + std::ranges::any_of(model.actions(), [](auto const& action) { return action.scope == CollectionActionScope::Row; }); + if (rowButtons) { + columns.push_back(ui::TableColumn{.label = std::string{}}); + } + std::size_t const visible = rowButtons ? columns.size() - 1 : columns.size(); + std::string const openLabel = options.openLabel; + ui::Node table = ui::table( + std::move(columns), [list] { return list->rows(); }, [](CollectionRow const& row) { return ui::Key{row.key}; }, + [list, visible, rowButtons, openLabel](reactive::Signal const& slot) { + reactive::Signal const* const row = &slot; + std::vector cells; + for (std::size_t i = 0; i < visible; ++i) { + cells.push_back(ui::text(ui::Text{.text = [row, i] { return row->get().cells.at(i); }})); + } + if (rowButtons) { + std::vector buttons; + if (list->editor() != nullptr) { + buttons.push_back(ui::button(ui::Button{.label = openLabel, .onClick = [list, row] { list->open(row->peek().key); }})); + } + for (std::size_t actionIndex = 0; actionIndex < list->model().actions().size(); ++actionIndex) { + CollectionAction const& action = detail::engine::elementAt(list->model().actions(), actionIndex); + if (action.scope == CollectionActionScope::Row) { + buttons.push_back(ui::button(ui::Button{.label = action.label, + .onClick = [list, row, actionIndex] { list->fire(actionIndex, row->peek().key); }})); + } + } + cells.push_back(ui::row(ui::Row{.children = std::move(buttons), .gap = 1})); + } + return cells; + }, + ui::TableOptions{.onActivate = [list](ui::Key key) { + if (auto const* const text = std::get_if(&key)) { + list->open(*text); + } + }}); + std::vector children{ui::row(ui::Row{.children = std::move(header), .gap = 1})}; + if (session.editor() != nullptr && model.kind() == CollectionKind::MasterDetail) { + children.push_back(ui::row(ui::Row{ + .children = {std::move(table), + ui::column(ui::Column{.children = {formView(*session.editor(), options.editor)}, + .common = {.visible = [list] { return list->openRow().has_value(); }}})}, + .gap = 2})); + } else { + children.push_back(std::move(table)); + } + children.push_back(ui::text(ui::Text{.text = [list] { return reactive::errorMessage(list->actionError()); }, + .role = ui::TextRole::Error, + .common = {.visible = [list] { return list->actionError() != nullptr; }}})); + if (session.editor() != nullptr && model.kind() == CollectionKind::Collection) { + children.push_back(ui::dialog(ui::Dialog{.open = [list] { return list->openRow().has_value(); }, + .title = model.rowAction()->action, + .child = formView(*session.editor(), options.editor), + .onDismiss = [list] { list->close(); }})); + } + children.push_back(ui::dialog(ui::Dialog{ + .open = [list] { return list->pendingConfirm().has_value(); }, + .title = options.confirmTitle, + .child = ui::column(ui::Column{ + .children = {ui::text(ui::Text{.text = options.confirmMessage}), + ui::row(ui::Row{.children = {ui::button(ui::Button{.label = options.confirmLabel, .onClick = [list] { list->confirm(); }}), + ui::button(ui::Button{.label = options.cancelLabel, .onClick = [list] { list->cancelConfirm(); }})}, + .gap = 1})}, + .gap = 1}), + .onDismiss = [list] { list->cancelConfirm(); }})); + return ui::column(ui::Column{.children = std::move(children), .gap = 1}); +} + +} // namespace morph::forms +``` + +Register the header and the test as listed under **Files**. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][collection]"` +Expected: PASS, 10 test cases. + +Mutation check: remove `reactive::MutationOptions{.invalidates = {&_list}}` from `_actions`' construction. Expected: +FAIL in "a confirmed row action fires its bound body and reloads…" (`harness.count("ListRows") == 2` is 1). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/collection.hpp tests/test_forms_engine_collection.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): collections — a v-* view as a reactive list, actions and an editor + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 14: Wizards — `WizardModel`, `WizardSession`, `wizardView` + +A wizard document (`w-*`, `flows::wizardSchemaJson()`) becomes one `FormSession` per step, all kept alive, so +Back and Next lose nothing. A step is **done** when its last submission succeeded. Every successful submission +records resolved values the way `FlowSession` does (`include/morph/forms/flows.hpp`, `captureResult`): each member +of the submitted body, then each member of the reply — the reply winning on a name collision — under +`.`, as JSON. Entering a step by Next applies its `prefill` (`{field: "Action.member"}`) from +those values with `FormSession::assign`, as JSON values (the QML wizard re-typed them as text, which put quotes in a +string); a step the prefill leaves ready submits in automatic mode, as entering it in the QML wizard did. + +**Files:** +- Create: `include/morph/forms/engine/wizard.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/forms/engine/collection.hpp`, add + `include/morph/forms/engine/wizard.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_wizard.cpp` after `test_forms_engine_collection.cpp` +- Test: `tests/test_forms_engine_wizard.cpp` + +**Interfaces:** +- Consumes: Tasks 6, 12, 13 (`SchemaLookup`); `forms::i18n::wizardTitleKey`, `wizardStepTitleKey` + (`include/morph/forms/i18n.hpp`); `ui::switchOf`, `ui::Switch`, `ui::SwitchCase`. +- Produces (`morph::forms`): `WizardStepSpec{action, title, prefill}`; `WizardModel` (`fromSchema(std::string_view + wizardId, std::string_view wizardJson) -> std::expected`, `id()`, `title()`, + `steps()`); `WizardSession` (`WizardSession(reactive::Runtime&, WizardModel, SchemaLookup const&, Submitter, + ChoiceFetcher, FormSessionOptions = {})`, `model()`, `current()`, `stepCount()`, `step(std::size_t) -> + FormSession&`, `stepDone(std::size_t)`, `canBack()`, `canNext()`, `onLastStep()`, `next()`, `back()`, + `resolved(std::string_view path) -> std::optional`, `title()`, `stepTitle(std::size_t)`); the + contract's `wizardView(WizardSession&) -> ui::Node`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_wizard.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "forms_engine_support.hpp" +#include "forms_engine_view_support.hpp" + +namespace { + +using morph::forms::WizardModel; +using morph::forms::WizardSession; +using morph::test::formsengine::Call; +using morph::test::formsengine::Harness; +namespace ui = morph::ui; +namespace view = morph::test::formsview; + +constexpr std::string_view kWizard = R"({"w-title":"Test flow","w-steps":[{"action":"WizStepOne","title":"One"}, + {"action":"WizStepTwo","title":"Two","prefill":{"refId":"WizStepOne.id","note":"WizStepOne.label"}}]})"; + +[[nodiscard]] std::optional stepSchemas(std::string_view action) { + if (action == "WizStepOne") { + return R"({"properties":{"label":{"type":"string","x-order":0}},"required":["label"]})"; + } + if (action == "WizStepTwo") { + return R"({"properties":{"refId":{"type":"integer","x-order":0},"note":{"type":["string","null"],"x-order":1}},"required":["refId"]})"; + } + return std::nullopt; +} + +struct WizardHarness : Harness { + WizardHarness() { + server.replyWith([](Call const& call) -> std::optional { + if (call.action == "WizStepOne") { + return R"({"id":1,"label":"x"})"; + } + return R"({"summary":"ok"})"; + }); + } + + [[nodiscard]] std::unique_ptr wizard() { + auto model = WizardModel::fromSchema("TestWizard", kWizard); + REQUIRE(model.has_value()); + return std::make_unique(rt, *std::move(model), stepSchemas, server.submitter(), server.fetcher()); + } +}; + +} // namespace + +TEST_CASE("WizardModel: the w-* document reads, and a malformed one is a SchemaError", "[forms-engine][wizard]") { + auto const model = WizardModel::fromSchema("TestWizard", kWizard); + REQUIRE(model.has_value()); + CHECK(model->id() == "TestWizard"); + CHECK(model->title() == "Test flow"); + REQUIRE(model->steps().size() == 2); + CHECK(model->steps()[1].action == "WizStepTwo"); + CHECK(model->steps()[1].prefill == + std::vector>{{"refId", "WizStepOne.id"}, {"note", "WizStepOne.label"}}); + CHECK(WizardModel::fromSchema("W", R"({"w-title":"x"})").error().path == "w-steps"); + CHECK(WizardModel::fromSchema("W", R"({"w-steps":[{"title":"no action"}]})").error().path == "w-steps[0]"); +} + +TEST_CASE("WizardSession: Next waits for the step's successful submission; Back always returns", + "[forms-engine][wizard]") { + WizardHarness harness; + auto const wizard = harness.wizard(); + CHECK(wizard->current() == 0); + CHECK(wizard->stepCount() == 2); + CHECK_FALSE(wizard->canBack()); + CHECK_FALSE(wizard->canNext()); + wizard->next(); + CHECK(wizard->current() == 0); + harness.type(wizard->step(0), "label", "sample"); + CHECK(wizard->stepDone(0)); + CHECK(wizard->canNext()); + wizard->next(); + harness.settle(); + CHECK(wizard->current() == 1); + CHECK(wizard->onLastStep()); + CHECK(wizard->canBack()); + CHECK_FALSE(wizard->canNext()); + wizard->back(); + CHECK(wizard->current() == 0); +} + +TEST_CASE("WizardSession: a failed submission leaves the step not done", "[forms-engine][wizard]") { + WizardHarness harness; + harness.server.replyWith([](Call const&) { return std::nullopt; }); + auto const wizard = harness.wizard(); + harness.type(wizard->step(0), "label", "sample"); + harness.server.rejectSubmit(0, "refused"); + harness.settle(); + CHECK_FALSE(wizard->stepDone(0)); + CHECK_FALSE(wizard->canNext()); +} + +TEST_CASE("WizardSession: entering a step applies its prefill as JSON values, the reply winning", + "[forms-engine][wizard]") { + WizardHarness harness; + auto const wizard = harness.wizard(); + harness.type(wizard->step(0), "label", "sample"); + CHECK(wizard->resolved("WizStepOne.id") == "1"); + CHECK(wizard->resolved("WizStepOne.label") == R"("x")"); // the reply's, not the draft's + CHECK_FALSE(wizard->resolved("WizStepOne.nope").has_value()); + wizard->next(); + harness.settle(); + CHECK(wizard->step(1).field("refId").text().peek() == "1"); + CHECK(wizard->step(1).field("note").text().peek() == "x"); // a string arrives unquoted + REQUIRE(harness.server.submits().size() == 2); // the filled step submits on entry + CHECK(harness.server.submits()[1].action == "WizStepTwo"); + CHECK(harness.server.submits()[1].body == R"({"refId":1,"note":"x"})"); + CHECK(wizard->stepDone(1)); +} + +TEST_CASE("WizardSession: an unknown step schema is refused at construction", "[forms-engine][wizard]") { + Harness harness; + auto model = WizardModel::fromSchema("W", R"({"w-steps":[{"action":"Missing","title":"?"}]})"); + REQUIRE(model.has_value()); + CHECK_THROWS_AS(WizardSession(harness.rt, *std::move(model), stepSchemas, harness.server.submitter(), harness.server.fetcher()), + std::invalid_argument); +} + +TEST_CASE("wizardView: the title with the position, the step's form, and Back/Next bound to the session", + "[forms-engine][wizard][view]") { + WizardHarness harness; + auto const wizard = harness.wizard(); + std::string const drawn = view::outline(morph::forms::wizardView(*wizard)); + CHECK(drawn.find(" Text \"Test flow (1 / 2)\" (heading)\n Text \"One\" (muted)\n Switch\n") != std::string::npos); + CHECK(drawn.find(" Button \"Back\" disabled\n Button \"Next\" disabled\n") != std::string::npos); + CHECK(drawn.find(" Text \"Last step — fill it in to finish\" (muted) hidden\n") != std::string::npos); + + ui::testing::RecordingBackend backend; + ui::Mounted const mounted{harness.rt, backend, morph::forms::wizardView(*wizard)}; + harness.settle(); + backend.edit(backend.all(view::kTextInput).front(), "sample"); + harness.settle(); + auto const next = backend.find(view::kButton, "label", "Next"); + REQUIRE(next.has_value()); + CHECK(backend.prop(*next, "enabled") == "true"); + backend.click(*next); + harness.settle(); + CHECK(wizard->current() == 1); + CHECK(backend.find("Text", "text", "Test flow (2 / 2)").has_value()); + CHECK(backend.prop(backend.all(view::kTextInput).front(), "text") == "1"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: `fatal error: 'morph/forms/engine/wizard.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `include/morph/forms/engine/wizard.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/wizard.hpp +/// @brief Wizards: a `w-*` document as one `FormSession` per step, sequenced by successful +/// submissions, with prefill from earlier steps' resolved values. +/// +/// Specified in `docs/spec/forms/engine.md`, "Wizards". + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../attributes.hpp" +#include "../../core/callback_scope.hpp" +#include "../../core/completion.hpp" +#include "../../reactive/signal.hpp" +#include "../../render/i18n.hpp" +#include "../../ui/view.hpp" +#include "../i18n.hpp" +#include "collection.hpp" +#include "detail/text.hpp" +#include "form_session.hpp" +#include "form_view.hpp" + +namespace morph::forms { + +/// @brief One `w-steps` entry. +struct WizardStepSpec { + /// @brief The step's action type. + std::string action{}; + /// @brief The step's title. + std::string title{}; + /// @brief `{field: "Action.member"}`: what entering the step fills in, in document order. + std::vector> prefill{}; +}; + +/// @brief The immutable model of a `w-*` wizard document. +class WizardModel { +public: + /// @brief Reads a wizard document. + /// @param wizardId The wizard's registered id (`BRIDGE_REGISTER_WIZARD`), the i18n key stem. + /// @param wizardJson The document, in the vocabulary `flows::wizardSchemaJson()` emits. + /// @return The model, or the error naming the malformed key. + [[nodiscard]] static std::expected fromSchema(std::string_view wizardId, + std::string_view wizardJson) { + using detail::engine::Json; + auto const dom = detail::engine::parseJson(wizardJson); + if (!dom || !dom->is_object()) { + return std::unexpected(SchemaError{"", "a wizard document must be a JSON object"}); + } + WizardModel model{}; + model._id = wizardId; + auto const* const title = detail::engine::stringAt(*dom, "w-title"); + model._title = title != nullptr ? *title : std::string{wizardId}; + Json const* const steps = detail::engine::member(*dom, "w-steps"); + if (steps == nullptr || !steps->is_array()) { + return std::unexpected(SchemaError{"w-steps", "a wizard document needs a \"w-steps\" array"}); + } + std::size_t index = 0; + for (Json const& entry : steps->get_array()) { + auto const* const action = detail::engine::stringAt(entry, "action"); + if (action == nullptr) { + return std::unexpected(SchemaError{"w-steps[" + std::to_string(index) + "]", "a step needs an \"action\""}); + } + WizardStepSpec step{}; + step.action = *action; + auto const* const stepTitle = detail::engine::stringAt(entry, "title"); + step.title = stepTitle != nullptr ? *stepTitle : step.action; + if (Json const* const prefill = detail::engine::member(entry, "prefill"); prefill != nullptr && prefill->is_object()) { + for (auto const& [field, path] : prefill->get_object()) { + if (auto const* const text = path.get_if()) { + step.prefill.emplace_back(field, *text); + } + } + } + model._steps.push_back(std::move(step)); + ++index; + } + return model; + } + + /// @brief The wizard's id. + /// @return The id passed to `fromSchema`. + [[nodiscard]] std::string_view id() const noexcept { return _id; } + + /// @brief `w-title`, or the id. + /// @return The title. + [[nodiscard]] std::string_view title() const noexcept { return _title; } + + /// @brief The steps, in order. + /// @return The steps. + [[nodiscard]] std::span steps() const noexcept { return _steps; } + +private: + WizardModel() = default; + + std::string _id; + std::string _title; + std::vector _steps; +}; + +/// @brief The reactive state of one wizard: its steps' sessions, the position, what each step resolved. +/// +/// Non-copyable and non-movable. Destroy a mounted view of it before the session. +class WizardSession { +public: + /// @param runtime The runtime. Borrowed. + /// @param model The wizard model. + /// @param schemas Finds each step's schema. + /// @param submit Executes every step's action. + /// @param choices Fetches the steps' Choice options. + /// @param options Every step's session options. + /// @throws std::invalid_argument when a step's schema is unknown or malformed. + WizardSession(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, WizardModel model, SchemaLookup const& schemas, + Submitter submit, ChoiceFetcher choices, FormSessionOptions options = {}) + : _rt{&runtime}, _model{std::move(model)}, _submit{std::move(submit)}, _current{runtime, std::size_t{0}} { + for (std::size_t i = 0; i < _model.steps().size(); ++i) { + WizardStepSpec const& spec = detail::engine::elementAt(_model.steps(), i); + auto const schema = schemas ? schemas(spec.action) : std::nullopt; + auto stepModel = schema ? FormModel::fromSchema(spec.action, *schema) + : std::expected{std::unexpected(SchemaError{"", "unknown"})}; + if (!stepModel) { + throw std::invalid_argument{"WizardSession: no usable schema for step '" + spec.action + "'"}; + } + _done.push_back(std::make_unique>(runtime, false)); + _steps.push_back(std::make_unique( + runtime, *std::move(stepModel), + [this, i](std::string_view action, std::string body) { return relay(i, action, std::move(body)); }, + choices, options)); + } + } + + ~WizardSession() = default; + WizardSession(WizardSession const&) = delete; + WizardSession& operator=(WizardSession const&) = delete; + WizardSession(WizardSession&&) = delete; + WizardSession& operator=(WizardSession&&) = delete; + + /// @brief The wizard model. + /// @return The model. + [[nodiscard]] WizardModel const& model() const noexcept { return _model; } + + /// @brief The current step. Tracked. + /// @return Its index. + [[nodiscard]] std::size_t current() const { return _current.get(); } + + /// @brief How many steps there are. + /// @return The count. + [[nodiscard]] std::size_t stepCount() const noexcept { return _steps.size(); } + + /// @brief A step's form session. + /// @param index The step. + /// @return Its session. + [[nodiscard]] FormSession& step(std::size_t index) const { return *_steps.at(index); } + + /// @brief Whether a step's last submission succeeded. Tracked. + /// @param index The step. + /// @return `true` when done. + [[nodiscard]] bool stepDone(std::size_t index) const { return _done.at(index)->get(); } + + /// @brief Whether Back is possible. Tracked. + /// @return `true` past the first step. + [[nodiscard]] bool canBack() const { return current() > 0; } + + /// @brief Whether Next is possible. Tracked. + /// @return `true` when the current step is done and is not the last. + [[nodiscard]] bool canNext() const { return current() + 1 < _steps.size() && stepDone(current()); } + + /// @brief Whether the current step is the last. Tracked. + /// @return `true` on the last step. + [[nodiscard]] bool onLastStep() const { return current() + 1 >= _steps.size(); } + + /// @brief Advances when `canNext()`, applying the next step's prefill. + void next() { + if (!_rt->untracked([&] { return canNext(); })) { + return; + } + std::size_t const target = _current.peek() + 1; + _current.set(target); + FormSession& session = *_steps.at(target); + for (auto const& [field, path] : detail::engine::elementAt(_model.steps(), target).prefill) { + if (auto const value = resolved(path)) { + session.assign(field, *value); + } + } + if (session.submitMode() == SubmitMode::Automatic && _rt->untracked([&] { return session.ready(); })) { + session.submit(); + } + } + + /// @brief Returns to the previous step, its values as they were. + void back() { + if (_current.peek() > 0) { + _current.set(_current.peek() - 1); + } + } + + /// @brief A value an earlier submission resolved. + /// @param path `.`. + /// @return The member's JSON, or `std::nullopt`. + [[nodiscard]] std::optional resolved(std::string_view path) const { + auto const found = _resolved.find(path); + return found == _resolved.end() ? std::nullopt : std::optional{found->second}; + } + + /// @brief The wizard's title, resolved through the translations (`.title`). + /// @return The title. + [[nodiscard]] std::string title() const { + auto const& options = _steps.empty() ? FormSessionOptions{} : _steps.front()->options(); + return render::resolveText(options.translations, options.bcp47, std::nullopt, i18n::wizardTitleKey(_model.id()), + _model.title()); + } + + /// @brief A step's title, resolved through the translations (`.step..title`). + /// @param index The step. + /// @return The title. + [[nodiscard]] std::string stepTitle(std::size_t index) const { + auto const& options = _steps.at(index)->options(); + return render::resolveText(options.translations, options.bcp47, std::nullopt, + i18n::wizardStepTitleKey(_model.id(), index), + detail::engine::elementAt(_model.steps(), index).title); + } + +private: + [[nodiscard]] async::Completion relay(std::size_t index, std::string_view action, std::string body) { + auto [completion, promise] = async::Completion::makeSettleable(&_rt->owner()); + auto settle = std::make_shared::Promise>(std::move(promise)); + std::string const submitted = body; + std::string const type{action}; + _submit(action, std::move(body)) + .then(_callbacks, + [this, index, settle, submitted, type](std::string const& reply) { + _rt->batch([&] { + record(type, submitted); + record(type, reply); + _done.at(index)->set(true); + }); + settle->resolve(reply); + }) + .onError(_callbacks, [this, index, settle](std::exception_ptr error) { + _done.at(index)->set(false); + settle->reject(std::move(error)); + }); + return std::move(completion); + } + + void record(std::string const& action, std::string const& json) { + auto const dom = detail::engine::parseJson(json); + if (!dom || !dom->is_object()) { + return; + } + for (auto const& [name, value] : dom->get_object()) { + _resolved.insert_or_assign(action + "." + name, detail::engine::toJson(value)); + } + } + + reactive::Runtime* _rt; + WizardModel _model; + Submitter _submit; + reactive::Signal _current; + std::vector>> _done; + std::map> _resolved; + // Before the steps: a step ready as constructed submits from its constructor through `relay`. + async::CallbackScope _callbacks; + std::vector> _steps; +}; + +/// @brief Renders a wizard: the title with the position, the current step's title and form, and +/// Back / Next bound to the session. +/// @param session The wizard session; mount the result after it and destroy the mount before it. +/// @return The view. +[[nodiscard]] inline ui::Node wizardView(WizardSession& session) { + WizardSession* const wizard = &session; + std::vector cases; + for (std::size_t i = 0; i < session.stepCount(); ++i) { + cases.push_back(ui::SwitchCase{.key = ui::Key{static_cast(i)}, .node = formView(session.step(i))}); + } + return ui::column(ui::Column{ + .children = {ui::text(ui::Text{.text = [wizard] { + return wizard->title() + " (" + std::to_string(wizard->current() + 1) + " / " + + std::to_string(wizard->stepCount()) + ")"; + }, + .role = ui::TextRole::Heading}), + ui::text(ui::Text{.text = [wizard] { return wizard->stepTitle(wizard->current()); }, .role = ui::TextRole::Muted}), + ui::switchOf(ui::Switch{.selector = [wizard] { return ui::Key{static_cast(wizard->current())}; }, + .cases = std::move(cases)}), + ui::row(ui::Row{.children = {ui::button(ui::Button{.label = "Back", + .onClick = [wizard] { wizard->back(); }, + .common = {.enabled = [wizard] { return wizard->canBack(); }}}), + ui::button(ui::Button{.label = "Next", + .onClick = [wizard] { wizard->next(); }, + .common = {.enabled = [wizard] { return wizard->canNext(); }}}), + ui::text(ui::Text{.text = "Last step — fill it in to finish", + .role = ui::TextRole::Muted, + .common = {.visible = [wizard] { return wizard->onLastStep(); }}})}, + .gap = 1})}, + .gap = 1}); +} + +} // namespace morph::forms +``` + +Register the header and the test as listed under **Files**. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][wizard]"` +Expected: PASS, 6 test cases. + +Mutation check: in `relay`'s success handler, swap the two `record` calls (reply first, then the draft). Expected: +FAIL in "entering a step applies its prefill as JSON values, the reply winning" (`WizStepOne.label` is +`"sample"`). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/wizard.hpp tests/test_forms_engine_wizard.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): wizards — one FormSession per step, sequenced by successful submissions + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 14b: Entering a wizard step re-fetches its Choice options + +A `WizardSession` builds every step's `FormSession` up front (Task 14), so a later step's `Choice` options are +fetched — and answered — before the earlier steps run. When the earlier step creates the row a later step's prefill +names (the forms demo's intake wizard: `RegisterSample` creates sample 100, step two's `sampleId` is prefilled with +it), Task 8's stale-selection rule clears the prefill at once, because the options answered for the current parents +lack it. Entering a step therefore re-fetches its options before the prefill is applied: the selection is kept while +the request is in flight and judged against a reply that includes the new row. + +**Files:** +- Modify: `include/morph/forms/engine/form_session.hpp` — `FieldState::refreshOptions()`, + `FormSession::refreshOptions()` +- Modify: `include/morph/forms/engine/wizard.hpp` — `WizardSession::next()` +- Test: `tests/test_forms_engine_wizard.cpp` (one case) + +**Interfaces:** +- Consumes: Task 6/8's `FieldState` (`_optionsQuery`, `_members`), `FormSession` (`_fields`), `WizardSession::next()`, + Part 1's `Query::refetch()`; the engine tests' `morph::test::formsengine::Harness`/`Call` and `WizardModel`, + `WizardSession` (already named in `tests/test_forms_engine_wizard.cpp`). +- Produces: `FieldState::refreshOptions()`, `FormSession::refreshOptions()` (listed by Task 16 in `engine.md`). + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_forms_engine_wizard.cpp`: + +```cpp +TEST_CASE("WizardSession: entering a step refetches its Choice options, so a prefill naming a row the previous " + "step created survives", + "[forms-engine][wizard]") { + auto const registered = std::make_shared(false); + Harness harness; + harness.server.replyWith([registered](Call const& call) -> std::optional { + if (call.action == "WizRegister") { + *registered = true; + return R"({"id":100})"; + } + return R"({"ok":true})"; + }); + harness.server.optionsWith([registered](Call const&) -> std::optional { + return *registered ? R"([{"id":1,"name":"One"},{"id":100,"name":"New"}])" : R"([{"id":1,"name":"One"}])"; + }); + auto model = WizardModel::fromSchema("RegisterThenMeasure", R"({"w-steps":[{"action":"WizRegister","title":"Register"}, + {"action":"WizMeasure","title":"Measure","prefill":{"sampleId":"WizRegister.id"}}]})"); + REQUIRE(model.has_value()); + morph::forms::SchemaLookup const schemas = [](std::string_view action) -> std::optional { + if (action == "WizRegister") { + return R"({"properties":{"name":{"type":"string","x-order":0}},"required":["name"]})"; + } + if (action == "WizMeasure") { + return R"({"properties":{"sampleId":{"type":"integer","x-order":0,"x-optionsAction":"ListSamples","x-optionValue":"id","x-optionLabel":"name"},"value":{"type":"integer","x-order":1}},"required":["sampleId","value"]})"; + } + return std::nullopt; + }; + WizardSession wizard{harness.rt, *std::move(model), schemas, harness.server.submitter(), harness.server.fetcher()}; + harness.settle(); + REQUIRE(harness.server.fetches().size() == 1); // step two's options, answered before step one ran + + harness.type(wizard.step(0), "name", "Core 9"); + REQUIRE(wizard.canNext()); + wizard.next(); + harness.settle(); + CHECK(harness.server.fetches().size() == 2); + CHECK(wizard.step(1).field("sampleId").text().peek() == "100"); + CHECK(wizard.step(1).field("sampleId").options().size() == 2); +} +``` + +Add `#include ` to the file's includes if it is not there. + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][wizard]" +``` + +Expected: FAIL in the new case — `harness.server.fetches().size() == 2` is `1 == 2`, and +`wizard.step(1).field("sampleId").text().peek() == "100"` is `"" == "100"`; every other `[wizard]` case passes. + +- [ ] **Step 3: Implement** + +In `include/morph/forms/engine/form_session.hpp`, in `FieldState`'s public section after `optionsPending()`: + +```cpp + /// @brief Re-fetches a Choice's options for its current parents, and every nested member's; nothing for + /// another kind. The selection is kept while the request is in flight and judged against its reply. + void refreshOptions() { + if (_optionsQuery != nullptr) { + _optionsQuery->refetch(); + } + for (auto const& member : _members) { + member->refreshOptions(); + } + } +``` + +and in `FormSession`'s public section after `reset()`: + +```cpp + /// @brief Re-fetches every Choice's options (`FieldState::refreshOptions`). + void refreshOptions() { + for (auto const& field : _fields) { + field->refreshOptions(); + } + } +``` + +In `include/morph/forms/engine/wizard.hpp`, in `WizardSession::next()`, directly after +`FormSession& session = *_steps.at(target);`: + +```cpp + // The step's Choice options were fetched when the wizard was built, before the earlier steps ran, and a + // prefill naming a row one of them created would be cleared against that answer. Re-fetching first keeps + // the selection while the request is in flight and judges it against the reply. + session.refreshOptions(); +``` + +Task 16 states this in `engine.md`'s "Wizards" section and lists both `refreshOptions()` among the names beyond +the contract. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine]" +``` + +Expected: every `[forms-engine]` case passes, the new one included. + +Mutation check: delete the `session.refreshOptions();` line from `WizardSession::next()`. Expected: FAIL in the new +case (`"" == "100"`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/engine/form_session.hpp include/morph/forms/engine/wizard.hpp \ + tests/test_forms_engine_wizard.cpp +git commit -m "wip(forms): entering a wizard step re-fetches its Choice options before the prefill + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 15: The app shell — `ViewScreen`, `AppShellModel`, `AppShellSession`, `appShellView` + +`forms/app.hpp` gains `ViewScreen` (kind `"view"`, ref `ViewTraits::typeId()`), which the +comment above `App` has been leaving room for. An `app-*` document becomes a menu beside the current screen; each +screen's session — a `FormSession`, `WizardSession` or `CollectionSession` — is made the first time it is shown and +kept, so switching screens loses nothing. + +**Files:** +- Modify: `include/morph/forms/app.hpp` — `#include "views.hpp"`; replace the comment block that begins + `// A ViewScreen counterpart (kind: "view") could be added here:` with `ViewScreen`; in the file comment + and `App`'s `@tparam Screens`, name `ViewScreen` beside `FormScreen` and `WizardScreen` +- Create: `include/morph/forms/engine/app_shell.hpp` +- Modify: `CMakeLists.txt` — `FILE_SET HEADERS`, after `include/morph/forms/engine/wizard.hpp`, add + `include/morph/forms/engine/app_shell.hpp` +- Modify: `tests/CMakeLists.txt` — add `test_forms_engine_app_shell.cpp` after `test_forms_engine_wizard.cpp` +- Test: `tests/test_forms_engine_app_shell.cpp` + +**Interfaces:** +- Consumes: `app::App`, `app::MenuEntry`, `app::appSchemaJson()` (`include/morph/forms/app.hpp`); + `views::ViewTraits` (`include/morph/forms/views.hpp`); `forms::i18n::appTitleKey`, `appMenuLabelKey`; + Tasks 12–14; Part 2's `ui::menu`, `ui::Menu`, `ui::MenuItem`, `ui::forEach`. +- Produces: the contract's `morph::app::ViewScreen` (`id()`, `kind()` = `"view"`, `ref()`); in + `morph::forms`: `ScreenKind{Form, Wizard, View}`, `AppMenuEntry{label, screen}`, `AppScreen{id, kind, ref}`, + `AppShellModel` (`fromSchema(std::string_view appId, std::string_view appJson) -> std::expected`, `id()`, `title()`, `menu()`, `screens()`, `find(std::string_view) -> AppScreen const*`), + `AppShellSources{actions, wizards, views}` (three `SchemaLookup`s), `AppShellSession` + (`AppShellSession(reactive::Runtime&, AppShellModel, AppShellSources, Submitter, ChoiceFetcher, + FormSessionOptions = {})`, `model()`, `current()`, `select(std::string_view)`, `form()`, `wizard()`, + `collection()`, `title()`, `menuLabel(std::size_t)`), and the contract's `appShellView(AppShellSession&) -> + ui::Node`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_forms_engine_app_shell.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "forms_engine_support.hpp" +#include "forms_engine_view_support.hpp" + +// NOLINTBEGIN(misc-use-internal-linkage) +struct AsSamplesView {}; +// NOLINTEND(misc-use-internal-linkage) + +template <> +struct morph::views::ViewTraits { + static constexpr std::string_view typeId() noexcept { return "Test_AppShell_Samples"; } +}; + +namespace { + +using morph::forms::AppShellModel; +using morph::forms::AppShellSession; +using morph::forms::ScreenKind; +using morph::test::formsengine::Call; +using morph::test::formsengine::Harness; +namespace ui = morph::ui; +namespace view = morph::test::formsview; + +constexpr std::string_view kApp = R"({"app-title":"Lab","app-menu":[{"label":"Density","screen":"density"}, + {"label":"Samples","screen":"samples"},{"label":"Intake","screen":"intake"}], + "app-screens":{"density":{"kind":"form","ref":"ComputeDensity"},"samples":{"kind":"view","ref":"SamplesView"}, + "intake":{"kind":"wizard","ref":"IntakeWizard"}}})"; + +[[nodiscard]] std::optional actions(std::string_view action) { + if (action == "ComputeDensity") { + return R"({"properties":{"mass":{"type":"integer","x-order":0}},"required":["mass"]})"; + } + if (action == "RegisterSample") { + return R"({"properties":{"label":{"type":"string","x-order":0}},"required":["label"]})"; + } + return std::nullopt; +} + +[[nodiscard]] std::optional wizards(std::string_view wizardId) { + if (wizardId == "IntakeWizard") { + return R"({"w-title":"Intake","w-steps":[{"action":"RegisterSample","title":"Register"}]})"; + } + return std::nullopt; +} + +[[nodiscard]] std::optional views(std::string_view viewId) { + if (viewId == "SamplesView") { + return R"({"v-kind":"collection","v-title":"Samples","v-query":"ListSamples","v-columns":[{"field":"name","label":"Name"}]})"; + } + return std::nullopt; +} + +struct ShellHarness : Harness { + ShellHarness() { + server.replyWith([](Call const& call) -> std::optional { + return call.action == "ListSamples" ? R"([{"id":1,"name":"S-1"}])" : R"({"ok":true})"; + }); + } + + [[nodiscard]] std::unique_ptr shell() { + auto model = AppShellModel::fromSchema("Lab", kApp); + REQUIRE(model.has_value()); + return std::make_unique(rt, *std::move(model), + morph::forms::AppShellSources{actions, wizards, views}, + server.submitter(), server.fetcher()); + } +}; + +} // namespace + +TEST_CASE("ViewScreen: a view screen emits kind view and the view's registered id", "[forms-engine][app-shell]") { + using Shell = morph::app::App<"Lab", std::tuple>, + std::tuple>>; + CHECK(morph::app::ViewScreen<"samples", AsSamplesView>::kind() == "view"); + CHECK(morph::app::appSchemaJson().find(R"("samples":{"kind":"view","ref":"Test_AppShell_Samples"})") != + std::string::npos); +} + +TEST_CASE("AppShellModel: the app-* document reads, and a malformed one is a SchemaError", "[forms-engine][app-shell]") { + auto const model = AppShellModel::fromSchema("Lab", kApp); + REQUIRE(model.has_value()); + CHECK(model->title() == "Lab"); + REQUIRE(model->menu().size() == 3); + CHECK(model->menu()[1].screen == "samples"); + REQUIRE(model->find("intake") != nullptr); + CHECK(model->find("intake")->kind == ScreenKind::Wizard); + CHECK(model->find("samples")->kind == ScreenKind::View); + CHECK(model->find("nope") == nullptr); + CHECK(AppShellModel::fromSchema("X", R"({"app-screens":{"a":{"kind":"page","ref":"r"}}})").error().path == + "app-screens.a"); + CHECK_FALSE(AppShellModel::fromSchema("X", "[]").has_value()); +} + +TEST_CASE("AppShellSession: the first menu entry is shown; a screen's session is made when first asked for and kept", + "[forms-engine][app-shell]") { + ShellHarness harness; + auto const shell = harness.shell(); + CHECK(shell->current() == "density"); + CHECK(harness.server.submits().empty()); + REQUIRE(shell->form("density") != nullptr); + CHECK(shell->form("density") == shell->form("density")); + CHECK(shell->collection("density") == nullptr); // a form screen is no collection + CHECK(harness.server.submits().empty()); // no list loads until its screen is made + REQUIRE(shell->collection("samples") != nullptr); + harness.settle(); + CHECK(shell->collection("samples")->rows().size() == 1); + REQUIRE(shell->wizard("intake") != nullptr); + CHECK(shell->wizard("intake")->stepCount() == 1); + shell->select("samples"); + CHECK(shell->current() == "samples"); + shell->select("nope"); + CHECK(shell->current() == "samples"); +} + +TEST_CASE("appShellView: the menu beside the current screen; selecting swaps the screen and keeps its state", + "[forms-engine][app-shell][view]") { + ShellHarness harness; + auto const shell = harness.shell(); + std::string const drawn = view::outline(morph::forms::appShellView(*shell)); + CHECK(drawn.find(" Row\n Menu [Density|Samples|Intake]\n ForEach\n") != std::string::npos); + + ui::testing::RecordingBackend backend; + ui::Mounted const mounted{harness.rt, backend, morph::forms::appShellView(*shell)}; + harness.settle(); + backend.edit(backend.all(view::kTextInput).front(), "12"); + harness.settle(); + CHECK(shell->form("density")->body() == R"({"mass":12})"); + shell->select("samples"); + harness.settle(); + CHECK(backend.find("Text", "text", "S-1").has_value()); + CHECK(backend.all(view::kTextInput).empty()); + shell->select("density"); + harness.settle(); + CHECK(backend.prop(backend.all(view::kTextInput).front(), "text") == "12"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/reactive --target morph_tests` +Expected: `fatal error: 'morph/forms/engine/app_shell.hpp' file not found`. + +- [ ] **Step 3: Implement** + +In `include/morph/forms/app.hpp`, add `#include "views.hpp"` after `#include "flows.hpp"`, and replace the comment +block beginning `// A ViewScreen counterpart (kind: "view") could be added here:` with: + +```cpp +/// @brief A screen backed by a registered view (`BRIDGE_REGISTER_VIEW`): a collection or +/// master-detail screen. +/// @tparam Id Screen-id, referenced from an `App::menu`'s `MenuEntry::screen()`. +/// @tparam View Registered view type this screen renders. +template +struct ViewScreen { + /// @brief The screen's id. + /// @return The declared id. + [[nodiscard]] static constexpr std::string_view id() noexcept { return Id.view(); } + + /// @brief The screen's kind, for the `app-screens[id].kind` key. + /// @return The literal `"view"`. + [[nodiscard]] static constexpr std::string_view kind() noexcept { return "view"; } + + /// @brief The referenced view's registered type-id. + /// @return `views::ViewTraits::typeId()`. + [[nodiscard]] static constexpr std::string_view ref() noexcept { + return ::morph::views::ViewTraits::typeId(); + } +}; +``` + +In the file comment, change "a reference to an already-registered action form (`FormScreen`) or wizard +(`WizardScreen`)" to "a reference to an already-registered action form (`FormScreen`), wizard (`WizardScreen`) or +view (`ViewScreen`)", and "the shell only routes to existing action-forms and wizards" to "the shell only routes to +existing action-forms, wizards and views"; in `App`'s documentation change +`std::tuple | WizardScreen<...>...>` to `std::tuple | WizardScreen<...> | +ViewScreen<...>...>`. + +Create `include/morph/forms/engine/app_shell.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +/// @file forms/engine/app_shell.hpp +/// @brief The app shell: an `app-*` document as a menu beside the current screen, each screen's +/// session made when first shown and kept. +/// +/// Specified in `docs/spec/forms/engine.md`, "App shell". + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../attributes.hpp" +#include "../../reactive/signal.hpp" +#include "../../render/i18n.hpp" +#include "../../ui/view.hpp" +#include "../i18n.hpp" +#include "collection.hpp" +#include "detail/text.hpp" +#include "form_session.hpp" +#include "form_view.hpp" +#include "wizard.hpp" + +namespace morph::forms { + +/// @brief What an `app-screens` entry routes to. +enum class ScreenKind : std::uint8_t { + Form, ///< `kind: form` — an action form (`ref` is the action type). + Wizard, ///< `kind: wizard` — a wizard (`ref` is the wizard id). + View ///< `kind: view` — a collection view (`ref` is the view id). +}; + +/// @brief One `app-menu` entry. +struct AppMenuEntry { + /// @brief The menu label. + std::string label{}; + /// @brief The screen id it routes to. + std::string screen{}; +}; + +/// @brief One `app-screens` entry. +struct AppScreen { + /// @brief The screen id. + std::string id{}; + /// @brief What it routes to. + ScreenKind kind = ScreenKind::Form; + /// @brief The action type, wizard id or view id. + std::string ref{}; +}; + +/// @brief The immutable model of an `app-*` document. +class AppShellModel { +public: + /// @brief Reads an app document. + /// @param appId The app's registered id (`BRIDGE_REGISTER_APP`), the i18n key stem. + /// @param appJson The document, in the vocabulary `app::appSchemaJson()` emits. + /// @return The model, or the error naming the malformed key. + [[nodiscard]] static std::expected fromSchema(std::string_view appId, + std::string_view appJson) { + using detail::engine::Json; + auto const dom = detail::engine::parseJson(appJson); + if (!dom || !dom->is_object()) { + return std::unexpected(SchemaError{"", "an app document must be a JSON object"}); + } + AppShellModel model{}; + model._id = appId; + auto const* const title = detail::engine::stringAt(*dom, "app-title"); + model._title = title != nullptr ? *title : std::string{appId}; + if (Json const* const menu = detail::engine::member(*dom, "app-menu"); menu != nullptr && menu->is_array()) { + for (Json const& entry : menu->get_array()) { + auto const* const label = detail::engine::stringAt(entry, "label"); + auto const* const screen = detail::engine::stringAt(entry, "screen"); + if (label == nullptr || screen == nullptr) { + return std::unexpected(SchemaError{"app-menu", "a menu entry needs a \"label\" and a \"screen\""}); + } + model._menu.push_back(AppMenuEntry{*label, *screen}); + } + } + if (Json const* const screens = detail::engine::member(*dom, "app-screens"); screens != nullptr && screens->is_object()) { + for (auto const& [screenId, node] : screens->get_object()) { + auto const* const kind = detail::engine::stringAt(node, "kind"); + auto const* const ref = detail::engine::stringAt(node, "ref"); + if (kind == nullptr || ref == nullptr || (*kind != "form" && *kind != "wizard" && *kind != "view")) { + return std::unexpected(SchemaError{"app-screens." + screenId, + "a screen needs a \"ref\" and a \"kind\" of form, wizard or view"}); + } + ScreenKind const screenKind = *kind == "form" ? ScreenKind::Form : (*kind == "wizard" ? ScreenKind::Wizard : ScreenKind::View); + model._screens.push_back(AppScreen{screenId, screenKind, *ref}); + } + } + return model; + } + + /// @brief The app's id. + /// @return The id passed to `fromSchema`. + [[nodiscard]] std::string_view id() const noexcept { return _id; } + + /// @brief `app-title`, or the id. + /// @return The title. + [[nodiscard]] std::string_view title() const noexcept { return _title; } + + /// @brief `app-menu`, in order. + /// @return The entries. + [[nodiscard]] std::span menu() const noexcept { return _menu; } + + /// @brief `app-screens`, in document order. + /// @return The screens. + [[nodiscard]] std::span screens() const noexcept { return _screens; } + + /// @brief A screen by id. + /// @param screenId The screen id. + /// @return The screen, or null. + [[nodiscard]] AppScreen const* find(std::string_view screenId) const noexcept { + auto const found = std::ranges::find(_screens, screenId, &AppScreen::id); + return found == _screens.end() ? nullptr : &*found; + } + +private: + AppShellModel() = default; + + std::string _id; + std::string _title; + std::vector _menu; + std::vector _screens; +}; + +/// @brief Where an app shell finds the documents its screens name. +struct AppShellSources { + /// @brief Action schemas, by action type (form screens, wizard steps, collection editors). + SchemaLookup actions{}; + /// @brief Wizard documents, by wizard id. + SchemaLookup wizards{}; + /// @brief View documents, by view id. + SchemaLookup views{}; +}; + +/// @brief The reactive state of one app shell: the current screen and every screen shown so far. +/// +/// Non-copyable and non-movable. Destroy a mounted view of it before the session. +class AppShellSession { +public: + /// @param runtime The runtime. Borrowed. + /// @param model The app model. + /// @param sources Where the screens' documents come from. + /// @param submit Executes every screen's actions. + /// @param choices Fetches Choice options for every screen. + /// @param options Every screen's session options. + AppShellSession(reactive::Runtime& runtime MORPH_LIFETIMEBOUND, AppShellModel model, AppShellSources sources, + Submitter submit, ChoiceFetcher choices, FormSessionOptions options = {}) + : _rt{&runtime}, + _model{std::move(model)}, + _sources{std::move(sources)}, + _submit{std::move(submit)}, + _choices{std::move(choices)}, + _options{std::move(options)}, + _current{runtime, initialScreen(_model)} {} + + ~AppShellSession() = default; + AppShellSession(AppShellSession const&) = delete; + AppShellSession& operator=(AppShellSession const&) = delete; + AppShellSession(AppShellSession&&) = delete; + AppShellSession& operator=(AppShellSession&&) = delete; + + /// @brief The app model. + /// @return The model. + [[nodiscard]] AppShellModel const& model() const noexcept { return _model; } + + /// @brief The current screen. Tracked. + /// @return Its id: the first menu entry's screen at first. + [[nodiscard]] std::string const& current() const { return _current.get(); } + + /// @brief Shows a screen; an id the document does not declare changes nothing. + /// @param screenId The screen id. + void select(std::string_view screenId) { + if (_model.find(screenId) != nullptr) { + _current.set(std::string{screenId}); + } + } + + /// @brief A form screen's session, made on first call. + /// @param screenId The screen id. + /// @return The session, or null for another kind or an unknown action schema. + [[nodiscard]] FormSession* form(std::string_view screenId) { + Screen& screen = screenFor(screenId); + return screen.form.get(); + } + + /// @brief A wizard screen's session, made on first call. + /// @param screenId The screen id. + /// @return The session, or null for another kind or an unusable wizard document. + [[nodiscard]] WizardSession* wizard(std::string_view screenId) { + Screen& screen = screenFor(screenId); + return screen.wizard.get(); + } + + /// @brief A view screen's session, made on first call. + /// @param screenId The screen id. + /// @return The session, or null for another kind or an unknown view document. + [[nodiscard]] CollectionSession* collection(std::string_view screenId) { + Screen& screen = screenFor(screenId); + return screen.collection.get(); + } + + /// @brief The app's title, resolved through the translations (`.title`). + /// @return The title. + [[nodiscard]] std::string title() const { + return render::resolveText(_options.translations, _options.bcp47, std::nullopt, i18n::appTitleKey(_model.id()), + _model.title()); + } + + /// @brief A menu entry's label, resolved through the translations (`.menu..label`). + /// @param index The entry. + /// @return The label. + [[nodiscard]] std::string menuLabel(std::size_t index) const { + return render::resolveText(_options.translations, _options.bcp47, std::nullopt, + i18n::appMenuLabelKey(_model.id(), index), + detail::engine::elementAt(_model.menu(), index).label); + } + +private: + struct Screen { + std::unique_ptr form; + std::unique_ptr wizard; + std::unique_ptr collection; + bool made = false; + }; + + [[nodiscard]] static std::string initialScreen(AppShellModel const& model) { + if (!model.menu().empty()) { + return model.menu().front().screen; + } + return model.screens().empty() ? std::string{} : model.screens().front().id; + } + + [[nodiscard]] Screen& screenFor(std::string_view screenId) { + Screen& screen = _screens.try_emplace(std::string{screenId}).first->second; + if (screen.made) { + return screen; + } + screen.made = true; + AppScreen const* const spec = _model.find(screenId); + if (spec == nullptr) { + return screen; + } + switch (spec->kind) { + case ScreenKind::Form: + if (auto const schema = _sources.actions ? _sources.actions(spec->ref) : std::nullopt) { + if (auto formModel = FormModel::fromSchema(spec->ref, *schema)) { + screen.form = std::make_unique(*_rt, *std::move(formModel), _submit, _choices, _options); + } + } + break; + case ScreenKind::Wizard: + if (auto const json = _sources.wizards ? _sources.wizards(spec->ref) : std::nullopt) { + if (auto wizardModel = WizardModel::fromSchema(spec->ref, *json)) { + try { + screen.wizard = std::make_unique(*_rt, *std::move(wizardModel), _sources.actions, + _submit, _choices, _options); + } catch (std::invalid_argument const&) { + screen.wizard.reset(); // a step's schema is missing: the screen shows nothing to edit + } + } + } + break; + case ScreenKind::View: + if (auto const json = _sources.views ? _sources.views(spec->ref) : std::nullopt) { + if (auto viewModel = CollectionModel::fromSchema(*json)) { + screen.collection = std::make_unique(*_rt, *std::move(viewModel), _sources.actions, + _submit, _choices, _options); + } + } + break; + } + return screen; + } + + reactive::Runtime* _rt; + AppShellModel _model; + AppShellSources _sources; + Submitter _submit; + ChoiceFetcher _choices; + FormSessionOptions _options; + reactive::Signal _current; + std::map> _screens; +}; + +/// @brief Renders an app shell: the title, and the menu beside the current screen's view (made +/// when first shown; a screen that cannot be made says so). +/// @param session The shell session; mount the result after it and destroy the mount before it. +/// @return The view. +[[nodiscard]] inline ui::Node appShellView(AppShellSession& session) { + AppShellSession* const shell = &session; + std::vector items; + for (std::size_t i = 0; i < session.model().menu().size(); ++i) { + std::string const screen = detail::engine::elementAt(session.model().menu(), i).screen; + items.push_back(ui::MenuItem{.label = session.menuLabel(i), .onSelect = [shell, screen] { shell->select(screen); }}); + } + auto content = ui::forEach( + [shell] { return std::vector{shell->current()}; }, [](std::string const& screenId) { return ui::Key{screenId}; }, + [shell](reactive::Signal const& slot) -> ui::Node { + std::string const screenId = slot.peek(); + if (FormSession* const form = shell->form(screenId)) { + return formView(*form); + } + if (WizardSession* const wizard = shell->wizard(screenId)) { + return wizardView(*wizard); + } + if (CollectionSession* const collection = shell->collection(screenId)) { + return collectionView(*collection); + } + return ui::text(ui::Text{.text = "this screen's document is not available: " + screenId, .role = ui::TextRole::Error}); + }); + return ui::column(ui::Column{ + .children = {ui::text(ui::Text{.text = session.title(), .role = ui::TextRole::Heading}), + ui::row(ui::Row{.children = {ui::menu(ui::Menu{.items = std::move(items)}), std::move(content)}, .gap = 2})}, + .gap = 1}); +} + +} // namespace morph::forms +``` + +Add `#include ` and `#include ` to the include list. Register the header and the test as +listed under **Files**. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/reactive --target morph_tests && ./build/reactive/tests/morph_tests "[forms-engine][app-shell],[app]"` +Expected: PASS — 4 new cases and the existing `[app]` cases in `test_flows_apps.cpp`. + +Mutation check: in `screenFor`, delete `screen.made = true;`. Expected: FAIL in "the first menu entry is shown; a +screen's session is made when first asked for and kept" (`form("density") == form("density")` compares two +different sessions — and the first is destroyed while the second is made). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/forms/app.hpp include/morph/forms/engine/app_shell.hpp tests/test_forms_engine_app_shell.cpp \ + tests/CMakeLists.txt CMakeLists.txt +git commit -m "wip(forms): ViewScreen and the app shell — a menu beside lazily made screens + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 16: Specs, maps, changelog and the pinned depth + +**Files:** +- Create: `docs/spec/forms/engine.md` +- Modify: `docs/spec/forms/forms.md` — a new `## The forms engine (C++)` section directly before + `## Shipped Qt/QML reference renderer`, and a line in its `## Contents` +- Modify: `docs/spec/forms/views.md` — a new `## The forms engine's collections` section directly before + `## The Qt/QML reference renderer` +- Modify: `docs/spec/forms/workflows_navigation.md` — `ViewScreen` in `## C++ descriptors` and in the `morph::app` + API table; a new `## The forms engine's wizards and app shell` section before `## The Qt/QML reference renderer`; + the `kind: "view"` bullet of `## Limitations` rewritten +- Modify: `docs/spec/forms/widget_hints.md` — a new `## In the forms engine` section before `## API reference` +- Modify: `docs/spec/README.md` — the "Schema-driven UI" group +- Modify: `docs/ARCHITECTURE.md` — the `morph::forms` row of "Namespace map", the `forms/` and `util/` tables of + "Header map" +- Modify: `CHANGELOG.md` — `## [Unreleased]` → `### Added` +- Modify: `docs/spec/pinned_facts.toml`, `tests/test_pinned_facts.cpp` + +**Interfaces:** +- Consumes: the API exactly as Tasks 1–15 ship it; where this text and the code disagree, fix the text. +- Produces: the authoritative spec Parts 8–10 read before touching the engine. + +- [ ] **Step 1: Write `docs/spec/forms/engine.md`** + +```markdown +# The forms engine — design + +Design spec for the C++ forms engine (`include/morph/forms/engine/`): a form described by schema JSON — +or by an action type through `schemaJson()` — as a toolkit-free reactive session (`FormSession`) and a +`ui::Node` view of it (`formView`), so one form renders on every frontend. Collections, wizards and the app +shell are built from the same parts. + +## Contents + +- [Shape](#shape) +- [The field model](#the-field-model) +- [Rules](#rules) +- [Values and exactness](#values-and-exactness) +- [FormSession](#formsession) +- [Submitters](#submitters) +- [Typed forms](#typed-forms) +- [Rendering](#rendering) +- [Overrides](#overrides) +- [Collections](#collections) +- [Wizards](#wizards) +- [App shell](#app-shell) +- [Headers](#headers) +- [Differences from the QML renderer](#differences-from-the-qml-renderer) +- [Design decisions](#design-decisions) +- [Limitations](#limitations) + +## Shape + + schema JSON ──FormModel::fromSchema─────┐ + ├─► FormModel ─► FormSession ─► formView() ─► ui::Node + action A ──FormModel::forAction()────┘ │ + └─ Submitter / ChoiceFetcher + +- `FormModel` — immutable: fields, kinds, constraints, layout groups, parsed rules, submit mode. +- `FormSession` — reactive state over a model: drafts, encodings, readiness, presentation, Choice + options, submission. No toolkit; tested headless. +- `formView(session, options)` — `ui` nodes bound to the session. +- `FormModel::forAction()` — the model of a registered action's own schema (`schemaJson()`). +- `Form` — a `FormSession` over that model with typed access. + +The schema JSON is the contract between the two entrances, already pinned by the conformance and rule +corpora; nothing about it changes for the engine. + +## The field model + +`FormModel::fromSchema(actionType, schemaJson) -> std::expected` reads the +emitted vocabulary with glaze's u64 DOM (integers keep every digit). Properties are described in +`x-order` (stable for ties). A property is resolved through `$ref` into `$defs` (its own keys win), then a +nullable `anyOf`/`oneOf` collapses onto its first non-null branch (outer keys win; the branches' +`anyOf`/`oneOf`/`const` are dropped). + +The kind is decided in this order, which is the order the QML renderer's encoder asked in: + +| `FieldKind` | From | +|---|---| +| `ObjectArray` / `Array` | `type: array`, items of object (or array) type / of scalars | +| `Enum` | a bare `enum`, or a `oneOf`/`anyOf` of `const` branches (one nested level, as glaze spells `std::optional`), unless `x-optionsAction` | +| `Choice` | `x-optionsAction` (`x-optionValue`, `x-optionLabel`, `x-optionsDependsOn`) | +| `DateTime` / `Date` | `format: date-time` / `format: date` | +| `Quantity` / `Decimal` | `x-decimalPlaces`, with / without `ExtUnits` | +| `Slider` | `x-widget: slider` with `x-min` and `x-max` on an integer or number | +| `Integer`, `Boolean`, `Number` | the JSON type | +| `Object` | `type: object` | +| `Multiline` / `Text` | `x-widget: textarea` / anything else | + +A `FieldSpec` carries the wire name and path, title, help, placeholder, `x-i18nKey`, `required`, +`x-blankAs` (text kinds), `x-readonly`, `x-hidden`, `x-computed.inputs`, `minimum`/`maximum`/`multipleOf` +as canonical decimal text, `x-exactMinimum`/`x-exactMaximum`, the instance bounds `x-minimum`/`x-maximum` +as `Rational`, `x-decimalPlaces`, `x-displayDecimals` (plain numbers only), the unit and its exact +alternatives (canonical first), enum options as `{valueJson, label}`, the Choice descriptor, `x-section`, +`x-colspan`, `x-widget`, the slider track, an Array's item type, and an Object's or ObjectArray's members. + +**Nesting.** Members are described to `kMaxNestingDepth` (4) levels below the action and stop at a `$ref` +already on the path, so a self-referential type is described once; the member where it would repeat is +`truncated`. **Groups:** `groups()` is the declared `x-layout` groups, each holding the fields whose +`x-section` names it in `x-order`, then one implicit group of the rest; without `x-layout`, one implicit +group of every field. A malformed document — not JSON, not an object, `properties` not an object, a +property not an object, a rule without a kind, `x-layout.groups` not an array — is a `SchemaError` naming +the path. + +**From an action type.** `FormModel::forAction()` is `fromSchema(ActionTraits::typeId(), +schemaJson())`. The schema is compiled from `A`, so a reader error there is a defect in the emitter or the +engine, not bad input: `forAction` throws `std::logic_error` naming the action id and the error's path and +message instead of returning an `expected` every caller would unwrap. + +## Rules + +`x-rules` parses into `RuleExpr` trees; a `"kind"` the engine does not know is kept as `Unknown`. +Evaluation is three-valued over each field's encoding — Blank, Invalid (typed, does not encode), Valid +with an exact `Scalar` (boolean, string, canonical decimal, `Rational`): + +| Node | Blank operand | Invalid operand | Valid | +|---|---|---|---| +| `engaged` / `notEngaged` | False / True | Unknown | True / False | +| `equals` | False | Unknown | exact equality; mismatched types are False | +| comparisons | True (vacuous) | Unknown | exact order; incomparable types are Unknown | +| membership | not engaged | Unknown unless the definite count decides | counted | +| `requiredWhen` (rule) | | | True unless `when` is True, then `engaged(field)` | +| `visibleWhen`, `readonlyWhen` (rule) | | | True | +| unknown kind | | | Unknown | + +`and`/`or`/`not` are Kleene. **A gating rule blocks readiness only when it is False.** An Invalid operand +already blocks through its own field error, so the only Unknown that reaches readiness is a kind the engine +does not know — the server, which knows it, judges it. Presentation: `visible` unless a `visibleWhen` +naming the field is False; `readonly` when a `readonlyWhen` is True; dynamically `required` when a +`requiredWhen` is True — Unknown presents as visible, editable, not required. Comparisons never pass through +a double: integers compare as digits, decimals and quantities as `Rational`s or by digit +cross-multiplication. This is the only rule evaluator on the client; `allRulesSatisfied` stays the +server's, and the rule corpus holds both to the same 46 rows. + +## Values and exactness + +`encodeScalar(spec, draft, unit, context)` trims the draft and returns `std::nullopt` (blank), +a `WireValue{json, scalar}`, or a `FieldError{code, message}` — codes `malformed`, `too-precise`, +`below-minimum`, `above-maximum`, `not-a-multiple`, `not-an-option`, `out-of-range`, `incomplete`, +`unrepresentable`; messages resolve through the session's translations under `morph.forms.error.`. + +| Kind | Literal | +|---|---| +| Text, Multiline, Date | the JSON string (`JSON.stringify` quoting); a Date must be a real `YYYY-MM-DD` | +| DateTime | `YYYY-MM-DDTHH:MM[:SS]` in the display zone → `"…Z"` UTC | +| Integer | the digits, leading zeros dropped, bounds by digits (`x-exact*` preferred) | +| Number | the typed digits after `render::normalizeLocaleNumber`; no exponent; `x-displayDecimals` is an entry limit | +| Decimal, Quantity | `{"num":N,"den":D,"dp":P}`: `parseDecimal` in the entry unit, `N` = scaled digits × unit num, `D` = 10^unit decimals × unit den — unreduced, as the QML renderer sent it | +| Boolean | `true`/`false`; a blank **required** box is `false` | +| Enum | the option's literal; anything outside the set is `not-an-option` | +| Choice | the selected option's literal | +| Array | `[…]` of the comma-separated items, by item type; blanks dropped | + +Quantity bounds (`minimum`, `maximum`, `multipleOf`, instance bounds) are checked exactly in the canonical +unit, whatever unit the value was typed in. `math::parseDecimal` (`util/rational.hpp`) is the decimal +parser the engine and applications share. `decodeScalar` is the inverse: numbers in the display locale +without grouping, a Number padded to `x-displayDecimals`, a Quantity rounded half away from zero to its +places, instants in the display zone. `convertDraft` switches a Quantity's unit exactly. + +## FormSession + +A `FieldState` per field: `text`, `unit`, `engaged` signals; `encoded`, `error`, `visible`, `readonly`, +`required` computeds; Object members and ObjectArray rows (`RowState`, held by `shared_ptr` so a mounted +row outlives its removal); a Choice's options. **Readiness:** every required field encodes, no field has an +error, no gating rule is False, `FormSessionOptions::accepts(body)` when set. `body()` is the JSON in +`x-order` while ready. An `x-blankAs: empty` field filled once and cleared sends `""`. An Object is blank +when every member is; a blank required member makes it `incomplete`. A collection encodes `[]` once engaged +or, when required, while empty; a truncated member is omitted while blank and `unrepresentable` holding a +value. + +**Submission** is a `reactive::Mutation` over the `Submitter`. In automatic mode an Effect submits whenever +the form is ready with a body different from the last one submitted or seen; a form ready as constructed +submits once. A programmatic change — `prefill`, `assign`, `reset`, clearing a stale Choice — records the +body it produced as seen, so it never submits; the next user edit does. Explicit mode submits only on +`submit()`, and only while ready. `prefill` replaces every draft (absent members blank, units canonical); +non-object text changes nothing. + +**Choice options** are a `reactive::Query` keyed on the request body: `{}` for an independent Choice, +`{"parent":,…}` once every parent encodes, idle otherwise. Latest wins; an idle Choice has no +options. A selection the answered options do not contain is cleared — only once the query answered for +the current parents and did not fail, so a value prefilled before its options arrive survives the fetch. + +## Submitters + +`bridgeSubmitter(bridge, callbacks)` and `bridgeChoiceFetcher` route an action type to the model registering +it — `ActionExecuteRegistry::modelsServing` — and execute the body through a `BridgeHandler` made once per model (`makeHandler`), so validation, journaling and backends behave as for any +typed call. An id no model serves, or more than one does, fails the completion with a message naming it. + +**Submitting through the application's handlers.** Under `LocalBackend` every `BridgeHandler` of a +non-shared model is its own model instance, so the handler `bridgeSubmitter` makes is not the one the rest of +the screen reads. When a form's submissions must land in the application's instance — an in-memory model, or +a shared instance another handler attached — use `handlerSubmitter(callbacks, handlers...)` and +`handlerChoiceFetcher(callbacks, handlers...)` (`handler_submitter.hpp`) instead. Each action type goes to the +first of the given handlers whose model serves it (`BridgeHandler::servesAction`), in the order they are +passed, through `executeJson`; an action none of them serves fails the completion with +`std::invalid_argument`, delivered on `callbacks`. The handlers are borrowed and must outlive the returned +function. Use `bridgeSubmitter` when the model's state lives outside the instance (a database), so any +handler of it sees the same data. + +## Typed forms + +`Form` builds its model with `FormModel::forAction()`. +`set<&A::member>(value)` writes the member's JSON as a programmatic change; `value()` decodes the body into +`A`; `ActionValidator` (the action's `validate()`) joins readiness through `accepts`; a submission goes +through `BridgeHandler::execute`, so `lastResult()` is typed. + +## Rendering + +`formView(session, options)`: + +| Kind | Control | +|---|---| +| Text, Array | `TextInput` | +| Multiline | `TextInput` (Multiline) | +| Integer, Number, Decimal | `TextInput`, the unit as muted `Text` beside it | +| Quantity | `Row{TextInput, Select(units)}`, or the unit `Text` without alternatives | +| Boolean | `Checkbox` | +| Enum | `Select` (Radio for `x-widget: radio`), nothing selected until chosen | +| Choice | `Row{Select, Busy}` | +| DateTime / Date | `DateTimeInput` | +| Slider | `Slider` | +| Object | `Panel(label)` of member cells | +| ObjectArray | `ForEach` of `Panel(label n){cells, Remove}`, then `Add` | + +A cell is label (` *` while required), control, help (muted, only when present), error (Error role, +visible while there is one); its `visible` and `enabled` follow the field. Groups: the implicit group is a +`Grid` of `flatGridColumns` (1 while `gridColumns` is 2), a section a `Panel`, an accordion a collapsible +`Panel` (`FormSession::groupCollapsed`), consecutive tab groups one `Tabs` (`tabSelection`); `x-colspan` +clamps to the grid. Then the status line, the Submit button in explicit mode (enabled while ready), and +`ok: ` / `error: `. + +## Overrides + +`Overrides` maps field path, widget hint, unit (`unitAscii`) and kind — resolved in that order, then the +built-in — to `std::function`. An override replaces the control; the cell around it +stays. `FieldView` (spec, state, session, `defaultControl()`) is copyable and safe to keep in a node. +Chrome is not a registry: a host wanting its own frame builds it from the same `FieldState` and +`FormSession` signals `formView` binds to. + +## Collections + +`CollectionModel::fromSchema(viewJson)` reads `v-*`. `CollectionSession`: the list is a `Query` on +`v-query` (rows are the reply array or its first array member; cells formatted exactly; keys by +`v-rowKey`); actions are one `Mutation` that refetches the list on success; a `confirm` action waits in +`pendingConfirm()`; the row editor is one `FormSession` for `v-rowAction`, reset and prefilled from `bind` +on `open()`, closed and the list refetched when its submission succeeds. `collectionView` draws a `Table` of +the visible columns with Open and row-action buttons, collection actions beside the title, the editor in a +`Dialog` (beside the table for master-detail), and the confirm `Dialog`. + +## Wizards + +`WizardModel::fromSchema(id, wizardJson)` reads `w-*`. `WizardSession` keeps one `FormSession` per step; a +step is done when its last submission succeeded. Each success records `.` from the +submitted body, then from the reply — the reply wins, as `FlowSession` documents. Next applies the step's +`prefill` with `assign` (JSON values), and a step the prefill leaves ready submits in automatic mode. Entering +a step first re-fetches its Choice options (`FormSession::refreshOptions`, `FieldState::refreshOptions`), so a +prefill naming a row an earlier step created is judged against options that include it. +`wizardView` draws the title with the position, the step title, the step's form, Back and Next. + +## App shell + +`AppShellModel::fromSchema(id, appJson)` reads `app-*`, screen kinds `form`, `wizard` and `view` +(`app::ViewScreen`). `AppShellSession` shows the first menu entry's screen and makes each screen's +session the first time it is shown, from `AppShellSources`. `appShellView` draws a `Menu` beside the current +screen. + +## Headers + +Header-only, base `morph` target. `engine/field_model.hpp` (model), `rules.hpp`, `values.hpp`, +`form_session.hpp` (no rendering), `bridge_submitter.hpp`, `handler_submitter.hpp`, `typed_form.hpp`, +`overrides.hpp`, `form_view.hpp`, `collection.hpp`, `wizard.hpp`, `app_shell.hpp`; `engine/detail/text.hpp`. +A controller that needs only `FormSession` includes `form_session.hpp` and nothing from `morph::ui`. + +## Differences from the QML renderer + +| Behaviour | QML renderer | Engine | +|---|---|---| +| Nested objects, collections of objects | unrepresentable unless a slot claims them | drawn and encoded | +| A required collection with no rows | blocks until a slot writes `[]` | `[]` | +| Array items | always strings | by item type | +| Quantity bounds | checked in the canonical unit only | checked whatever the entry unit | +| A required checkbox | seeded `"false"` into the draft | blank draft that encodes `false` | +| Clearing a stale Choice | on options arrival | whenever the answered options do not contain it | +| Several `visibleWhen`/`requiredWhen` for one field | the first one | all of them | +| Chrome | a registry of QML components | composed from the session's signals | + +## Design decisions + +| Decision | Why | +|---|---| +| Unknown blocks nothing | A newer server's rule kinds must not lock an older client out; the server re-checks. | +| Programmatic changes record their body | Prefill and reset are not user actions; recording the body (not a flag) also lets a user edit in the same flush through. | +| Unreduced Quantity literals | Byte parity with what the QML renderer sent and the corpora pin. | +| Rows by `shared_ptr` | A removed row's view still binds to it until it unmounts. | +| Readiness through `accepts` | `Form` adds `validate()` without a second readiness path. | + +## Limitations + +- A self-referential type is editable to the first repetition only; deeper is `truncated`. +- Choice fetching depends on a `ChoiceFetcher`; without one a Choice has no options. +- Display text of the engine's own chrome (status, Add, Remove, Back, Next) is English. +``` + +- [ ] **Step 2: Add the engine sections to the existing specs** + +In `docs/spec/forms/forms.md`, directly before `## Shipped Qt/QML reference renderer`: + +```markdown +## The forms engine (C++) + +The renderer contract above has a C++ implementation: `include/morph/forms/engine/`. `FormModel` reads a +schema in this vocabulary; `FormSession` is the reactive state (drafts, exact encodings, readiness, `x-rules`, +Choice options, submission); `formView` renders it as a `ui::Node` tree on every frontend; `Form` is the +typed facade over `schemaJson()`. It runs the rule, instance-bounds and conformance corpora +(`tests/data/`) alongside the compiled evaluators. Everything it does, and where it differs from the QML +renderer below, is specified in [engine.md](engine.md). +``` + +and add `- [The forms engine (C++)](#the-forms-engine-c)` to its `## Contents` before the QML renderer's entry. + +In `docs/spec/forms/views.md`, before `## The Qt/QML reference renderer`: + +```markdown +## The forms engine's collections + +`morph::forms::CollectionModel` reads this vocabulary and `CollectionSession` runs it: the list is a query on +`v-query`, actions are mutations that refetch it on success, `confirm` actions wait for an answer, and the +`v-rowAction` editor is a `FormSession` prefilled from `bind`. Bodies take the row's JSON values, so an id past +2^53 is sent exactly; cells are formatted from the exact `{num, den, dp}`. `collectionView` renders it as a +table. See [engine.md, "Collections"](engine.md#collections). +``` + +In `docs/spec/forms/workflows_navigation.md`: in `## C++ descriptors`, after the `WizardScreen` description, add +`` `ViewScreen` — a screen backed by a registered view: `kind()` is `"view"`, `ref()` is +`views::ViewTraits::typeId()`. ``; in the `morph::app` API table, after the `WizardScreen` row, add +`` | `ViewScreen` | struct | `id()`, `kind()=="view"`, `ref()==ViewTraits::typeId()`. | ``; +before `## The Qt/QML reference renderer` add: + +```markdown +## The forms engine's wizards and app shell + +`morph::forms::WizardSession` runs a `w-*` document with one `FormSession` per step; a step is done when its +last submission succeeded, and Next applies the next step's `prefill` from the resolved values — the submitted +body, then the reply, the reply winning — as JSON values. `AppShellSession` runs an `app-*` document, making +each screen's session (form, wizard or view) the first time it is shown. See +[engine.md, "Wizards" and "App shell"](engine.md#wizards). +``` + +and replace the `## Limitations` bullet that begins `**`kind: "view"` is not implemented.**` with: + +```markdown +- **`kind: "view"` routes to a registered view** through `app::ViewScreen`; the forms engine's app + shell renders it. The QML reference demo's `AppShell.qml` renders a placeholder for it. +``` + +In `docs/spec/forms/widget_hints.md`, before `## API reference`: + +```markdown +## In the forms engine + +`formView` draws `x-widget: textarea` (`Multiline`) as a multiline `TextInput`, `x-widget: slider` with +`x-min`/`x-max`/`x-step` (`Ranged`) as a `Slider` — the encoding follows the member's JSON type — and +`x-widget: radio` on an Enum or Choice as a radio `Select`. `Overrides::byWidget(hint, …)` replaces the +control for every field with a hint. See [engine.md, "Rendering"](engine.md#rendering). +``` + +- [ ] **Step 3: Maps and changelog** + +In `docs/spec/README.md`, in the "**Schema-driven UI**" group, add after the `forms/forms.md` link line: +`` [`forms/engine.md`](forms/engine.md) · ``. + +In `docs/ARCHITECTURE.md`, "Namespace map", extend the `morph::forms` row's public symbols with: `FormModel`, +`FieldSpec`, `FieldKind`, `RuleExpr`, `Tri`, `FormSession`, `FieldState`, `FormSessionOptions`, `Submitter`, +`ChoiceFetcher`, `bridgeSubmitter`, `bridgeChoiceFetcher`, `handlerSubmitter`, `handlerChoiceFetcher`, `Form`, `formView`, `Overrides`, `FieldView`, +`CollectionSession`, `collectionView`, `WizardSession`, `wizardView`, `AppShellSession`, `appShellView`; and to +the `forms/` header table add: + +```markdown +| `forms/engine/field_model.hpp` | `FormModel`, `FieldSpec`, `FieldKind`, `FieldGroupSpec` — the runtime form model read from schema JSON or an action type (`forAction`) | +| `forms/engine/rules.hpp` | `RuleExpr`, `Tri`, `Scalar`, `SchemaError` — three-valued, exact `x-rules` evaluation and presentation verdicts | +| `forms/engine/values.hpp` | `encodeScalar`, `decodeScalar`, `convertDraft`, `parseOptions`, `FieldError`, `WireValue` | +| `forms/engine/form_session.hpp` | `FormSession`, `FieldState`, `RowState`, `Submitter`, `ChoiceFetcher` — a form's reactive state, no rendering | +| `forms/engine/bridge_submitter.hpp` | `bridgeSubmitter`, `bridgeChoiceFetcher` — routed through `ActionExecuteRegistry` | +| `forms/engine/handler_submitter.hpp` | `handlerSubmitter`, `handlerChoiceFetcher` — routed through the application's own `BridgeHandler`s | +| `forms/engine/typed_form.hpp` | `Form` — the typed facade | +| `forms/engine/overrides.hpp`, `form_view.hpp` | `Overrides`, `FieldView`, `formView` — the form as `ui` nodes | +| `forms/engine/collection.hpp`, `wizard.hpp`, `app_shell.hpp` | collections, wizards and the app shell over `FormSession` | +| `forms/engine/detail/text.hpp` | exact text helpers (detail) | +``` + +and in the `util/` table extend the `util/rational.hpp` row's text with `; `parseDecimal`, the exact decimal +entry parser`. + +In `CHANGELOG.md`, under `## [Unreleased]` → `### Added` (after the entries earlier parts of this branch +added): + +```markdown +- **The C++ forms engine** (`include/morph/forms/engine/`), header-only in the base `morph` target: a form + described by schema JSON or by an action type (`Form`) as a reactive `FormSession` — exact encodings + (integers by digits, `{num, den, dp}` quantities with exact unit conversion), three-valued `x-rules`, + readiness, automatic or explicit submission, prefill, Choice options as queries — and `formView`, which + renders it as a `morph::ui` tree on every frontend, with `Overrides` per field, widget hint, unit or kind. + Collections (`v-*`), wizards (`w-*`) and the app shell (`app-*`) are built on it. `bridgeSubmitter` / + `bridgeChoiceFetcher` route an action type to its model through `ActionExecuteRegistry`, which gains + `modelsServing` and `makeHandler`; `handlerSubmitter` / `handlerChoiceFetcher` route it through the + application's own handlers instead, so an in-memory or shared model instance receives the submissions. + `FormModel::forAction()` builds a form model from a registered action. Specified in + `docs/spec/forms/engine.md`. +- **`morph::math::parseDecimal`**: locale entry text to an exact `Rational`, refusing more places than + declared. +- **`morph::app::ViewScreen`**: an app-shell screen of kind `"view"`. +``` + +- [ ] **Step 4: Pin the nesting depth** + +Append to `docs/spec/pinned_facts.toml`: + +```toml + +# ── The forms engine ──────────────────────────────────────────────────────── +FORMS_ENGINE_MAX_NESTING_DEPTH = 4 # morph::forms::kMaxNestingDepth; include/morph/forms/engine/field_model.hpp +``` + +Append to `tests/test_pinned_facts.cpp` (add `#include `): + +```cpp +// ── The forms engine ───────────────────────────────────────────────────────── + +static_assert(morph::forms::kMaxNestingDepth == + static_cast(morph::pinned_facts::kExpected_FORMS_ENGINE_MAX_NESTING_DEPTH)); +``` + +Run: `cmake build/reactive && cmake --build build/reactive --target morph_tests` — expected to build. Change the +toml value to 5 and reconfigure: expected a `static_assert` failure. Restore it. + +- [ ] **Step 5: Build the docs with warnings as errors** + +```bash +cmake -S . -B build/docs -G Ninja -DMORPH_BUILD_DOCUMENTATION=ON -DMORPH_BUILD_TESTS=OFF -DMORPH_BUILD_EXAMPLES=OFF +cmake --build build/docs --target doc +``` + +Expected: exits 0. A warning naming a `morph/forms/engine` symbol is a missing `@param`/`@tparam`/`@return`: fix +the header. Then `pre-commit run --files docs/spec/forms/*.md docs/spec/README.md docs/ARCHITECTURE.md CHANGELOG.md` +— expected clean (whitespace, JSON validity, codespell). + +- [ ] **Step 6: Commit** + +```bash +git add docs/spec/forms docs/spec/README.md docs/ARCHITECTURE.md CHANGELOG.md docs/spec/pinned_facts.toml \ + tests/test_pinned_facts.cpp +git commit -m "wip(forms): specify the forms engine + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 17: Whole-part verification + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Strict build and the full suite** + +```bash +cmake --build build/reactive && ctest --test-dir build/reactive --output-on-failure +``` + +Expected: every test passes; `VERIFY_INTERFACE_HEADER_SETS` compiles each new public header standalone — the +twelve headers this part adds to `FILE_SET HEADERS` are, in order, `forms/engine/rules.hpp`, `field_model.hpp`, +`values.hpp`, `form_session.hpp`, `bridge_submitter.hpp`, `handler_submitter.hpp` (Task 10b), `typed_form.hpp`, +`overrides.hpp`, `form_view.hpp`, `collection.hpp`, `wizard.hpp` and `app_shell.hpp`, with +`forms/engine/detail/text.hpp` in `morph_detail_headers` (`forms/app.hpp` and `core/bridge.hpp` change in place). +Check the list against `CMakeLists.txt`: `grep -c 'include/morph/forms/engine/' CMakeLists.txt` prints 13. Then +`cmake --build build/all && ctest --test-dir build/all -R forms --output-on-failure` (`build/all` still configures +`MORPH_BUILD_FORMS_QML=ON`): the QML suite still reads the corpora from `tests/data/`. + +- [ ] **Step 2: The QuickTest parity map is real** + +Every TEST_CASE the plan's "QuickTest parity map" names exists (the map's backticked tokens are the test names, +the `.cpp` files and the `tst_*.qml` files; only the names are checked): + +```bash +./build/reactive/tests/morph_tests --list-tests --verbosity quiet > /tmp/morph_names.txt +sed -n '/^## QuickTest parity map/,/^## What the parity map does not carry/p' \ + docs/superpowers/plans/2026-10-04-declarative-ui-tui-5-forms-engine.md \ + | grep -o '`[^`]*`' | tr -d '`' | grep -v -e '\.cpp$' -e '\.qml$' | sort -u \ + | while IFS= read -r name; do grep -Fxq -- "$name" /tmp/morph_names.txt || echo "MISSING: $name"; done +``` + +Expected: no `MISSING:` line. A name with an escaped quote in the source (`\"\"`) is listed by Catch2 with plain +quotes; the map spells it that way. + +- [ ] **Step 3: Sanitizers** (Linux; on macOS an ASan configure of the same tree) + +```bash +cmake --preset clang-asan && cmake --build --preset clang-asan +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-asan/tests/morph_tests asan +./build/clang-asan/tests/morph_tests "[forms-engine]" +cmake --preset clang-tsan && cmake --build --preset clang-tsan +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-tsan/tests/morph_tests tsan +./build/clang-tsan/tests/morph_tests "[forms-engine]" +``` + +Expected: clean. The removed-row and destroyed-session cases rely on ASan as their observer; the bridge cases run +a real thread pool under TSan. + +- [ ] **Step 4: clang-tidy over the changed lines** — the recipe in CONTRIBUTING, "Running the `clang-tidy-diff` + gate locally", with `origin/master...HEAD` and the file count asserted non-zero. + +Expected: no findings. + +- [ ] **Step 5: Install/export** + +```bash +bash scripts/check_install_export.sh +``` + +Expected: passes — `forms/engine/detail/text.hpp` is in the detail file set, so the installed public engine +headers compile. + +- [ ] **Step 6: Commit any fixes** + +```bash +git add -A include/morph tests docs CMakeLists.txt src/qt/forms +git commit -m "wip(forms): fixes from the sanitizer, tidy and install gates + +Signed-off-by: Christian Parpart " +``` + +Skip the commit if there was nothing to fix, and say so in the hand-off. + +--- + +### Task 18: Squash this part into its one commit + +- [ ] **Step 1: Squash** + +Follow the master plan's "Squashing a part" procedure with key `forms` and this message: + +```text +forms: the C++ forms engine, rendered through the view tree + +The schema-driven forms runtime moves from QML/JS into C++: FormModel reads +the schema vocabulary schemaJson() emits; FormSession keeps drafts, +exact encodings, three-valued x-rules, readiness, Choice options and +automatic or explicit submission as morph::reactive state; formView renders +it as morph::ui nodes, so one form draws on every frontend. Form, +collections, wizards and the app shell (with ViewScreen) build on it, and +math::parseDecimal is the decimal entry parser they share. The rule, +instance-bounds and conformance corpora run through the engine. + +Signed-off-by: Christian Parpart +``` + +If a deviation from this plan was made during execution, add one paragraph per deviation to the body before the +sign-off — what the plan said, what was done instead, and why (master plan, "The docs commit"). + +- [ ] **Step 2: Check the history** + +`git log --oneline master..HEAD` ends with `forms: the C++ forms engine, rendered through the view tree`, directly +after the `qt_quick:` commit. + diff --git a/docs/superpowers/plans/2026-10-04-declarative-ui-tui-6-examples-foundation.md b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-6-examples-foundation.md new file mode 100644 index 000000000..4630682a4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-6-examples-foundation.md @@ -0,0 +1,5690 @@ +# Declarative UI, Part 6 — The Examples Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Give every example application a Qt-free foundation — environment, transport, ids, UUIDs, an event +poller, controller wiring and completion mapping, test waits and a frontend smoke harness — teach `morph_add_rung` the `app/` + `ui/` layout, and ship the +two non-rung examples `examples/tui/gallery` and `examples/tui/workout` on the injected frontend. + +**Architecture:** `examples/common/app/` is a new STATIC target, `morph_ladder_app_common`, whose interface names +no toolkit: the Qt (`QtWebSocketBackend`) and `morph::net` (`SocketBackend`) transports are separate translation +units compiled in only when their option is on and linked PRIVATE. It is added from the root `CMakeLists.txt` +whenever the ladder, the bank example or examples/tui is enabled. Test helpers (`testkit/wait.hpp`, +`testkit/fake_app_context.hpp`, `testkit/frontend_smoke.hpp`) form `morph_example_testkit`; a new CMake module +`cmake/morph_example_app.cmake` builds an example's one binary against whichever frontends were built. One +framework seam is added: `morph::net::SocketBackend` implements `setConnectHandler`/`setDisconnectHandler`, so a +terminal client learns its connection state without blocking. + +**Tech Stack:** C++23; morph core (`Bridge`, `LocalBackend`, `IoLoop`, `Completion`), `morph::reactive` +(Part 1), `morph::ui` (Part 2), `morph::tui` (Part 3), `morph::qt_quick` (Part 4), `morph::net`, `morph::qt`; +core-cpp's `core::tui` test doubles; Catch2 v3; CMake. + +**Spec:** `docs/superpowers/specs/2026-10-04-examples-migration-design.md` (spec 4) §2, §3, §4, §6, §8 item 1; +`docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md` (spec 1) §8. + +This is **Part 6 of 11** of the declarative-UI program. Read the master plan +`docs/superpowers/plans/2026-10-04-declarative-ui-tui.md` first: it fixes the branch, the commit layout and the +`wip(...)` commit convention every task below follows, and this part ends by squashing its `wip(examples-common)` +commits into one. The commit is **additions only**: `examples/common`'s Qt pieces (`gui/`, `qml_surface`, +`testkit_main.cpp`'s GUI branch) and `morph_add_rung`'s `gui/`, `gui_lib/`, `gui/qml/` and `gui_wasm/` conventions +stay untouched and keep building every rung; Part 10 removes them (`pump.hpp` stays, for the rig's Socket mode, +whose server is Qt) and rewrites +`examples/TESTING.md`, `IMPLEMENTATION.md` and `LADDER.md` — this part does not edit those three files. + +## Global Constraints + +- Part 1's constraints apply unchanged (C++23; SPDX line first, `#pragma once`; clang-tidy naming; present-tense + comments with no history and no issue numbers; `-Weverything -Werror`; clang-tidy `WarningsAsErrors: "*"`). +- Nothing under `examples/common/app/` includes a Qt, `core::tui` or frontend header except + `transport_qt.cpp` (Qt) and `transport_net.cpp` (`morph::net`); the target links those PRIVATE. +- Every macro tested with `#if` is defined to `0` or `1` by CMake (`-Wundef` is on): `MORPH_EXAMPLE_HAS_TUI`, + `MORPH_EXAMPLE_HAS_QT_QUICK`, `MORPH_EXAMPLES_TRANSPORT_QT`, `MORPH_EXAMPLES_TRANSPORT_NET`. +- A `main` catches every exception (clang-tidy `bugprone-exception-escape` checks `main`) and returns 1 with the + message on `std::cerr`. +- Example code lives in its own namespace (`morph::examples`, `morph::examples::testing`); an example + application's client code lives in `::client` (`gallery::client`, `workout::client`) and exposes + `[[nodiscard]] std::unique_ptr ::client::makeApplication(ui::AppContext& ctx, + examples::AppEnvironment const& env)`, so every `ui/main.cpp` has the shape of spec 4 §3 (the contract's + "Conventions for every example application"). +- View tests read Part 2's `RecordingBackend` (`include/morph/ui/testing/recording_backend.hpp`) exactly as it is + written. Kinds: `Text`, `Button`, `TextInput`, `Checkbox`, `Select`, `Menu`, `Grid`, `Spacer`, `Panel`, `Scroll`, + `Busy`, `DateTimeInput`, `Slider`, `FilePicker`, `Slot`, `Tabs`, `Dialog`, `Table`; a stack is `Column` or `Row` + by its axis, so a `ui::forEach` (vertical by default) mounts as a `Column` and a `Switch` as a `Slot`. Props are + the setter's name without `set`, first letter lower-cased (`label`, `title`, `open`, `selected`, `placeholder`), + except that a `TextInput`'s value is `text`; a bool reads `"true"`/`"false"`. The mount skips a `Common` setter + whose value is the constant default (no `enabled`/`visible` prop on a plain widget) and always calls it for a + binding. `chooseIndex` on a `Menu` runs that item's action; on a `Tabs` it marks the tab `selected`, then calls + `onSelect`. A `Tabs` page mounts the first time it is selected; a `Dialog`'s content exists only while it is + open. `find` returns the lowest live id that matches. +- clang-tidy's `readability-identifier-length` holds in every code block, tests included: no parameter, lambda + parameter or local shorter than three characters except `i j k x y n N fn cb op` (parameters) and + `i j k x y lk cb op fn` (variables); struct and class data members are exempt. Headers (`include/**` and every + example `app/**` header), and the non-test `.cpp` files too, use `.at()` or iteration, never an unchecked + `operator[]` on a container (`cppcoreguidelines-pro-bounds-avoid-unchecked-container-access`); `std::span`, + which has no `at()` before C++26, is indexed after an explicit bounds test. +- No `CHANGELOG.md` entry for the examples; one `### Added` line for the `SocketBackend` seam (Task 1). +- Every example test directory carries a `.clang-tidy` subtracting only `bugprone-chained-comparison`, as + `examples/common/testkit/.clang-tidy` does. +- Commits end with `Signed-off-by: Christian Parpart `. + +## Review Focus + +1. **A connect handler installed after the connection is already up** must still learn it, once — on a threaded + transport the first connect can beat the installation (Task 1 test "a connect handler installed while + connected is called once at installation"). +2. **A value option given as the last argument** (`app --server`) must be refused, not read past `argv` (Task 2 + test "a value option without its value is refused"). +3. **A connect notification arriving after its `Connection` is destroyed** must touch nothing (Task 5 test "a + connect notification after the connection is gone touches nothing"; ASan observes). +4. **A refresh-timer refetch while an events page is in flight** must not apply that page's events twice (Task 7 + test "a refetch while a page is in flight applies its events once"). +5. **A view that never reaches the screen** must make the frontend smoke fail with a non-zero exit, not pass + because `run` returned (Task 8 test "an empty view times out instead of passing"). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `include/morph/net/socket_backend.hpp` | `SocketBackend::setConnectHandler` / `setDisconnectHandler` (the one framework seam) | +| `tests/net/test_socket_backend.cpp` | Cases for the two handlers | +| `docs/spec/core/backend.md`, `CHANGELOG.md` | The seam's spec text and changelog line | +| `CMakeLists.txt` (root) | `include(cmake/morph_example_app.cmake)`; the "Example applications on an injected frontend" block | +| `cmake/morph_example_app.cmake` | `morph_example_frontends()`, `morph_add_example_ui(TARGET … SOURCES … LIBRARIES …)` | +| `cmake/morph_add_rung.cmake` | `app/*.cpp` → `ladder__app`, `ui/*.cpp` → ``, `tests/smoke/*.cpp` → `ladder__smoke_tests` | +| `examples/common/app/CMakeLists.txt` | `morph_ladder_app_common`, `morph_example_testkit`, the tests subdirectory | +| `examples/common/app/app_environment.{hpp,cpp}` | `AppEnvironment::fromArgs`, `detail::applyQuery` (WASM `?poll=`/`?server=`) | +| `examples/common/app/uuid.{hpp,cpp}` | `newUuid()` — RFC 4122 v4, lower-case | +| `examples/common/app/ids.hpp` | Strong id ↔ `std::string` / `ui::Key` | +| `examples/common/app/transport.{hpp,cpp}` | `TransportError`, `LocalSetup`, `Link`, `Connection`, `connect()` | +| `examples/common/app/detail/remote_backends.hpp` | Declarations of the two optional remote-backend factories | +| `examples/common/app/transport_qt.cpp` | `QtWebSocketBackend` factory (only with `MORPH_BUILD_QT`) | +| `examples/common/app/transport_net.cpp` | `SocketBackend` factory (only with `MORPH_BUILD_NET`, POSIX, native) | +| `examples/common/app/poller.hpp` | `PollPage`, `PollerOptions`, `Poller` over a `Query` with `refreshEvery` | +| `examples/common/app/wiring.hpp` | `Wiring{runtime, scheduler, bridge, callbacks}` — what every controller is built from, all borrowed | +| `examples/common/app/completion_map.hpp` | `mapCompletion` — derive a completion on the owner, gated by a token | +| `examples/common/testkit/wait.hpp` | `pumpUntil` (MainThreadExecutor, StepExecutor), `awaitOn` | +| `examples/common/testkit/fake_app_context.hpp` | `FakeAppContext` — a headless `ui::AppContext` for controller and view tests | +| `examples/common/testkit/frontend_smoke.hpp`, `testkit_src/frontend_smoke.cpp` | `SmokeFrontend`, `SmokeResult`, `smokeRun`, `runFrontendSmoke` | +| `examples/common/app/tests/` | `examples_common_app_tests`: every piece above | +| `examples/tui/CMakeLists.txt`, `README.md` | Registers gallery and workout; how to run them | +| `examples/tui/gallery/` | `gallery_app`, `gallery`, `gallery_tests`; client code in `gallery::client` | +| `examples/tui/workout/` | `workout_app`, `workout`, `workout_tests`; client code in `workout::client` | + +## Build and test commands + +Three configurations; the first is the master plan's, the other two prove each frontend and each transport is +optional. + +```bash +cmake -S . -B build/all -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_NET=ON -DMORPH_BUILD_FORMS_QML=ON # once +cmake -S . -B build/ex-tui -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_NET=ON # once +cmake -S . -B build/ex-qt -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON # once +cmake --build build/all --target examples_common_app_tests +./build/all/examples/common/app/tests/examples_common_app_tests +``` + +Configuring prints `morph: warnings: ... strict=ON`; if it does not, stop and say so (CONTRIBUTING, "Warnings +are errors"). `build/ex-tui` has no Qt at all and `build/ex-qt` no TUI and no `morph::net`; both keep +`MORPH_BUILD_EXAMPLES` at its default `ON`. + +--- +### Task 1: `SocketBackend` reports connects and drops + +`Connection::ready()` (Task 5) needs to learn, without blocking, when a remote transport comes up and goes down. +`QtWebSocketBackend` implements `IBackend::setConnectHandler`/`setDisconnectHandler`; `SocketBackend` does not +(`docs/spec/core/backend.md`, "Connect/disconnect notifications", says so), so a terminal client could only poll +`waitForConnected`. The fix is smaller than an issue describing it, so it is made here (AGENTS.md, "The bar"). + +**Files:** +- Modify: `include/morph/net/socket_backend.hpp` — two public overrides after `setReconnectHandler` + (line ~403); in `Core`: `notify`, two members after `reconnectExec` (line ~922), and edits to `onConnected` + (~689), `onDisconnected` (~704) and `close` (~732) +- Modify: `tests/net/test_socket_backend.cpp` — append five cases after + `"SocketBackend: a reconnect handler throwing a non-std::exception leaves the transport usable"` (~1588) +- Modify: `docs/spec/core/backend.md` — "Connect/disconnect notifications" last paragraph (~178) and the + `### SocketBackend (namespace morph::net)` table (~1910) +- Modify: `CHANGELOG.md` — `## [Unreleased]` → `### Added` + +**Interfaces:** +- Consumes: `IBackend::setConnectHandler(const std::function&)`, + `IBackend::setDisconnectHandler(const std::function&)` (`include/morph/core/backend.hpp:522`, `:537`); + `morph::log::logError(std::format_string<…>, …)` (`core/logger.hpp:256`). +- Produces: `morph::net::SocketBackend::setConnectHandler`, `setDisconnectHandler` — run on the I/O loop; a + connect handler installed while connected is called once at installation; `close()` clears both and calls + neither; the disconnect handler runs only when an established connection drops. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/net/test_socket_backend.cpp`, after the case named above: + +```cpp +TEST_CASE("SocketBackend: the connect handler runs once after the first connect", + "[net][socket_backend][notifications]") { + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + morph::net::SocketServer wsServer{*server, 0}; + REQUIRE(wsServer.listen()); + morph::net::SocketBackend backend{"ws://127.0.0.1:" + std::to_string(static_cast(wsServer.port()))}; + std::atomic connects{0}; + // Installed while the dial may still be in progress or may already have completed: either way it runs once. + backend.setConnectHandler([&] { connects.fetch_add(1); }); + REQUIRE(backend.waitForConnected()); + spinUntil([&] { return connects.load() == 1; }); + CHECK(backend.registerModel("SbEchoModel", nullptr).v != 0U); // a round trip through the loop + CHECK(connects.load() == 1); +} + +TEST_CASE("SocketBackend: a connect handler installed while connected is called once at installation", + "[net][socket_backend][notifications]") { + SharedLoopStack stack; // connected on return + std::atomic connects{0}; + stack.backend->setConnectHandler([&] { connects.fetch_add(1); }); + spinUntil([&] { return connects.load() == 1; }); + CHECK(stack.backend->registerModel("SbEchoModel", nullptr).v != 0U); + CHECK(connects.load() == 1); +} + +TEST_CASE("SocketBackend: the disconnect handler runs on a drop, and the connect handler again on reconnect", + "[net][socket_backend][notifications][disconnect]") { + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + morph::net::SocketBackend::Config cfg; + cfg.initialReconnectDelay = std::chrono::milliseconds{50}; + cfg.maxReconnectDelay = std::chrono::milliseconds{200}; + std::atomic connects{0}; + std::atomic drops{0}; + std::uint16_t port = 0; + std::unique_ptr backend; + { + morph::net::SocketServer first{*server, 0}; + REQUIRE(first.listen()); + port = first.port(); + backend = std::make_unique( + "ws://127.0.0.1:" + std::to_string(static_cast(port)), cfg); + backend->setConnectHandler([&] { connects.fetch_add(1); }); + backend->setDisconnectHandler([&] { drops.fetch_add(1); }); + REQUIRE(backend->waitForConnected()); + spinUntil([&] { return connects.load() == 1; }); + } + spinUntil([&] { return drops.load() == 1; }); + CHECK(drops.load() == 1); + + std::this_thread::sleep_for(std::chrono::milliseconds{100}); // the OS releases the port + morph::net::SocketServer second{*server, port}; + REQUIRE(second.listen()); + spinUntil([&] { return connects.load() == 2; }); + CHECK(connects.load() == 2); + CHECK(drops.load() == 1); +} + +TEST_CASE("SocketBackend: a throwing connect handler leaves the transport usable", + "[net][socket_backend][notifications]") { + SharedLoopStack stack; + std::atomic calls{0}; + stack.backend->setConnectHandler([&] { + calls.fetch_add(1); + throw std::runtime_error("connect handler blew up"); + }); + spinUntil([&] { return calls.load() == 1; }); + CHECK(calls.load() == 1); + CHECK(stack.backend->registerModel("SbEchoModel", nullptr).v != 0U); +} + +TEST_CASE("SocketBackend: closing the backend calls neither handler", "[net][socket_backend][notifications]") { + std::atomic drops{0}; + { + SharedLoopStack stack; + stack.backend->setDisconnectHandler([&] { drops.fetch_add(1); }); + CHECK(stack.backend->registerModel("SbEchoModel", nullptr).v != 0U); // the install has run + stack.backend.reset(); + } + CHECK(drops.load() == 0); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/all --target morph_net_tests && ./build/all/tests/net/morph_net_tests "[notifications]"` +Expected: FAIL — `connects.load() == 1` is `0 == 1` (the base class stores and ignores the handler), and the +throwing-handler case reports `calls.load() == 1` as `0 == 1`. + +- [ ] **Step 3: Implement** + +In `include/morph/net/socket_backend.hpp`, after `setReconnectHandler`'s definition, add: + +```cpp + /// @brief Installs the callback run on the I/O loop after every successful connect, the first included. + /// + /// Posted: the loop stores it. Installed while the connection is already up, it is also called once at + /// installation, because on a transport with its own thread the first connect can complete before the + /// caller installs anything. It runs on the loop's thread, so it must not wait for the loop: post to an + /// executor of your own for anything more. A throw is logged and the transport goes on. `close()` clears it. + /// @param handler The callback; `nullptr` clears it. + void setConnectHandler(const std::function& handler) override { + _loop->post([core = _core, handler] { + core->note("SocketBackend::setConnectHandler"); + if (core->closed) { + return; + } + core->connectHandler = handler; + if (core->connected.load()) { + Core::notify(core->connectHandler, "connect"); + } + }); + } + + /// @brief Installs the callback run on the I/O loop when an established connection drops. + /// + /// Posted: the loop stores it. It runs after the pending calls are rejected and before a reconnect is + /// scheduled, and never for a dial that did not connect or for `close()`, which clears it. Same thread and + /// throw rules as `setConnectHandler`. + /// @param handler The callback; `nullptr` clears it. + void setDisconnectHandler(const std::function& handler) override { + _loop->post([core = _core, handler] { + core->note("SocketBackend::setDisconnectHandler"); + if (!core->closed) { + core->disconnectHandler = handler; + } + }); + } +``` + +In `struct Core`, add before `onConnected`: + +```cpp + /// Runs a connect or disconnect handler on the loop. A throw is logged here: an exception escaping into + /// the loop's turn would end the connection's flow. + static void notify(std::function const& handler, char const* what) noexcept { + if (!handler) { + return; + } + try { + handler(); + } catch (const std::exception& error) { + ::morph::log::logError("[socket-backend] {} handler threw: {}", what, error.what()); + } catch (...) { + ::morph::log::logError("[socket-backend] {} handler threw a non-std::exception", what); + } + } +``` + +At the end of `onConnected()`, after the reconnect-handler `if`: + +```cpp + // A copy: the handler may install a replacement, which is posted, but the copy keeps the call + // independent of the member either way. + auto const handler = connectHandler; + notify(handler, "connect"); +``` + +Replace `onDisconnected()` with: + +```cpp + void onDisconnected() { + // Called after every attempt, a dial that never connected included; only a drop of an + // established connection is reported. + bool const wasConnected = connected.exchange(false); + if (conn) { + ::morph::net::detail::closeAfterFlush(conn); + conn.reset(); + } + cancelAll(std::make_exception_ptr(::morph::backend::DisconnectedError{})); + if (wasConnected) { + auto const handler = disconnectHandler; + notify(handler, "disconnect"); + } + } +``` + +In `close()`, after `reconnectHandler = nullptr;`: + +```cpp + connectHandler = nullptr; + disconnectHandler = nullptr; +``` + +After the member `::morph::exec::IExecutor* reconnectExec{nullptr};`: + +```cpp + /// Run on the loop after every connect; cleared by `close()`. + std::function connectHandler; + /// Run on the loop when an established connection drops; cleared by `close()`. + std::function disconnectHandler; +``` + +In `docs/spec/core/backend.md`, replace the paragraph beginning "`QtWebSocketBackend` is currently the only +backend that overrides either" with: + +```markdown +`QtWebSocketBackend` and `SocketBackend` override both. `QtWebSocketBackend`'s +`connected`/`disconnected` `QWebSocket` signal slots invoke `_connectHandler`/ +`_disconnectHandler` (if installed) at the same points they already invoke +`_reconnectHandler`/schedule a reconnect — see that section below. +`SocketBackend` runs both on its I/O loop: the connect handler after every +completed handshake, the disconnect handler when an established connection +drops, after its pending calls are rejected and before a reconnect is +scheduled — never for a dial that did not connect. Because its first connect +runs on the loop's own thread and can complete before a caller installs a +handler, a connect handler installed while the connection is up is also called +once at installation. `close()` clears both and calls neither; a throwing +handler is logged and the transport goes on. +``` + +In the `### SocketBackend (namespace morph::net)` table, after the `setReconnectHandler` row, add: + +```markdown +| `setConnectHandler(handler)` | Posted: the loop stores it and runs it after every completed connect, the first included; installed while connected, it is also called once at installation. On the loop's thread; a throw is logged. `nullptr` clears; `close()` clears. | +| `setDisconnectHandler(handler)` | Posted: the loop stores it and runs it when an established connection drops, after the pending calls are rejected and before a reconnect is scheduled; never for a failed dial or `close()`. `nullptr` clears. | +``` + +In `CHANGELOG.md`, under `## [Unreleased]` → `### Added`, append: + +```markdown +- **`morph::net::SocketBackend` reports connects and drops.** It implements + `IBackend::setConnectHandler` and `setDisconnectHandler`, run on its I/O loop, + so a client on the raw-socket transport learns its connection state without + blocking in `waitForConnected`. A connect handler installed while the + connection is up is called once at installation; closing the backend calls + neither. +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/all --target morph_net_tests && ./build/all/tests/net/morph_net_tests "[socket_backend]"` +Expected: PASS, every `[socket_backend]` case (the existing ones prove the `onDisconnected` rewrite changed +nothing else). Mutation check: delete the `if (core->connected.load()) { … }` block in `setConnectHandler`. +Expected FAIL in "a connect handler installed while connected is called once at installation" (`0 == 1`). +Restore. Second mutation: drop the `if (wasConnected)` guard in `onDisconnected`. Expected FAIL in "the +disconnect handler runs on a drop, and the connect handler again on reconnect": the re-dials that fail while no +server listens (50 ms backoff inside the 100 ms gap) each call `onDisconnected`, so the last +`CHECK(drops.load() == 1)` reads `2` or more. Restore. + +- [ ] **Step 5: Commit** + +```bash +git add include/morph/net/socket_backend.hpp tests/net/test_socket_backend.cpp docs/spec/core/backend.md CHANGELOG.md +git commit -m "wip(examples-common): SocketBackend reports connects and drops + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 2: `morph_ladder_app_common` and `AppEnvironment` + +**Where the target lives, and why.** Three consumers need it: the ladder (`MORPH_BUILD_LADDER`, which is *not* +gated on `MORPH_BUILD_EXAMPLES`), the bank example (`MORPH_BUILD_BANK_EXAMPLE`, added from the root's Demo block, +not the ladder) and `examples/tui` (`MORPH_BUILD_EXAMPLES` plus a frontend). So `examples/common/app/` gets its own +`CMakeLists.txt`, added **once** from the root in a new block placed *after* the Tests block (its tests and +examples/tui's call `include(Catch)` the way `tests/net` does) and *before* the ladder block (so +`morph_add_rung` finds it). `examples/bank`, added earlier from the Demo block, links +`morph::ladder_app_common` by name; CMake resolves target names at generate time, so the order is harmless there, +and bank must not test `if(TARGET …)` for it. The directory is added under Emscripten too (the ladder's WASM +configure builds it), where its tests are skipped. + +**Files:** +- Create: `examples/common/app/CMakeLists.txt`, `examples/common/app/app_environment.hpp`, + `examples/common/app/app_environment.cpp` +- Create: `examples/common/app/tests/CMakeLists.txt`, `examples/common/app/tests/.clang-tidy`, + `examples/common/app/tests/test_app_environment.cpp` +- Modify: `CMakeLists.txt` (root) — a new block between the end of the `# ── Tests ──` block (`endif()` after + `add_subdirectory(tests)`) and `# ── Application ladder (optional) ──` + +**Interfaces:** +- Consumes: `morph::morph`, `morph_test_main`, `apply_warnings`/`apply_sanitizers`/`apply_coverage` + (`cmake/compiler_options.cmake`). +- Produces: target `morph_ladder_app_common` / `morph::ladder_app_common` (PUBLIC include dir + `examples/common`, so headers are `` and ``); `morph::examples::AppEnvironment{server, db, + user, seed, pollId}` with `static AppEnvironment fromArgs(int, char const* const*)`; + `morph::examples::detail::applyQuery(AppEnvironment&, std::string_view)`; test target + `examples_common_app_tests` (ctest label `examples-common`). + +- [ ] **Step 1: Write the failing test** + +Create `examples/common/app/tests/.clang-tidy`: + +```yaml +# Test idiom, not a defect: Catch2's REQUIRE(a == b) expands to a chained comparison. The full reasoning, and +# why the file is per directory, is in examples/common/testkit/.clang-tidy. +Checks: '-bugprone-chained-comparison' +CheckOptions: + - key: readability-function-cognitive-complexity.IgnoreMacros + value: "true" +InheritParentConfig: true +``` + +Create `examples/common/app/tests/test_app_environment.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +using morph::examples::AppEnvironment; + +namespace { + +template +AppEnvironment parse(std::array const& argv) { + return AppEnvironment::fromArgs(static_cast(argv.size()), argv.data()); +} + +} // namespace + +TEST_CASE("AppEnvironment: no options leaves every field at its default", "[examples-common][environment]") { + auto const env = parse(std::array{"app"}); + CHECK_FALSE(env.server.has_value()); + CHECK(env.db.empty()); + CHECK(env.user.empty()); + CHECK_FALSE(env.seed); + CHECK_FALSE(env.pollId.has_value()); +} + +TEST_CASE("AppEnvironment: each option reads its value in both spellings", "[examples-common][environment]") { + auto const spaced = parse(std::array{"app", "--server", "ws://h:1", "--db", "a.db", "--user", "ann", "--poll", + "p1", "--seed"}); + CHECK(spaced.server == std::optional{"ws://h:1"}); + CHECK(spaced.db == "a.db"); + CHECK(spaced.user == "ann"); + CHECK(spaced.pollId == std::optional{"p1"}); + CHECK(spaced.seed); + + auto const joined = parse(std::array{"app", "--server=ws://h:2", "--db=b.db", "--user=bob", "--poll=p2"}); + CHECK(joined.server == std::optional{"ws://h:2"}); + CHECK(joined.db == "b.db"); + CHECK(joined.user == "bob"); + CHECK(joined.pollId == std::optional{"p2"}); + CHECK_FALSE(joined.seed); +} + +TEST_CASE("AppEnvironment: unknown arguments are left to the frontend", "[examples-common][environment]") { + auto const env = parse(std::array{"app", "--ui=tui", "-platform", "offscreen", "--db", "x.db"}); + CHECK(env.db == "x.db"); + CHECK_FALSE(env.server.has_value()); +} + +TEST_CASE("AppEnvironment: a value option without its value is refused", "[examples-common][environment]") { + CHECK_THROWS_WITH(parse(std::array{"app", "--server"}), Catch::Matchers::ContainsSubstring("--server")); + CHECK_THROWS_AS(parse(std::array{"app", "--db", "x.db", "--user"}), std::invalid_argument); +} + +TEST_CASE("AppEnvironment: a later occurrence wins", "[examples-common][environment]") { + auto const env = parse(std::array{"app", "--db", "first.db", "--db=second.db"}); + CHECK(env.db == "second.db"); +} + +TEST_CASE("AppEnvironment: a longer word that starts with an option is not that option", + "[examples-common][environment]") { + auto const env = parse(std::array{"app", "--serverx=1", "--dbfile", "y"}); + CHECK_FALSE(env.server.has_value()); + CHECK(env.db.empty()); +} + +TEST_CASE("applyQuery: reads server and poll, decoded, and ignores the rest", "[examples-common][environment]") { + AppEnvironment env; + morph::examples::detail::applyQuery(env, "?poll=abc%2D1&lang=en&server=ws%3A%2F%2Fhost%3A8080&x"); + CHECK(env.pollId == std::optional{"abc-1"}); + CHECK(env.server == std::optional{"ws://host:8080"}); +} + +TEST_CASE("applyQuery: a malformed escape is kept, a plus is a space, an empty value is absent", + "[examples-common][environment]") { + AppEnvironment env; + morph::examples::detail::applyQuery(env, "poll=%zz+1&server="); + CHECK(env.pollId == std::optional{"%zz 1"}); + CHECK_FALSE(env.server.has_value()); +} +``` + +Create `examples/common/app/tests/CMakeLists.txt`: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# +# examples_common_app_tests: the Qt-free example foundation, tested headless. It links Qt only when the Qt +# transport or the Qt Quick smoke half is built, and its main (morph_test_main) owns no Qt application object, +# which is what lets the Qt Quick smoke construct its own. + +add_executable(examples_common_app_tests + test_app_environment.cpp +) +target_link_libraries(examples_common_app_tests PRIVATE morph::ladder_app_common morph_test_main) +target_compile_features(examples_common_app_tests PRIVATE cxx_std_23) +apply_warnings(examples_common_app_tests) +if(AF_COVERAGE) + apply_coverage(examples_common_app_tests) +endif() +if(DEFINED AF_SANITIZER) + apply_sanitizers(examples_common_app_tests ${AF_SANITIZER}) +endif() + +include(Catch) +catch_discover_tests(examples_common_app_tests + DISCOVERY_MODE PRE_TEST + PROPERTIES LABELS examples-common TIMEOUT 120) +``` + +Create `examples/common/app/CMakeLists.txt`: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# +# morph_ladder_app_common: what every example application's composition root and controllers share -- the +# environment a binary reads from its command line, the transport that builds its Bridge, ids, UUIDs and the +# event poller. The ladder, the bank example and examples/tui link it, so the root CMakeLists.txt adds this +# directory when any of them is enabled. +# +# Its interface names no toolkit: an application library links it and still fails to compile on a Qt include. +# The Qt and morph::net transports are separate translation units, compiled in only when their option is on and +# linked PRIVATE, so their include paths never reach a consumer. + +if(NOT TARGET morph::morph) + message(FATAL_ERROR "examples/common/app expects the morph::morph target; configure from the repository root.") +endif() + +add_library(morph_ladder_app_common STATIC + app_environment.cpp +) +add_library(morph::ladder_app_common ALIAS morph_ladder_app_common) +target_include_directories(morph_ladder_app_common PUBLIC "${PROJECT_SOURCE_DIR}/examples/common") +target_link_libraries(morph_ladder_app_common PUBLIC morph::morph) +target_compile_features(morph_ladder_app_common PUBLIC cxx_std_23) +apply_warnings(morph_ladder_app_common) +if(AF_COVERAGE) + apply_coverage(morph_ladder_app_common) +endif() +if(DEFINED AF_SANITIZER) + apply_sanitizers(morph_ladder_app_common ${AF_SANITIZER}) +endif() + +# Catch2 and a terminal or desktop to run on: neither exists in a WebAssembly configure. +if(MORPH_BUILD_TESTS AND NOT EMSCRIPTEN) + add_subdirectory(tests) +endif() +``` + +In the root `CMakeLists.txt`, between the Tests block and `# ── Application ladder (optional) ──`, add: + +```cmake +# ── Example applications on an injected frontend ──────────────────────────── +# examples/common/app is the toolkit-free foundation every example application +# builds on (see its CMakeLists.txt). The ladder, the bank example, examples/tui +# and the forms demo (examples/forms, added in every native configure with +# examples on) each link it, so it is added once, when any of them is on -- +# MORPH_BUILD_LADDER on its own, since the ladder is not gated on +# MORPH_BUILD_EXAMPLES. It comes after the Tests block because its tests call +# include(Catch), and before the ladder block because morph_add_rung links it. +# examples/bank and examples/forms, added from the Demo block above, link it by +# name, which CMake resolves at generate time. +if(MORPH_BUILD_LADDER OR (MORPH_BUILD_EXAMPLES AND (NOT EMSCRIPTEN OR MORPH_BUILD_BANK_EXAMPLE OR MORPH_BUILD_TUI + OR MORPH_BUILD_QT_QUICK))) + add_subdirectory(examples/common/app) +endif() +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake -S . -B build/all && cmake --build build/all --target examples_common_app_tests` +Expected: FAIL — CMake reports `Cannot find source file: app_environment.cpp`. + +- [ ] **Step 3: Implement** + +Create `examples/common/app/app_environment.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// @brief `morph::examples::AppEnvironment`: the deployment choices an example's `main` reads from its command +/// line (and, in a browser, from the page's query string). + +namespace morph::examples { + +/// @brief What an example application is pointed at: a server or an in-process database, a principal, demo data. +/// +/// `main` reads it once and hands it to the application factory; nothing here names a frontend. +struct AppEnvironment { + /// @brief `--server URL`: run against a remote `RemoteServer`; empty runs the models in-process. + std::optional server; + /// @brief `--db PATH`: the in-process database; empty lets the application choose its default. + std::string db; + /// @brief `--user NAME`: the principal installed as the bridge's default session; empty installs none. + std::string user; + /// @brief `--seed`: populate an empty database with demo data. + bool seed = false; + /// @brief `--poll ID` natively, `?poll=ID` in a browser: the poll a polls client opens first. + std::optional pollId; + + /// @brief Reads the options above from @p argv, and under WebAssembly also from the page's query string. + /// + /// Each value option is accepted as `--name value` or `--name=value`; a later occurrence wins. Every other + /// argument belongs to the frontend (`--ui=`, Qt's own options) and is ignored here. + /// @param argc The argument count `main` received. + /// @param argv The arguments `main` received; `argv[0]` is the program and is skipped. + /// @return The environment. + /// @throws std::invalid_argument when a value option is the last argument. + [[nodiscard]] static AppEnvironment fromArgs(int argc, char const* const* argv); +}; + +namespace detail { + +/// @brief Applies a URL query string's `server` and `poll` parameters to @p env. +/// +/// Other parameters are ignored, `+` decodes to a space and `%XX` to its byte (a malformed escape is kept as +/// written), and a parameter with an empty value leaves its field as it was. +/// @param env The environment to update. +/// @param query The query, with or without its leading `?`. +void applyQuery(AppEnvironment& env, std::string_view query); + +} // namespace detail + +} // namespace morph::examples +``` + +Create `examples/common/app/app_environment.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "app/app_environment.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__EMSCRIPTEN__) +#include + +#include +#endif + +namespace morph::examples { + +namespace { + +/// One option that takes a value, and where the value goes. +struct ValueOption { + std::string_view name; + void (*assign)(AppEnvironment&, std::string); +}; + +constexpr std::array kValueOptions{{ + {"--server", [](AppEnvironment& env, std::string value) { env.server = std::move(value); }}, + {"--db", [](AppEnvironment& env, std::string value) { env.db = std::move(value); }}, + {"--user", [](AppEnvironment& env, std::string value) { env.user = std::move(value); }}, + {"--poll", [](AppEnvironment& env, std::string value) { env.pollId = std::move(value); }}, +}}; + +/// The value option @p arg names, and its inline `=value` when it carries one. +struct Match { + ValueOption const* option = nullptr; + std::optional inlineValue; +}; + +Match match(std::string_view arg) { + for (auto const& option : kValueOptions) { + if (arg == option.name) { + return Match{.option = &option, .inlineValue = std::nullopt}; + } + if (arg.size() > option.name.size() && arg.starts_with(option.name) && arg.at(option.name.size()) == '=') { + return Match{.option = &option, .inlineValue = arg.substr(option.name.size() + 1)}; + } + } + return Match{}; +} + +std::optional hexDigit(char digit) { + if (digit >= '0' && digit <= '9') { + return static_cast(digit - '0'); + } + if (digit >= 'a' && digit <= 'f') { + return static_cast(digit - 'a') + 10U; + } + if (digit >= 'A' && digit <= 'F') { + return static_cast(digit - 'A') + 10U; + } + return std::nullopt; +} + +std::string decode(std::string_view text) { + std::string out; + out.reserve(text.size()); + for (std::size_t i = 0; i < text.size(); ++i) { + char const current = text.at(i); + if (current == '+') { + out.push_back(' '); + continue; + } + if (current == '%' && i + 2 < text.size()) { + auto const high = hexDigit(text.at(i + 1)); + auto const low = hexDigit(text.at(i + 2)); + if (high && low) { + out.push_back(static_cast((*high << 4U) | *low)); + i += 2; + continue; + } + } + out.push_back(current); + } + return out; +} + +#if defined(__EMSCRIPTEN__) +// Returns a malloc'd UTF-8 copy of `window.location.search`, freed by the caller. EM_JS bodies reach the +// runtime's string helpers without -sEXPORTED_RUNTIME_METHODS, and need no Embind. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables): EM_JS defines a function through a macro. +EM_JS(char*, morphExamplesPageQuery, (), { + var query = window.location.search; + var length = lengthBytesUTF8(query) + 1; + var ptr = _malloc(length); + stringToUTF8(query, ptr, length); + return ptr; +}); + +std::string pageQuery() { + char* raw = morphExamplesPageQuery(); + std::string query{raw}; + std::free(raw); // NOLINT(cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory): EM_JS hands out malloc'd memory. + return query; +} +#endif + +} // namespace + +AppEnvironment AppEnvironment::fromArgs(int argc, char const* const* argv) { + AppEnvironment env; + auto const args = std::span{argv, argc > 0 ? static_cast(argc) : std::size_t{0}}; + for (std::size_t i = 1; i < args.size(); ++i) { + std::string_view const arg = args[i] != nullptr ? std::string_view{args[i]} : std::string_view{}; + if (arg == "--seed") { + env.seed = true; + continue; + } + auto const found = match(arg); + if (found.option == nullptr) { + continue; + } + if (found.inlineValue) { + found.option->assign(env, std::string{*found.inlineValue}); + continue; + } + if (i + 1 >= args.size() || args[i + 1] == nullptr) { + throw std::invalid_argument{std::string{found.option->name} + " needs a value"}; + } + ++i; + found.option->assign(env, std::string{args[i]}); + } +#if defined(__EMSCRIPTEN__) + detail::applyQuery(env, pageQuery()); +#endif + return env; +} + +void detail::applyQuery(AppEnvironment& env, std::string_view query) { + if (query.starts_with('?')) { + query.remove_prefix(1); + } + while (!query.empty()) { + auto const amp = query.find('&'); + auto const pair = query.substr(0, amp); + query = amp == std::string_view::npos ? std::string_view{} : query.substr(amp + 1); + auto const equals = pair.find('='); + if (equals == std::string_view::npos) { + continue; + } + auto const key = decode(pair.substr(0, equals)); + auto value = decode(pair.substr(equals + 1)); + if (value.empty()) { + continue; + } + if (key == "server") { + env.server = std::move(value); + } else if (key == "poll") { + env.pollId = std::move(value); + } + } +} + +} // namespace morph::examples +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target examples_common_app_tests && ./build/all/examples/common/app/tests/examples_common_app_tests "[environment]" +``` + +Expected: PASS, 8 test cases. Also `cmake --build build/ex-tui --target examples_common_app_tests` and the same +for `build/ex-qt` build (the block's condition holds there through `MORPH_BUILD_TUI` / `MORPH_BUILD_QT_QUICK`). +Mutation check: in `match()`, replace `arg.at(option.name.size()) == '='` with `true`. Expected FAIL in "a longer +word that starts with an option is not that option" (`--serverx=1` sets `server`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add CMakeLists.txt examples/common/app +git commit -m "wip(examples-common): morph_ladder_app_common and AppEnvironment + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 3: `newUuid()` and the id helpers + +`QUuid` idempotency keys (kanban, ledger) and `id_qml.hpp`'s `QString` forms get Qt-free replacements. +`id_qml.hpp` stays until Part 10; nothing switches to these helpers in this part. + +**Files:** +- Create: `examples/common/app/uuid.hpp`, `examples/common/app/uuid.cpp`, `examples/common/app/ids.hpp` +- Modify: `examples/common/app/CMakeLists.txt` — add `uuid.cpp` to `morph_ladder_app_common`'s sources, after + `app_environment.cpp` +- Modify: `examples/common/app/tests/CMakeLists.txt` — add `test_uuid.cpp` and `test_ids.cpp` after + `test_app_environment.cpp` +- Test: `examples/common/app/tests/test_uuid.cpp`, `examples/common/app/tests/test_ids.cpp` + +**Interfaces:** +- Consumes: `morph::ui::Key` (`include/morph/ui/view.hpp`, Part 2). +- Produces: `morph::examples::newUuid() -> std::string`; in `ids.hpp`: `kNoId` (`std::int64_t{-1}`), + `idNumber(Id const&) -> std::int64_t`, `idText(Id const&) -> std::string`, + `idFromText(std::string_view) -> Id`, `idKey(Id const&) -> ui::Key`, `idFromKey(ui::Key const&) -> Id`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/common/app/tests/test_uuid.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +TEST_CASE("newUuid: an RFC 4122 version-4 UUID in lower case", "[examples-common][uuid]") { + std::regex const shape{"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}; + for (int i = 0; i < 100; ++i) { + auto const uuid = morph::examples::newUuid(); + INFO(uuid); + CHECK(std::regex_match(uuid, shape)); + } +} + +TEST_CASE("newUuid: a thousand in a row are distinct", "[examples-common][uuid]") { + std::set seen; + for (int i = 0; i < 1000; ++i) { + seen.insert(morph::examples::newUuid()); + } + CHECK(seen.size() == std::size_t{1000}); +} +``` + +Create `examples/common/app/tests/test_ids.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +using morph::examples::idFromKey; +using morph::examples::idFromText; +using morph::examples::idKey; +using morph::examples::idNumber; +using morph::examples::idText; +using morph::examples::kNoId; + +namespace { + +// The two id shapes the examples have: payload in an optional, and a plain integer whose 0 means "none". +struct OptionalId { + std::optional value; + OptionalId() = default; + explicit OptionalId(std::int64_t payload) : value{payload} {} + [[nodiscard]] bool hasValue() const { return value.has_value(); } + [[nodiscard]] std::int64_t operator*() const { return *value; } // NOLINT(bugprone-unchecked-optional-access) + bool operator==(OptionalId const&) const = default; +}; + +struct ZeroSentinel { + std::int64_t value = 0; + [[nodiscard]] bool hasValue() const { return value != 0; } + [[nodiscard]] std::int64_t operator*() const { return value; } + bool operator==(ZeroSentinel const&) const = default; +}; + +} // namespace + +TEST_CASE("ids: unset, zero and an ordinary id stay apart", "[examples-common][ids]") { + CHECK(idNumber(OptionalId{}) == kNoId); + CHECK(idNumber(OptionalId{0}) == 0); + CHECK(idNumber(OptionalId{7}) == 7); + CHECK(idText(OptionalId{}).empty()); + CHECK(idText(OptionalId{0}) == "0"); + CHECK(idText(OptionalId{7}) == "7"); +} + +TEST_CASE("ids: a zero-sentinel id's zero is its empty state", "[examples-common][ids]") { + CHECK(idNumber(ZeroSentinel{}) == kNoId); + CHECK(idText(ZeroSentinel{}).empty()); + CHECK(idText(ZeroSentinel{9}) == "9"); +} + +TEST_CASE("ids: text round-trips, and anything but a whole integer is the empty id", "[examples-common][ids]") { + CHECK(idFromText("42") == OptionalId{42}); + CHECK(idFromText("0") == OptionalId{0}); + CHECK(idFromText("") == OptionalId{}); + CHECK(idFromText("4x") == OptionalId{}); + CHECK(idFromText(" 4") == OptionalId{}); + CHECK(idFromText("9223372036854775807") == OptionalId{INT64_MAX}); + CHECK(idFromText("9223372036854775808") == OptionalId{}); +} + +TEST_CASE("ids: a view key carries the id as an integer, never a double", "[examples-common][ids]") { + std::int64_t const big = (std::int64_t{1} << 53) + 1; + morph::ui::Key const key = idKey(OptionalId{big}); + REQUIRE(std::holds_alternative(key)); + CHECK(std::get(key) == big); + CHECK(idFromKey(key) == OptionalId{big}); + CHECK(idFromKey(idKey(OptionalId{})) == OptionalId{}); + CHECK(idFromKey(morph::ui::Key{std::string{"17"}}) == OptionalId{17}); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/all --target examples_common_app_tests` +Expected: FAIL — `'app/uuid.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/common/app/uuid.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +/// @file +/// @brief `morph::examples::newUuid`: fresh idempotency keys without a toolkit. + +namespace morph::examples { + +/// @brief A random RFC 4122 version-4 UUID, `xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx` in lower case. +/// +/// Each thread draws from its own generator, seeded once from `std::random_device`; the keys are unique, not +/// secret. +/// @return The UUID text. +[[nodiscard]] std::string newUuid(); + +} // namespace morph::examples +``` + +Create `examples/common/app/uuid.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "app/uuid.hpp" + +#include +#include +#include +#include +#include +#include + +namespace morph::examples { + +std::string newUuid() { + thread_local std::mt19937_64 generator{[] { + std::random_device device; + std::seed_seq seed{device(), device(), device(), device()}; + return std::mt19937_64{seed}; + }()}; + std::array bytes{}; + for (std::size_t i = 0; i < bytes.size(); i += 8) { + auto word = generator(); + for (std::size_t j = 0; j < 8; ++j) { + bytes.at(i + j) = static_cast(word & 0xFFU); + word >>= 8U; + } + } + bytes.at(6) = static_cast((bytes.at(6) & 0x0FU) | 0x40U); // version 4 + bytes.at(8) = static_cast((bytes.at(8) & 0x3FU) | 0x80U); // variant 10 + constexpr std::string_view kHex = "0123456789abcdef"; + std::string out; + out.reserve(36); + for (std::size_t i = 0; i < bytes.size(); ++i) { + if (i == 4 || i == 6 || i == 8 || i == 10) { + out.push_back('-'); + } + out.push_back(kHex.at(bytes.at(i) >> 4U)); + out.push_back(kHex.at(bytes.at(i) & 0x0FU)); + } + return out; +} + +} // namespace morph::examples +``` + +Create `examples/common/app/ids.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +/// @file +/// @brief Strong id ↔ text and ↔ `ui::Key`, once, for every example. +/// +/// An id here is anything with `hasValue()`, `operator*()` yielding the integer, and `Id{std::int64_t}`. That +/// covers both shapes the examples have: an optional payload (an unset id and an id holding 0 differ) and a +/// plain integer whose own 0 means "none" (`hasValue()` is `value != 0`). The guarantee is stated in +/// `hasValue()`'s terms, the only one true of both: whatever the id calls empty maps to the empty +/// representation, and everything it calls engaged — 0 included — maps to its own payload. + +namespace morph::examples { + +/// @brief What an id looks like to these helpers. +/// @tparam Id The strong id type. +template +concept StrongId = requires(Id const& strongId) { + { strongId.hasValue() } -> std::convertible_to; + { *strongId } -> std::convertible_to; +}; + +/// @brief The integer standing in for an empty id: ids are database keys starting at 1, so -1 names no row, +/// while 0 is a real value for an optional-backed id. +inline constexpr std::int64_t kNoId = -1; + +/// @brief The id as an integer. +/// @tparam Id The strong id type. +/// @param strongId The id. +/// @return Its payload, or `kNoId` when it is empty. +template +[[nodiscard]] std::int64_t idNumber(Id const& strongId) { + return strongId.hasValue() ? static_cast(*strongId) : kNoId; +} + +/// @brief The id as decimal text. +/// @tparam Id The strong id type. +/// @param strongId The id. +/// @return Its payload in decimal, or an empty string when it is empty. +template +[[nodiscard]] std::string idText(Id const& strongId) { + return strongId.hasValue() ? std::to_string(static_cast(*strongId)) : std::string{}; +} + +/// @brief Parses decimal text into an id. +/// @tparam Id The strong id type. +/// @param text The text; the whole of it must be one integer in `std::int64_t`'s range. +/// @return The id, or the empty id for anything else. +template +[[nodiscard]] Id idFromText(std::string_view text) { + std::int64_t value = 0; + auto const* const end = text.data() + text.size(); // NOLINT(cppcoreguidelines-pro-bounds-pointer-arithmetic) + auto const [ptr, error] = std::from_chars(text.data(), end, value); + if (text.empty() || error != std::errc{} || ptr != end) { + return Id{}; + } + return Id{value}; +} + +/// @brief The id as a view key: an integer, so an id above 2^53 survives every frontend. +/// @tparam Id The strong id type. +/// @param strongId The id. +/// @return `ui::Key{idNumber(strongId)}`. +template +[[nodiscard]] ui::Key idKey(Id const& strongId) { + return ui::Key{idNumber(strongId)}; +} + +/// @brief The id a view key names. +/// @tparam Id The strong id type. +/// @param key An integer key (`kNoId` is the empty id) or a decimal string key. +/// @return The id, or the empty id. +template +[[nodiscard]] Id idFromKey(ui::Key const& key) { + if (auto const* number = std::get_if(&key)) { + return *number == kNoId ? Id{} : Id{*number}; + } + return idFromText(std::get(key)); +} + +} // namespace morph::examples +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target examples_common_app_tests && ./build/all/examples/common/app/tests/examples_common_app_tests "[uuid],[ids]" +``` + +Expected: PASS, 6 test cases. Mutation check: delete the `bytes.at(8) = …` variant line. Expected FAIL in "an +RFC 4122 version-4 UUID in lower case" (a fourth group not starting with 8, 9, a or b, within 100 draws). +Restore. Second: drop `ptr != end` from `idFromText`. Expected FAIL in "text round-trips" (`"4x"` parses to 4). +Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/common/app +git commit -m "wip(examples-common): newUuid and the id helpers + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 4: Test waits and `FakeAppContext` + +`testkit/wait.hpp` is the Qt-free counterpart of `pump.hpp`: it pumps the owner executor a controller test runs +on, scaled by the same `MORPH_LADDER_DEADLINE_MS` knob (`testkit/deadline.hpp`). `pump.hpp` stays for the rig's +Socket mode, whose server is Qt. `FakeAppContext` is the `ui::AppContext` every controller and view test in Parts +7–10 builds an application on: a `MainThreadExecutor` owner, a `Runtime`, a `ManualScheduler`, a recorded quit. +Both are header-only and reached through `morph_ladder_app_common`'s include path (`examples/common`). + +**Files:** +- Create: `examples/common/testkit/wait.hpp`, `examples/common/testkit/fake_app_context.hpp` +- Modify: `examples/common/app/tests/CMakeLists.txt` — add `test_wait.cpp` after `test_ids.cpp` +- Test: `examples/common/app/tests/test_wait.cpp` + +**Interfaces:** +- Consumes: `exec::MainThreadExecutor::{runOnce, runFor}` (`core/executor.hpp:280`, `:307`); + `ladder::testkit::StepExecutor::runOne` (`examples/common/testkit/step_executor.hpp`); + `ladder::testkit::detail::scaledDeadlineMs` (`testkit/deadline.hpp`); `async::Completion::makeSettleable`; + `reactive::Runtime`, `reactive::testing::ManualScheduler` (Part 1); `ui::AppContext`, `ui::Scheduler` (Part 2); + `exec::IoLoop`. +- Produces: `morph::examples::testing::pumpUntil(exec::MainThreadExecutor&, Pred, std::chrono::milliseconds = 2000ms) + -> bool` (the contract's signature), plus an overload on `ladder::testkit::StepExecutor&`; + `awaitOn(exec::MainThreadExecutor&, async::Completion, std::chrono::milliseconds = 2000ms) -> T`; + `FakeAppContext(std::string frontendName = "test", exec::IoLoop* ioLoop = nullptr)` with `owner()`, + `manualScheduler()`, `quitCode()`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/common/app/tests/test_wait.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using morph::examples::testing::awaitOn; +using morph::examples::testing::FakeAppContext; +using morph::examples::testing::pumpUntil; +using namespace std::chrono_literals; + +TEST_CASE("pumpUntil: runs posted work until the predicate holds", "[examples-common][wait]") { + morph::exec::MainThreadExecutor owner; + bool done = false; + owner.post([&] { owner.post([&] { done = true; }); }); + CHECK(pumpUntil(owner, [&] { return done; })); +} + +TEST_CASE("pumpUntil: sees work posted from another thread", "[examples-common][wait]") { + morph::exec::MainThreadExecutor owner; + bool done = false; + std::thread poster{[&] { owner.post([&] { done = true; }); }}; + CHECK(pumpUntil(owner, [&] { return done; })); + poster.join(); +} + +TEST_CASE("pumpUntil: returns false when the budget runs out", "[examples-common][wait]") { + morph::exec::MainThreadExecutor owner; + CHECK_FALSE(pumpUntil(owner, [] { return false; }, 20ms)); +} + +TEST_CASE("pumpUntil: drives a StepExecutor the same way", "[examples-common][wait]") { + morph::ladder::testkit::StepExecutor owner; + std::atomic done{false}; + std::thread poster{[&] { owner.post([&] { done = true; }); }}; + CHECK(pumpUntil(owner, [&] { return done.load(); })); + poster.join(); + CHECK_FALSE(pumpUntil(owner, [] { return false; }, 20ms)); +} + +TEST_CASE("awaitOn: returns the value, rethrows the failure, and gives up at the budget", + "[examples-common][wait]") { + morph::exec::MainThreadExecutor owner; + { + auto [completion, promise] = morph::async::Completion::makeSettleable(&owner); + std::thread settler{[&promise] { promise.resolve(7); }}; + CHECK(awaitOn(owner, std::move(completion)) == 7); + settler.join(); + } + { + auto [completion, promise] = morph::async::Completion::makeSettleable(&owner); + promise.reject(std::make_exception_ptr(std::runtime_error{"nope"})); + CHECK_THROWS_WITH(awaitOn(owner, std::move(completion)), "nope"); + } + { + auto [completion, promise] = morph::async::Completion::makeSettleable(&owner); + CHECK_THROWS_AS(awaitOn(owner, std::move(completion), 20ms), std::runtime_error); + } +} + +TEST_CASE("FakeAppContext: a headless context with a manual clock and a recorded quit", "[examples-common][wait]") { + FakeAppContext ctx; + CHECK(ctx.frontendName() == "test"); + CHECK(ctx.ioLoop() == nullptr); + CHECK(&ctx.executor() == &ctx.owner()); + CHECK(&ctx.runtime().owner() == &ctx.owner()); + + int fired = 0; + auto const timer = ctx.scheduler().after(10ms, [&] { ++fired; }); + ctx.manualScheduler().advance(9ms); + CHECK(fired == 0); + ctx.manualScheduler().advance(1ms); + CHECK(fired == 1); + + CHECK_FALSE(ctx.quitCode().has_value()); + ctx.quit(3); + CHECK(ctx.quitCode() == std::optional{3}); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target examples_common_app_tests` +Expected: FAIL — `'testkit/fake_app_context.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/common/testkit/wait.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "testkit/deadline.hpp" +#include "testkit/step_executor.hpp" + +/// @file +/// @brief Waits for a controller test, over the owner executor the code under test delivers on — no Qt, no +/// sleeps. Every budget is scaled by `MORPH_LADDER_DEADLINE_MS` (`testkit/deadline.hpp`), the knob every +/// ladder wait obeys. + +namespace morph::examples::testing { + +namespace detail { + +/// @brief The moment a wait with @p budget gives up. +/// @param budget The unscaled budget. +/// @return Now plus the scaled budget. +[[nodiscard]] inline std::chrono::steady_clock::time_point deadlineAfter(std::chrono::milliseconds budget) { + return std::chrono::steady_clock::now() + + std::chrono::milliseconds{::morph::ladder::testkit::detail::scaledDeadlineMs(budget.count())}; +} + +} // namespace detail + +/// @brief Runs @p owner's tasks until @p done holds or the budget runs out. +/// +/// Runs whatever is queued, then blocks on the executor for at most a millisecond at a time, so work posted from +/// another thread (a worker pool, an I/O loop) is picked up as soon as it arrives. +/// @tparam Pred A predicate taking no arguments. +/// @param owner The executor the code under test delivers on; this thread must be the one that runs it. +/// @param done The condition to wait for. +/// @param budget How long to wait before giving up, before scaling. +/// @return Whether @p done holds. +template Pred> +[[nodiscard]] bool pumpUntil(exec::MainThreadExecutor& owner, Pred done, + std::chrono::milliseconds budget = std::chrono::milliseconds{2000}) { + auto const deadline = detail::deadlineAfter(budget); + while (!done()) { + if (owner.runOnce()) { + continue; + } + if (std::chrono::steady_clock::now() >= deadline) { + return done(); + } + owner.runFor(std::chrono::milliseconds{1}); + } + return true; +} + +/// @brief The same wait over a `StepExecutor`, which cannot block: between empty polls the thread yields. +/// @tparam Pred A predicate taking no arguments. +/// @param owner The executor the code under test delivers on. +/// @param done The condition to wait for. +/// @param budget How long to wait before giving up, before scaling. +/// @return Whether @p done holds. +template Pred> +[[nodiscard]] bool pumpUntil(::morph::ladder::testkit::StepExecutor& owner, Pred done, + std::chrono::milliseconds budget = std::chrono::milliseconds{2000}) { + auto const deadline = detail::deadlineAfter(budget); + while (!done()) { + if (owner.runOne()) { + continue; + } + if (std::chrono::steady_clock::now() >= deadline) { + return done(); + } + std::this_thread::yield(); + } + return true; +} + +/// @brief Waits for @p completion, pumping @p owner, and returns its value. +/// +/// The outcome lives in shared state the handlers own, so a completion that settles after a timeout writes into +/// memory nobody reads rather than into this unwound frame. +/// @tparam T The value type. +/// @param owner The completion's executor; this thread must be the one that runs it. +/// @param completion The completion to wait for. +/// @param budget How long to wait before giving up, before scaling. +/// @return The value. +/// @throws The completion's own exception when it fails, or `std::runtime_error` when the budget runs out. +template +T awaitOn(exec::MainThreadExecutor& owner, async::Completion completion, + std::chrono::milliseconds budget = std::chrono::milliseconds{2000}) { + struct Outcome { + std::optional value; + std::exception_ptr error; + }; + auto const outcome = std::make_shared(); + completion.then([outcome](T const& value) { outcome->value = value; }) + .onError([outcome](std::exception_ptr error) { outcome->error = std::move(error); }); + if (!pumpUntil(owner, [&] { return outcome->value.has_value() || outcome->error != nullptr; }, budget)) { + throw std::runtime_error{"awaitOn: the budget ran out before the completion settled"}; + } + if (outcome->error != nullptr) { + std::rethrow_exception(outcome->error); + } + return std::move(*outcome->value); +} + +} // namespace morph::examples::testing +``` + +Create `examples/common/testkit/fake_app_context.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// @file +/// @brief `morph::examples::testing::FakeAppContext`: the context a frontend hands an application, without a +/// frontend — for controller and view tests. + +namespace morph::examples::testing { + +/// @brief A headless `ui::AppContext`: a `MainThreadExecutor` the test thread pumps, a runtime owned by it, a +/// `ManualScheduler` whose time moves only when the test says, and a quit that is recorded, not obeyed. +/// +/// Destroy every application built on it first: it owns the runtime and the scheduler they hold. +class FakeAppContext final : public ui::AppContext { +public: + /// @param frontendName What `frontendName()` reports — `"tui"` or `"qt"` selects that remote transport. + /// @param ioLoop What `ioLoop()` reports. Borrowed; null for none. + explicit FakeAppContext(std::string frontendName = "test", exec::IoLoop* ioLoop = nullptr) + : _name{std::move(frontendName)}, _ioLoop{ioLoop} {} + + ~FakeAppContext() override = default; + FakeAppContext(FakeAppContext const&) = delete; + FakeAppContext& operator=(FakeAppContext const&) = delete; + FakeAppContext(FakeAppContext&&) = delete; + FakeAppContext& operator=(FakeAppContext&&) = delete; + + reactive::Runtime& runtime() override { return _runtime; } + exec::IExecutor& executor() override { return _owner; } + ui::Scheduler& scheduler() override { return _scheduler; } + exec::IoLoop* ioLoop() override { return _ioLoop; } + void quit(int exitCode) override { _quitCode = exitCode; } + [[nodiscard]] std::string_view frontendName() const override { return _name; } + + /// @brief The owner executor, to pump. + /// @return The executor `executor()` returns. + [[nodiscard]] exec::MainThreadExecutor& owner() noexcept { return _owner; } + + /// @brief The scheduler, to advance. + /// @return The scheduler `scheduler()` returns. + [[nodiscard]] reactive::testing::ManualScheduler& manualScheduler() noexcept { return _scheduler; } + + /// @brief The code the application last asked to quit with. + /// @return The exit code, or nothing when `quit` was not called. + [[nodiscard]] std::optional quitCode() const noexcept { return _quitCode; } + +private: + std::string _name; + exec::IoLoop* _ioLoop; + exec::MainThreadExecutor _owner; + reactive::Runtime _runtime{_owner}; + reactive::testing::ManualScheduler _scheduler; + std::optional _quitCode; +}; + +} // namespace morph::examples::testing +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target examples_common_app_tests && ./build/all/examples/common/app/tests/examples_common_app_tests "[wait]" +``` + +Expected: PASS, 6 test cases. Mutation check: in the `MainThreadExecutor` overload, delete the +`if (owner.runOnce()) { continue; }` statement and replace `owner.runFor(...)` with +`std::this_thread::yield()`. Expected FAIL in "runs posted work until the predicate holds" (nothing runs the +queue; `pumpUntil` returns false). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/common/testkit/wait.hpp examples/common/testkit/fake_app_context.hpp examples/common/app/tests +git commit -m "wip(examples-common): pumpUntil, awaitOn and FakeAppContext + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 5: `Connection` and `connect()` — the local transport and readiness + +`connect` builds the `Bridge` inside an application factory, on `ctx.executor()` (spec 4 §3). This task ships +the local path, the error for a frontend with no remote transport, and `Connection::ready()` — a tracked +`Signal` a controller's query keys read, so a remote client issues nothing before its transport is up (a +keyed attach issued before `QtWebSocketBackend` connects is rejected outright, `docs/spec/core/backend.md`, "The +structural registration surface"). Task 6 adds the two remote transports. + +**Files:** +- Create: `examples/common/app/transport.hpp`, `examples/common/app/transport.cpp` +- Modify: `examples/common/app/CMakeLists.txt` — add `transport.cpp` after `uuid.cpp`; after + `target_compile_features(morph_ladder_app_common …)` add + `target_compile_definitions(morph_ladder_app_common PUBLIC MORPH_EXAMPLES_TRANSPORT_QT=0 MORPH_EXAMPLES_TRANSPORT_NET=0)` + (Task 6 computes them) +- Modify: `examples/common/app/tests/CMakeLists.txt` — add `test_transport.cpp` after `test_wait.cpp` +- Test: `examples/common/app/tests/test_transport.cpp` + +**Interfaces:** +- Consumes: `bridge::Bridge(std::unique_ptr, exec::IExecutor&)` (`core/bridge.hpp:715`), + `Bridge::setDefaultSession`, `Bridge::defaultSession()`; `backend::LocalBackend(exec::IExecutor&)` + (`core/backend.hpp:1041`); `IBackend::setConnectHandler`/`setDisconnectHandler`; `exec::ThreadPoolExecutor`; + `reactive::Signal`; `ui::AppContext`; `testing::FakeAppContext`, `testing::awaitOn`, + `testing::pumpUntil` (Task 4). +- Produces (the contract's Part 6 names, plus two additions marked †): `TransportError`, `LocalSetup{setupDatabase, + workers}`, `Link{Local, Remote}` †, `Connection(ui::AppContext&, Link, std::unique_ptr, + std::unique_ptr = nullptr)` with `bridge()`, `callbacks()`, `ready()` † + (`reactive::Signal const&`), `link()`; `connect(ui::AppContext&, AppEnvironment const&, LocalSetup)`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/common/app/tests/test_transport.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct CommonPing { + int value = 0; +}; + +struct CommonPingModel { + int execute(CommonPing ping) { return ping.value + 1; } +}; + +BRIDGE_REGISTER_MODEL(CommonPingModel, "CommonPingModel") +BRIDGE_REGISTER_ACTION(CommonPingModel, CommonPing, "CommonPing") + +namespace { + +using morph::examples::AppEnvironment; +using morph::examples::Connection; +using morph::examples::Link; +using morph::examples::LocalSetup; +using morph::examples::TransportError; +using morph::examples::testing::awaitOn; +using morph::examples::testing::FakeAppContext; +using morph::examples::testing::pumpUntil; + +// What a remote transport hands its connect and disconnect hooks, kept after the Connection clears them, so a +// test can fire a notification that raced the teardown. +struct Notifications { + std::function connect; + std::function disconnect; + int clears = 0; +}; + +// A LocalBackend that reports connection state the way a remote transport does: through the two hooks. +class NotifyingBackend final : public morph::backend::LocalBackend { +public: + NotifyingBackend(morph::exec::IExecutor& pool, Notifications& seen) : LocalBackend{pool}, _seen{&seen} {} + + void setConnectHandler(std::function const& handler) override { + if (handler) { + _seen->connect = handler; + } else { + ++_seen->clears; + } + } + void setDisconnectHandler(std::function const& handler) override { + if (handler) { + _seen->disconnect = handler; + } else { + ++_seen->clears; + } + } + +private: + Notifications* _seen; +}; + +std::unique_ptr notifyingRemote(FakeAppContext& ctx, Notifications& seen) { + auto pool = std::make_unique(1); + auto backend = std::make_unique(*pool, seen); + return std::make_unique(ctx, Link::Remote, std::move(backend), std::move(pool)); +} + +} // namespace + +TEST_CASE("connect: without --server the models run in-process, ready at once", "[examples-common][transport]") { + FakeAppContext ctx; + auto const connection = morph::examples::connect(ctx, AppEnvironment{}, LocalSetup{}); + CHECK(connection->link() == Link::Local); + CHECK(connection->ready().peek()); + CHECK(&connection->callbacks() == &ctx.executor()); + morph::bridge::BridgeHandler handler{connection->bridge(), &connection->callbacks()}; + CHECK(awaitOn(ctx.owner(), handler.execute(CommonPing{41})) == 42); +} + +TEST_CASE("connect: the database named by --db is set up before the bridge exists", "[examples-common][transport]") { + FakeAppContext ctx; + AppEnvironment env; + env.db = "lab.db"; + std::string setUp; + auto const connection = morph::examples::connect( + ctx, env, LocalSetup{.setupDatabase = [&](std::string const& database) { setUp = database; }, .workers = 2}); + CHECK(setUp == "lab.db"); +} + +TEST_CASE("connect: --user installs the default session, and no --user installs none", + "[examples-common][transport]") { + FakeAppContext ctx; + AppEnvironment env; + env.user = "ann"; + CHECK(morph::examples::connect(ctx, env, LocalSetup{})->bridge().defaultSession().principal == "ann"); + CHECK(morph::examples::connect(ctx, AppEnvironment{}, LocalSetup{})->bridge().defaultSession().principal.empty()); +} + +TEST_CASE("connect: --server on a frontend with no remote transport is a TransportError naming it", + "[examples-common][transport]") { + FakeAppContext ctx{"test"}; + AppEnvironment env; + env.server = "ws://127.0.0.1:1"; + CHECK_THROWS_MATCHES(morph::examples::connect(ctx, env, LocalSetup{}), TransportError, + Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("'test'"))); +} + +TEST_CASE("Connection: a remote link is ready only between a connect and a drop", "[examples-common][transport]") { + FakeAppContext ctx; + Notifications seen; + auto const connection = notifyingRemote(ctx, seen); + CHECK_FALSE(connection->ready().peek()); + REQUIRE(seen.connect); + REQUIRE(seen.disconnect); + + seen.connect(); // the transport's thread; the Connection posts to the owner + CHECK_FALSE(connection->ready().peek()); + CHECK(pumpUntil(ctx.owner(), [&] { return connection->ready().peek(); })); + + seen.disconnect(); + CHECK(pumpUntil(ctx.owner(), [&] { return !connection->ready().peek(); })); +} + +TEST_CASE("Connection: a connect notification after the connection is gone touches nothing", + "[examples-common][transport]") { + FakeAppContext ctx; + Notifications seen; + auto connection = notifyingRemote(ctx, seen); + connection.reset(); + CHECK(seen.clears == 2); // both hooks were cleared on the way out + + // A notification already running on the transport's thread when the hooks were cleared. + seen.connect(); + seen.disconnect(); + ctx.owner().drain(); // the posted updates find the connection gone (ASan observes) + SUCCEED(); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target examples_common_app_tests` +Expected: FAIL — `'app/transport.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/common/app/transport.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/app_environment.hpp" + +/// @file +/// @brief `morph::examples::connect`: the `Bridge` an example application talks to, in-process or remote, +/// chosen from its `AppEnvironment` and the frontend it runs on. + +namespace morph::examples { + +/// @brief The environment asks for a transport this build or this frontend cannot provide. +class TransportError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +/// @brief How to run the models in-process. +struct LocalSetup { + /// @brief Creates or migrates the database `--db` names (empty: the application's default); may be empty. + std::function setupDatabase; + /// @brief Worker threads for the models; under single-threaded WebAssembly the owner executor runs them. + std::size_t workers = 4; +}; + +/// @brief Where a connection's models run. +enum class Link : std::uint8_t { + Local, ///< In-process, on a worker pool. + Remote, ///< On a `RemoteServer`, over a WebSocket. +}; + +/// @brief An application's bridge and what it needs to outlive: the worker pool of a local link. +/// +/// Built by `connect()` inside an application factory, on the frontend's owner executor, and destroyed there +/// before the runtime. The bridge's backend belongs to the connection: do not `switchBackend` it. +class Connection { +public: + /// @param ctx The frontend's context: its executor owns the bridge and receives every callback. + /// @param link Where the backend runs; a remote one reports its state through `ready()`. + /// @param backend The backend. Owned. + /// @param pool The workers @p backend runs on, when it is local; destroyed after the bridge. + Connection(ui::AppContext& ctx, Link link, std::unique_ptr backend, + std::unique_ptr pool = nullptr); + + /// @brief Clears the remote backend's hooks, then destroys the bridge and only then the pool. + ~Connection(); + Connection(Connection const&) = delete; + Connection& operator=(Connection const&) = delete; + Connection(Connection&&) = delete; + Connection& operator=(Connection&&) = delete; + + /// @brief The bridge every handler of the application is built on. + /// @return The bridge, valid as long as this connection. + [[nodiscard]] bridge::Bridge& bridge() noexcept { return *_bridge; } + + /// @brief The executor every handler delivers on: the frontend's owner. + /// @return `ctx.executor()`. + [[nodiscard]] exec::IExecutor& callbacks() noexcept { return *_callbacks; } + + /// @brief Whether calls can reach the models now. Tracked. + /// + /// Always true for a local link. A remote one turns true when its transport connects and false when it + /// drops; a query whose key reads it issues nothing while it is false. + /// @return The signal. + [[nodiscard]] reactive::Signal const& ready() const noexcept { return _ready; } + + /// @brief Where the models run. + /// @return The link given at construction. + [[nodiscard]] Link link() const noexcept { return _link; } + +private: + std::unique_ptr _pool; + exec::IExecutor* _callbacks; + backend::detail::IBackend* _backend; + std::unique_ptr _bridge; + reactive::Signal _ready; + Link _link; + // Last, so it expires first: a notification posted from the transport's thread checks it before touching + // `_ready`. + std::shared_ptr _alive = std::make_shared(); +}; + +/// @brief Builds the application's bridge. +/// +/// Without `--server`: `setupDatabase(env.db)`, then a `LocalBackend` over a pool of `local.workers` (under +/// single-threaded WebAssembly, over `ctx.executor()`). With it: `QtWebSocketBackend` when the frontend is `"qt"` +/// (in a build with `MORPH_BUILD_QT`), `morph::net::SocketBackend` on `ctx.ioLoop()` when it is `"tui"` (in a +/// build with `MORPH_BUILD_NET`, which is POSIX-only). A non-empty `env.user` becomes the bridge's default +/// session principal. Call it on `ctx.executor()`, inside the application factory. +/// @param ctx The frontend's context. +/// @param env The deployment choices. +/// @param local How to run the models in-process. +/// @return The connection. +/// @throws TransportError when `--server` is given and no remote transport applies, naming why. +[[nodiscard]] std::unique_ptr connect(ui::AppContext& ctx, AppEnvironment const& env, LocalSetup local); + +} // namespace morph::examples +``` + +Create `examples/common/app/transport.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "app/transport.hpp" + +#include +#include +#include +#include +#include +#include + +namespace morph::examples { + +namespace { + +std::unique_ptr remoteBackend(ui::AppContext& ctx, std::string const& url) { + static_cast(url); + throw TransportError{std::format("--server is not supported on the '{}' frontend", ctx.frontendName())}; +} + +std::unique_ptr localConnection(ui::AppContext& ctx, std::size_t workers) { +#if defined(__EMSCRIPTEN__) + // One thread in a browser: the owner runs the models' strands too. + static_cast(workers); + return std::make_unique(ctx, Link::Local, std::make_unique(ctx.executor())); +#else + auto pool = std::make_unique(workers); + auto backend = std::make_unique(*pool); + return std::make_unique(ctx, Link::Local, std::move(backend), std::move(pool)); +#endif +} + +} // namespace + +Connection::Connection(ui::AppContext& ctx, Link link, std::unique_ptr backend, + std::unique_ptr pool) + : _pool{std::move(pool)}, + _callbacks{&ctx.executor()}, + _backend{backend.get()}, + _bridge{std::make_unique(std::move(backend), ctx.executor())}, + _ready{ctx.runtime(), link == Link::Local}, + _link{link} { + if (_link == Link::Local) { + return; + } + // The hooks run on the transport's thread; the signal belongs to the owner, so the update is posted there and + // dropped if this connection is gone by then. + auto const notify = [owner = _callbacks, alive = std::weak_ptr{_alive}, ready = &_ready](bool connected) { + owner->post([alive, ready, connected] { + if (!alive.expired()) { + ready->set(connected); + } + }); + }; + _backend->setConnectHandler([notify] { notify(true); }); + _backend->setDisconnectHandler([notify] { notify(false); }); +} + +Connection::~Connection() { + if (_link == Link::Remote) { + _backend->setConnectHandler(nullptr); + _backend->setDisconnectHandler(nullptr); + } +} + +std::unique_ptr connect(ui::AppContext& ctx, AppEnvironment const& env, LocalSetup local) { + std::unique_ptr connection; + if (env.server.has_value()) { + connection = std::make_unique(ctx, Link::Remote, remoteBackend(ctx, *env.server)); + } else { + if (local.setupDatabase) { + local.setupDatabase(env.db); + } + connection = localConnection(ctx, local.workers); + } + if (!env.user.empty()) { + connection->bridge().setDefaultSession(session::Context{.principal = env.user}); + } + return connection; +} + +} // namespace morph::examples +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target examples_common_app_tests && ./build/all/examples/common/app/tests/examples_common_app_tests "[transport]" +``` + +Expected: PASS, 6 test cases. Mutation check: in the posted lambda, drop the `if (!alive.expired())` guard. +Expected FAIL in "a connect notification after the connection is gone touches nothing" — under the ASan build +(`cmake --preset clang-asan`, same target) as `heap-use-after-free` on the destroyed signal; a plain Debug build +may pass silently, which is why Task 12 runs this binary under ASan. Restore. Second: initialise `_ready` with +`true`. Expected FAIL in "a remote link is ready only between a connect and a drop". Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/common/app +git commit -m "wip(examples-common): Connection, connect() and the local transport + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 6: The remote transports — Qt Quick over `QtWebSocketBackend`, the TUI over `SocketBackend` + +**Files:** +- Create: `examples/common/app/detail/remote_backends.hpp`, `examples/common/app/transport_qt.cpp`, + `examples/common/app/transport_net.cpp` +- Modify: `examples/common/app/transport.cpp` — replace `remoteBackend` in the anonymous namespace +- Modify: `examples/common/app/CMakeLists.txt` — replace the `target_compile_definitions(… =0 … =0)` line from + Task 5 with the block in Step 3 +- Modify: `examples/common/app/tests/CMakeLists.txt` — the link block in Step 3 +- Test: `examples/common/app/tests/test_transport.cpp` — append the cases below + +**Interfaces:** +- Consumes: `qt::QtWebSocketBackend(QUrl, Config)` (`include/morph/qt/qt_websocket_backend.hpp:142`); + `net::SocketBackend(exec::IoLoop&, std::string_view, Config = {})` (`include/morph/net/socket_backend.hpp:105`) + and its connect/disconnect hooks (Task 1); `qt::QtWebSocketServer(RemoteServer&, quint16)`, + `net::SocketServer(exec::IoLoop&, RemoteServer&, std::uint16_t)`, `backend::RemoteServer(IExecutor&)` (tests). +- Produces: `morph::examples::detail::qtBackend(std::string const&)`, + `morph::examples::detail::socketBackend(exec::IoLoop&, std::string const&)`; the PUBLIC compile definitions + `MORPH_EXAMPLES_TRANSPORT_QT` / `MORPH_EXAMPLES_TRANSPORT_NET` (`0`/`1`), which an application may read to say + whether `--server` works on a frontend. + +- [ ] **Step 1: Write the failing tests** + +In `examples/common/app/tests/test_transport.cpp`, add `#include `, `#include ` and +`#include ` to the standard includes, and after the last `#include` line: + +```cpp +#if MORPH_EXAMPLES_TRANSPORT_NET +#include +#include +#include +#endif +#if MORPH_EXAMPLES_TRANSPORT_QT +#include +#include +#include +#endif +``` + +Then append the cases: + +```cpp +#if MORPH_EXAMPLES_TRANSPORT_NET +TEST_CASE("connect: --server on the terminal UI reaches a RemoteServer over morph::net, and follows a drop", + "[examples-common][transport][remote]") { + morph::exec::IoLoop ioLoop; + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + auto wsServer = std::make_unique(ioLoop, *server, 0); + REQUIRE(wsServer->listen()); + FakeAppContext ctx{"tui", &ioLoop}; + AppEnvironment env; + env.server = std::format("ws://127.0.0.1:{}", wsServer->port()); + env.user = "ann"; + auto const connection = morph::examples::connect(ctx, env, LocalSetup{}); + CHECK(connection->link() == Link::Remote); + CHECK(connection->bridge().defaultSession().principal == "ann"); + REQUIRE(pumpUntil(ctx.owner(), [&] { return connection->ready().peek(); })); + + morph::bridge::BridgeHandler handler{connection->bridge(), &connection->callbacks()}; + CHECK(awaitOn(ctx.owner(), handler.execute(CommonPing{1})) == 2); + + wsServer.reset(); + CHECK(pumpUntil(ctx.owner(), [&] { return !connection->ready().peek(); })); +} + +TEST_CASE("connect: a --server that is not a ws:// URL on the terminal UI is a TransportError naming it", + "[examples-common][transport][remote]") { + morph::exec::IoLoop ioLoop; + FakeAppContext ctx{"tui", &ioLoop}; + AppEnvironment env; + env.server = "http://example.invalid"; + CHECK_THROWS_MATCHES(morph::examples::connect(ctx, env, LocalSetup{}), TransportError, + Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("http://example.invalid"))); +} + +TEST_CASE("connect: --server on a terminal UI without an I/O loop is a TransportError", + "[examples-common][transport][remote]") { + FakeAppContext ctx{"tui"}; + AppEnvironment env; + env.server = "ws://127.0.0.1:1"; + CHECK_THROWS_AS(morph::examples::connect(ctx, env, LocalSetup{}), TransportError); +} +#else +TEST_CASE("connect: --server on the terminal UI says morph::net is not built", "[examples-common][transport][remote]") { + FakeAppContext ctx{"tui"}; + AppEnvironment env; + env.server = "ws://127.0.0.1:1"; + CHECK_THROWS_MATCHES(morph::examples::connect(ctx, env, LocalSetup{}), TransportError, + Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("morph::net"))); +} +#endif + +#if MORPH_EXAMPLES_TRANSPORT_QT +TEST_CASE("connect: --server on Qt Quick reaches a RemoteServer over QtWebSocketBackend", + "[examples-common][transport][remote]") { + std::string program = "examples_common_app_tests"; + std::array argv{program.data(), nullptr}; + int argc = 1; + QCoreApplication const app{argc, argv.data()}; + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + morph::qt::QtWebSocketServer qtServer{*server, 0}; + REQUIRE(qtServer.listen()); + FakeAppContext ctx{"qt"}; + AppEnvironment env; + env.server = std::format("ws://127.0.0.1:{}", qtServer.port()); + auto const connection = morph::examples::connect(ctx, env, LocalSetup{}); + // The socket lives on Qt's event loop and the replies on the owner: pump both. + auto const pumpBoth = [&](auto done) { + return pumpUntil(ctx.owner(), [&] { + QCoreApplication::processEvents(); + return done(); + }); + }; + REQUIRE(pumpBoth([&] { return connection->ready().peek(); })); + + morph::bridge::BridgeHandler handler{connection->bridge(), &connection->callbacks()}; + std::optional reply; + handler.execute(CommonPing{5}).then([&](int value) { reply = value; }); + REQUIRE(pumpBoth([&] { return reply.has_value(); })); + CHECK(reply == std::optional{6}); +} + +TEST_CASE("connect: a --server that is not a WebSocket URL on Qt Quick is a TransportError", + "[examples-common][transport][remote]") { + FakeAppContext ctx{"qt"}; + AppEnvironment env; + env.server = "http://example.invalid"; + CHECK_THROWS_AS(morph::examples::connect(ctx, env, LocalSetup{}), TransportError); +} +#else +TEST_CASE("connect: --server on Qt Quick says morph::qt is not built", "[examples-common][transport][remote]") { + FakeAppContext ctx{"qt"}; + AppEnvironment env; + env.server = "ws://127.0.0.1:1"; + CHECK_THROWS_MATCHES(morph::examples::connect(ctx, env, LocalSetup{}), TransportError, + Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("morph::qt"))); +} +#endif +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: + +```bash +cmake --build build/all --target examples_common_app_tests && ./build/all/examples/common/app/tests/examples_common_app_tests "[remote]" +``` + +Expected: FAIL — both macros are still `0`, so only the two `#else` cases compile, and they fail: the message +is `--server is not supported on the 'tui' frontend`, without `morph::net` (and likewise for `qt`). + +- [ ] **Step 3: Implement** + +Create `examples/common/app/detail/remote_backends.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +/// @file +/// @brief The remote backends `connect()` can build. Each is defined in its own translation unit, compiled only +/// when its transport is (`MORPH_EXAMPLES_TRANSPORT_QT`, `MORPH_EXAMPLES_TRANSPORT_NET`), so this header +/// names no toolkit. + +namespace morph::examples::detail { + +/// @brief A `QtWebSocketBackend` connecting to @p url. Defined in `transport_qt.cpp`. +/// @param url A `ws://` or `wss://` URL. +/// @return The backend, connecting. +/// @throws TransportError when @p url is not a WebSocket URL. +[[nodiscard]] std::unique_ptr qtBackend(std::string const& url); + +/// @brief A `morph::net::SocketBackend` connecting to @p url on @p loop. Defined in `transport_net.cpp`. +/// @param loop The frontend's I/O loop. Borrowed: it must outlive the backend. +/// @param url A `ws://` URL. +/// @return The backend, connecting. +/// @throws TransportError when @p url is not a `ws://` URL. +[[nodiscard]] std::unique_ptr socketBackend(exec::IoLoop& loop, std::string const& url); + +} // namespace morph::examples::detail +``` + +Create `examples/common/app/transport_qt.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +#include "app/detail/remote_backends.hpp" +#include "app/transport.hpp" + +namespace morph::examples::detail { + +std::unique_ptr qtBackend(std::string const& url) { + QUrl const parsed{QString::fromStdString(url), QUrl::StrictMode}; + if (!parsed.isValid() || (parsed.scheme() != QStringLiteral("ws") && parsed.scheme() != QStringLiteral("wss"))) { + throw TransportError{std::format("--server {}: not a ws:// or wss:// URL", url)}; + } + return std::make_unique(parsed, qt::QtWebSocketBackend::Config{}); +} + +} // namespace morph::examples::detail +``` + +Create `examples/common/app/transport_net.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "app/detail/remote_backends.hpp" +#include "app/transport.hpp" + +namespace morph::examples::detail { + +std::unique_ptr socketBackend(exec::IoLoop& loop, std::string const& url) { + try { + return std::make_unique(loop, url); + } catch (std::exception const& error) { + throw TransportError{std::format("--server {}: {}", url, error.what())}; + } +} + +} // namespace morph::examples::detail +``` + +In `examples/common/app/transport.cpp`, add `#include "app/detail/remote_backends.hpp"` and replace +`remoteBackend` with: + +```cpp +std::unique_ptr remoteBackend(ui::AppContext& ctx, std::string const& url) { + static_cast(url); // unused when neither transport is built + auto const frontend = ctx.frontendName(); + if (frontend == "qt") { +#if MORPH_EXAMPLES_TRANSPORT_QT + return detail::qtBackend(url); +#else + throw TransportError{"--server on the Qt Quick frontend needs morph::qt, which this build does not include " + "(MORPH_BUILD_QT=OFF)"}; +#endif + } + if (frontend == "tui") { +#if MORPH_EXAMPLES_TRANSPORT_NET + if (ctx.ioLoop() == nullptr) { + throw TransportError{"--server on the terminal UI needs the frontend's I/O loop, and this one has none"}; + } + return detail::socketBackend(*ctx.ioLoop(), url); +#else + throw TransportError{"--server on the terminal UI needs morph::net, which is POSIX-only and not built here " + "(MORPH_BUILD_NET=OFF, or Windows); run without --server for in-process models"}; +#endif + } + throw TransportError{std::format("--server is not supported on the '{}' frontend", frontend)}; +} +``` + +In `examples/common/app/CMakeLists.txt`, replace Task 5's `target_compile_definitions` line with: + +```cmake +# The remote transports, each compiled in only when its option built it. Linked PRIVATE: their include paths +# (Qt's, core-cpp's sockets) never reach an application library. The two macros are PUBLIC and always 0 or 1, +# so code may test them under -Wundef. +set(_morph_examples_qt 0) +if(MORPH_BUILD_QT) + set(_morph_examples_qt 1) + find_package(Qt6 6.5 REQUIRED COMPONENTS Core WebSockets) + target_sources(morph_ladder_app_common PRIVATE transport_qt.cpp) + target_link_libraries(morph_ladder_app_common PRIVATE morph::qt morph_qt_impl Qt6::Core) +endif() +# morph::net is POSIX sockets: not on Windows, not in a browser. +set(_morph_examples_net 0) +if(MORPH_BUILD_NET AND NOT WIN32 AND NOT EMSCRIPTEN) + set(_morph_examples_net 1) + target_sources(morph_ladder_app_common PRIVATE transport_net.cpp) + target_link_libraries(morph_ladder_app_common PRIVATE morph::net) +endif() +target_compile_definitions(morph_ladder_app_common PUBLIC + MORPH_EXAMPLES_TRANSPORT_QT=${_morph_examples_qt} + MORPH_EXAMPLES_TRANSPORT_NET=${_morph_examples_net}) +``` + +In `examples/common/app/tests/CMakeLists.txt`, after `target_link_libraries(examples_common_app_tests …)`: + +```cmake +# The remote cases stand up a server of each transport, so they need its headers directly. +if(MORPH_BUILD_QT) + target_link_libraries(examples_common_app_tests PRIVATE morph::qt morph_qt_impl Qt6::Core) +endif() +if(MORPH_BUILD_NET AND NOT WIN32) + target_link_libraries(examples_common_app_tests PRIVATE morph::net) +endif() +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run, in each configuration: +`cmake --build build/all --target examples_common_app_tests && ./build/all/examples/common/app/tests/examples_common_app_tests "[transport]"` +then the same with `build/ex-tui` and `build/ex-qt`. +Expected: PASS everywhere — `build/all` runs both round trips (11 `[transport]` cases), `build/ex-tui` the net +round trip and "says morph::qt is not built" (10), `build/ex-qt` the Qt round trip and "says morph::net is not +built" (9). +Mutation check: in `Connection`'s constructor, drop the `setDisconnectHandler` line. Expected FAIL in "reaches a +RemoteServer over morph::net, and follows a drop" (the last `pumpUntil` times out) and in Task 5's "ready only +between a connect and a drop" (`REQUIRE(seen.disconnect)`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/common/app +git commit -m "wip(examples-common): remote transports over QtWebSocketBackend and SocketBackend + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 7: `Poller` — "events since cursor" over a `Query` + +`EventPoller` (`examples/common/gui/event_poller.hpp`) is a `QTimer` plus hand-sequenced in-flight flags. Its +replacement is a `Query` whose key is the cursor and whose `refreshEvery` is the poll interval, so the reactive +core supplies what `EventPoller` hand-rolled: one request at a time per key, a superseded reply dropped, a +destroyed poller gating replies in flight. Two decisions carry over: a `ClientTimeoutError` is retried on the +next tick (so is a `DisconnectedError`, which a reconnect ends), and any other failure stops polling and is +reported. One goes away: `EventPoller` set a bridge-wide execute deadline so a dropped reply could not wedge it; +here the next tick's refetch supersedes a reply that never came, so a poller touches no bridge setting. + +**Files:** +- Create: `examples/common/app/poller.hpp` +- Modify: `examples/common/app/tests/CMakeLists.txt` — add `test_poller.cpp` after `test_transport.cpp` +- Test: `examples/common/app/tests/test_poller.cpp` + +**Interfaces:** +- Consumes: `reactive::Query(Runtime&, Fetch, Key, QueryOptions)`, `Query::{value, error, pending, refetch}`, + `reactive::QueryOptions{scheduler, refreshEvery}`, `reactive::Effect`, `reactive::Signal`, + `reactive::Runtime::{batch, untracked}`, `reactive::errorMessage` (Part 1); + `backend::ClientTimeoutError`, `backend::DisconnectedError` (`core/backend.hpp:595`, `:626`); + `async::Completion::makeSettleable`. +- Produces: `morph::examples::PollPage{events, next}`, `PollerOptions{interval}` †, + `Poller(reactive::Runtime&, reactive::Scheduler&, Fetch, Cursor start, OnEvent, PollerOptions = {})` + with `cursor()`, `pending()`, `lastError()`, `stopped()`, `stoppedBy()`, `pollNow()`, `resume(Cursor)`, and the + static `adapt(exec::IExecutor&, FetchFn, ToPageFn) -> Fetch` (a model reply → a page); + `detail::isTransientPollError(std::exception_ptr const&)`. († addition to the contract.) + +- [ ] **Step 1: Write the failing test** + +Create `examples/common/app/tests/test_poller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using morph::async::Completion; +using morph::examples::PollerOptions; +using morph::examples::testing::FakeAppContext; +using namespace std::chrono_literals; + +struct FeedEvent { + int id = 0; + std::string text; +}; + +using FeedPoller = morph::examples::Poller; +using FeedPage = FeedPoller::Page; + +// Stands in for a server: every fetch is held until the test settles it. +class FakeFeed { +public: + explicit FakeFeed(morph::exec::IExecutor& owner) : _owner{&owner} {} + + FeedPoller::Fetch fetcher() { + return [this](int const& cursor) { + auto [completion, promise] = Completion::makeSettleable(_owner); + _calls.push_back(Call{.cursor = cursor, .promise = std::move(promise)}); + return std::move(completion); + }; + } + + void resolve(std::size_t index, FeedPage page) { _calls.at(index).promise.resolve(std::move(page)); } + void reject(std::size_t index, std::exception_ptr error) { _calls.at(index).promise.reject(std::move(error)); } + [[nodiscard]] std::size_t calls() const { return _calls.size(); } + [[nodiscard]] int cursorOf(std::size_t index) const { return _calls.at(index).cursor; } + +private: + struct Call { + int cursor; + Completion::Promise promise; + }; + morph::exec::IExecutor* _owner; + std::vector _calls; +}; + +struct Rig { + FakeAppContext ctx; + FakeFeed feed{ctx.owner()}; + std::vector applied; + std::unique_ptr poller = std::make_unique( + ctx.runtime(), ctx.scheduler(), feed.fetcher(), 0, [this](FeedEvent const& event) { applied.push_back(event.id); }, + PollerOptions{.interval = 3000ms}); + + void settle() { ctx.owner().drain(); } +}; + +FeedPage page(std::vector const& ids, int next) { + FeedPage out{.events = {}, .next = next}; + for (int const eventId : ids) { + out.events.push_back(FeedEvent{.id = eventId, .text = "event " + std::to_string(eventId)}); + } + return out; +} + +} // namespace + +TEST_CASE("Poller: fetches from its start, applies a page in order and follows the cursor", + "[examples-common][poller]") { + Rig rig; + REQUIRE(rig.feed.calls() == 1); + CHECK(rig.feed.cursorOf(0) == 0); + rig.feed.resolve(0, page({1, 2}, 2)); + rig.settle(); + CHECK(rig.applied == std::vector{1, 2}); + CHECK(rig.poller->cursor() == 2); + REQUIRE(rig.feed.calls() == 2); // a new cursor is a new key: asked at once, not on the next tick + CHECK(rig.feed.cursorOf(1) == 2); +} + +TEST_CASE("Poller: asks again on its interval while nothing happens", "[examples-common][poller]") { + Rig rig; + rig.feed.resolve(0, page({}, 0)); + rig.settle(); + CHECK(rig.feed.calls() == 1); + rig.ctx.manualScheduler().advance(3000ms); + rig.settle(); + REQUIRE(rig.feed.calls() == 2); + CHECK(rig.feed.cursorOf(1) == 0); +} + +TEST_CASE("Poller: a refetch while a page is in flight applies its events once", "[examples-common][poller]") { + Rig rig; + rig.ctx.manualScheduler().advance(3000ms); // the first reply is late: the tick supersedes it + rig.settle(); + REQUIRE(rig.feed.calls() == 2); + CHECK(rig.feed.cursorOf(1) == 0); + rig.feed.resolve(1, page({1}, 1)); + rig.settle(); + rig.feed.resolve(0, page({1}, 1)); // the superseded reply lands after all + rig.settle(); + CHECK(rig.applied == std::vector{1}); + CHECK(rig.poller->cursor() == 1); +} + +TEST_CASE("Poller: a timeout is retried on the next tick", "[examples-common][poller]") { + Rig rig; + rig.feed.reject(0, std::make_exception_ptr(morph::backend::ClientTimeoutError{})); + rig.settle(); + CHECK_FALSE(rig.poller->stopped()); + CHECK(rig.poller->lastError() != nullptr); + rig.ctx.manualScheduler().advance(3000ms); + rig.settle(); + REQUIRE(rig.feed.calls() == 2); + rig.feed.resolve(1, page({4}, 4)); + rig.settle(); + CHECK(rig.applied == std::vector{4}); + CHECK(rig.poller->lastError() == nullptr); +} + +TEST_CASE("Poller: any other failure stops it and says why; resume restarts from a cursor", + "[examples-common][poller]") { + Rig rig; + rig.feed.reject(0, std::make_exception_ptr(std::runtime_error{"poll is gone"})); + rig.settle(); + REQUIRE(rig.poller->stopped()); + CHECK(morph::reactive::errorMessage(rig.poller->stoppedBy()) == "poll is gone"); + rig.ctx.manualScheduler().advance(30000ms); + rig.settle(); + CHECK(rig.feed.calls() == 1); + + rig.poller->resume(7); + rig.settle(); + CHECK_FALSE(rig.poller->stopped()); + REQUIRE(rig.feed.calls() == 2); + CHECK(rig.feed.cursorOf(1) == 7); +} + +TEST_CASE("Poller: destroying it cancels its timer and gates the reply in flight", "[examples-common][poller]") { + Rig rig; + rig.poller.reset(); + CHECK(rig.ctx.manualScheduler().pendingTimers() == 0); + rig.feed.resolve(0, page({1}, 1)); + rig.settle(); + CHECK(rig.applied.empty()); +} + +TEST_CASE("Poller: adapt turns a model's reply into a page", "[examples-common][poller]") { + struct Reply { + std::vector ids; + int last = 0; + }; + FakeAppContext ctx; + std::vector::Promise> promises; + auto const fetch = FeedPoller::adapt( + ctx.owner(), + [&](int const&) { + auto [completion, promise] = Completion::makeSettleable(&ctx.owner()); + promises.push_back(std::move(promise)); + return std::move(completion); + }, + [](Reply const& reply) { + FeedPage out{.events = {}, .next = reply.last}; + for (int const eventId : reply.ids) { + out.events.push_back(FeedEvent{.id = eventId, .text = {}}); + } + return out; + }); + std::vector applied; + FeedPoller const poller{ctx.runtime(), ctx.scheduler(), fetch, 0, + [&](FeedEvent const& event) { applied.push_back(event.id); }}; + REQUIRE(promises.size() == 1); + promises.front().resolve(Reply{.ids = {5, 6}, .last = 6}); + ctx.owner().drain(); + CHECK(applied == std::vector{5, 6}); + CHECK(poller.cursor() == 6); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target examples_common_app_tests` +Expected: FAIL — `'app/poller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/common/app/poller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// @file +/// @brief `morph::examples::Poller`: ask "what happened since this cursor" on an interval, apply the answer, and +/// move the cursor — a `Query` keyed by the cursor and refreshed by the frontend's scheduler. + +namespace morph::examples { + +/// @brief One answer to "what happened since this cursor". +/// @tparam Event An event of the feed. +/// @tparam Cursor The feed's position. +template +struct PollPage { + /// @brief The events after the asked cursor, oldest first. + std::vector events; + /// @brief The cursor after the last of them (the asked one when there are none). + Cursor next; +}; + +/// @brief How a `Poller` paces itself. +struct PollerOptions { + /// @brief How often to ask while nothing changes; zero asks only on a cursor change or `pollNow()`. Three + /// seconds trades a viewer's staleness against N viewers' request rate. + std::chrono::milliseconds interval{3000}; +}; + +namespace detail { + +/// @brief Whether a poll failure is retried on the next tick: the client gave up waiting +/// (`ClientTimeoutError`) or the transport dropped (`DisconnectedError`, which a reconnect ends). +/// @param error The failure. +/// @return True for those two; false for anything else and for null. +[[nodiscard]] inline bool isTransientPollError(std::exception_ptr const& error) noexcept { + if (error == nullptr) { + return false; + } + try { + std::rethrow_exception(error); + } catch (backend::ClientTimeoutError const&) { + return true; + } catch (backend::DisconnectedError const&) { + return true; + } catch (...) { + return false; + } +} + +} // namespace detail + +/// @brief Polls a feed for events since a cursor and applies each event once, in order. +/// +/// A `Query` whose key is the cursor: a delivered page's events go to `onEvent`, then the cursor moves to +/// `page.next`, which is a new key and so is asked at once; while nothing changes the scheduler re-asks every +/// `interval`. A tick that finds a request still in flight supersedes it, so a reply that never comes cannot +/// wedge the poller, and a superseded reply is dropped. A transient failure (`detail::isTransientPollError`) +/// waits for the next tick; any other stops polling until `resume`. Owned by a controller, built and used on the +/// runtime's owner. +/// @tparam Event An event of the feed. +/// @tparam Cursor The feed's position; equality-comparable, so an unchanged cursor asks nothing new. +template +class Poller { +public: + /// @brief One answer from the feed. + using Page = PollPage; + /// @brief Asks the feed for the events after a cursor; its `Completion` delivers on the runtime's owner. + using Fetch = std::function(Cursor const&)>; + /// @brief Applies one event. + using OnEvent = std::function; + + /// @param runtime The runtime. Borrowed: it must outlive the poller. + /// @param scheduler Runs the interval. Borrowed: it must outlive the poller. + /// @param fetch Asks the feed; `adapt` builds one over a model call. + /// @param start The cursor to ask from first. + /// @param onEvent Applies each event, oldest first, on the owner. + /// @param options The interval. + Poller(reactive::Runtime& runtime, reactive::Scheduler& scheduler, Fetch fetch, Cursor start, OnEvent onEvent, + PollerOptions options = {}) + : _rt{&runtime}, + _cursor{runtime, std::move(start)}, + _stoppedBy{runtime, nullptr}, + _onEvent{std::move(onEvent)}, + _query{runtime, std::move(fetch), + [this]() -> std::optional { + if (_stoppedBy.get() != nullptr) { + return std::nullopt; + } + return _cursor.get(); + }, + reactive::QueryOptions{.scheduler = &scheduler, .refreshEvery = options.interval}}, + _apply{runtime, [this] { applyDelivered(); }}, + _watch{runtime, [this] { watchFailures(); }} {} + + ~Poller() = default; + Poller(Poller const&) = delete; + Poller& operator=(Poller const&) = delete; + Poller(Poller&&) = delete; + Poller& operator=(Poller&&) = delete; + + /// @brief Builds a `Fetch` over a call that answers with a model's own reply type. + /// @tparam FetchFn Callable `(Cursor const&) -> async::Completion`. + /// @tparam ToPageFn Callable `(R const&) -> Page`; a throw fails that poll. + /// @param owner The runtime's owner, where the page is delivered. Borrowed. + /// @param fetch Issues the call. + /// @param toPage Turns the reply into a page. + /// @return The fetcher. + template + [[nodiscard]] static Fetch adapt(exec::IExecutor& owner, FetchFn fetch, ToPageFn toPage) { + return [owner = &owner, fetch = std::move(fetch), toPage = std::move(toPage)](Cursor const& cursor) { + auto [page, settle] = async::Completion::makeSettleable(owner); + auto const promise = std::make_shared::Promise>(std::move(settle)); + auto reply = fetch(cursor); + reply + .then([promise, toPage](auto const& value) { + try { + promise->resolve(toPage(value)); + } catch (...) { + promise->reject(std::current_exception()); + } + }) + .onError([promise](std::exception_ptr error) { promise->reject(std::move(error)); }); + return std::move(page); + }; + } + + /// @brief Where the feed is read up to. Tracked. + /// @return The cursor the next ask starts from. + [[nodiscard]] Cursor const& cursor() const { return _cursor.get(); } + + /// @brief Whether an ask is in flight. Tracked. + /// @return True from issue to delivery. + [[nodiscard]] bool pending() const { return _query.pending(); } + + /// @brief The last transient failure, cleared by the next page. Tracked. + /// @return The failure, or null. + [[nodiscard]] std::exception_ptr lastError() const { return _query.error(); } + + /// @brief Whether a non-transient failure stopped polling. Tracked. + /// @return True until `resume`. + [[nodiscard]] bool stopped() const { return _stoppedBy.get() != nullptr; } + + /// @brief The failure that stopped polling. Tracked. + /// @return The failure, or null while polling. + [[nodiscard]] std::exception_ptr stoppedBy() const { return _stoppedBy.get(); } + + /// @brief Asks now instead of at the next tick; does nothing while stopped. + void pollNow() { _query.refetch(); } + + /// @brief Starts polling again, from @p from. + /// @param from The cursor to ask from. + void resume(Cursor from) { + _rt->batch([&] { + _stoppedBy.set(nullptr); + _cursor.set(std::move(from)); + }); + } + +private: + void applyDelivered() { + auto const& page = _query.value(); + if (!page.has_value()) { + return; + } + // The handler's own reads must not subscribe this effect to the application's signals. + _rt->untracked([&] { + for (auto const& event : page->events) { + _onEvent(event); + } + _cursor.set(page->next); + }); + } + + void watchFailures() { + auto const error = _query.error(); + if (error == nullptr || detail::isTransientPollError(error)) { + return; + } + _rt->untracked([&] { + if (_stoppedBy.peek() == nullptr) { + log::logError("Poller: polling stopped: {}", reactive::errorMessage(error)); + _stoppedBy.set(error); + } + }); + } + + reactive::Runtime* _rt; + reactive::Signal _cursor; + reactive::Signal _stoppedBy; + OnEvent _onEvent; + reactive::Query _query; + reactive::Effect _apply; + reactive::Effect _watch; +}; + +} // namespace morph::examples +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target examples_common_app_tests && ./build/all/examples/common/app/tests/examples_common_app_tests "[poller]" +``` + +Expected: PASS, 7 test cases. Mutation check: in `applyDelivered`, apply the events before the +`untracked` call *and* inside it (apply twice). Expected FAIL in "applies a page in order" (`{1, 2, 1, 2}`). +Restore. Second: make `watchFailures` treat every error as fatal (drop the `isTransientPollError` test). +Expected FAIL in "a timeout is retried on the next tick" (`stopped()` is true). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/common/app +git commit -m "wip(examples-common): Poller, events since a cursor over a refreshed Query + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 7b: `Wiring` and `mapCompletion` — what every controller is built from + +Every migrated client's controllers take the same four borrowed collaborators, and every one derives a completion +from a handler's reply — mapping its value, observing its failure — on the owner and behind a `CallbackToken`. +Both live here once, in `morph::examples`, so Parts 7–10 include them instead of each app growing its own copy. +The implementation and the five tests are the kanban client's, renamed into `morph::examples`. + +**Files:** +- Create: `examples/common/app/wiring.hpp`, `examples/common/app/completion_map.hpp` +- Modify: `examples/common/app/tests/CMakeLists.txt` — add `test_completion_map.cpp` after `test_poller.cpp` +- Test: `examples/common/app/tests/test_completion_map.cpp` + +**Interfaces:** +- Consumes: `async::Completion::makeSettleable(IExecutor*)`, `Completion::then(CallbackToken, …)`, + `Completion::onError(CallbackToken, …)`, `Completion::thenDetached`, `Completion::onErrorDetached` + (`morph/core/completion.hpp`); `async::CallbackScope::{token, reset}` (`morph/core/callback_scope.hpp`); + `reactive::Runtime`, `reactive::Scheduler`, `reactive::errorMessage` (Part 1); `bridge::Bridge`; + `testing::pumpUntil` (Task 4). +- Produces (the contract's Part 6 names): + - `morph::examples::Wiring{runtime, scheduler, bridge, callbacks}` — `reactive::Runtime&`, + `reactive::Scheduler&`, `bridge::Bridge&`, `exec::IExecutor&`, all borrowed (`app/wiring.hpp`). + - `morph::examples::mapCompletion(exec::IExecutor& owner, async::CallbackToken token, + async::Completion from, OnValue onValue, OnError onError) -> async::Completion` + (`app/completion_map.hpp`). + +- [ ] **Step 1: Write the failing test** + +Create `examples/common/app/tests/test_completion_map.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using morph::async::Completion; +using morph::examples::testing::pumpUntil; + +// Wiring borrows all four: a controller built from it owns none of them. +static_assert(std::is_same_v); +static_assert(std::is_same_v); +static_assert(std::is_same_v); +static_assert(std::is_same_v); + +// What a mapped completion delivered, observed from the outside. +struct Outcome { + std::optional value; + std::string error; +}; + +void observe(Completion& completion, Outcome& outcome) { + completion.thenDetached([&outcome](std::string const& value) { outcome.value = value; }) + .onErrorDetached([&outcome](std::exception_ptr error) { + outcome.error = morph::reactive::errorMessage(error); + }); +} + +} // namespace + +TEST_CASE("mapCompletion maps a value on the owner", "[examples-common][completion]") { + morph::exec::MainThreadExecutor owner; + morph::async::CallbackScope scope; + auto source = Completion::makeSettleable(&owner); + auto mapped = morph::examples::mapCompletion( + owner, scope.token(), std::move(source.first), [](int const& value) { return std::to_string(value * 2); }, + [](std::exception_ptr const&) {}); + Outcome outcome; + observe(mapped, outcome); + source.second.resolve(21); + REQUIRE(pumpUntil(owner, [&outcome] { return outcome.value.has_value(); })); + CHECK(*outcome.value == "42"); +} + +TEST_CASE("mapCompletion: two overlapping completions each deliver their own value", + "[examples-common][completion]") { + morph::exec::MainThreadExecutor owner; + morph::async::CallbackScope scope; + auto first = Completion::makeSettleable(&owner); + auto second = Completion::makeSettleable(&owner); + auto echo = [](std::string const& value) { return "created " + value; }; + auto mappedFirst = morph::examples::mapCompletion(owner, scope.token(), std::move(first.first), echo, + [](std::exception_ptr const&) {}); + auto mappedSecond = morph::examples::mapCompletion(owner, scope.token(), std::move(second.first), + echo, [](std::exception_ptr const&) {}); + Outcome firstOutcome; + Outcome secondOutcome; + observe(mappedFirst, firstOutcome); + observe(mappedSecond, secondOutcome); + second.second.resolve("Beta"); + first.second.resolve("Alpha"); + REQUIRE(pumpUntil(owner, [&] { return firstOutcome.value && secondOutcome.value; })); + CHECK(*firstOutcome.value == "created Alpha"); + CHECK(*secondOutcome.value == "created Beta"); +} + +TEST_CASE("mapCompletion passes a failure on after observing it", "[examples-common][completion]") { + morph::exec::MainThreadExecutor owner; + morph::async::CallbackScope scope; + auto source = Completion::makeSettleable(&owner); + std::string observed; + auto mapped = morph::examples::mapCompletion( + owner, scope.token(), std::move(source.first), [](int const&) { return std::string{"unused"}; }, + [&observed](std::exception_ptr const& error) { observed = morph::reactive::errorMessage(error); }); + Outcome outcome; + observe(mapped, outcome); + source.second.reject(std::make_exception_ptr(std::runtime_error{"refused"})); + REQUIRE(pumpUntil(owner, [&outcome] { return !outcome.error.empty(); })); + CHECK(observed == "refused"); + CHECK(outcome.error == "refused"); +} + +TEST_CASE("mapCompletion fails the derived completion when the mapper throws", "[examples-common][completion]") { + morph::exec::MainThreadExecutor owner; + morph::async::CallbackScope scope; + auto source = Completion::makeSettleable(&owner); + auto mapped = morph::examples::mapCompletion( + owner, scope.token(), std::move(source.first), + [](int const&) -> std::string { throw std::runtime_error{"unreadable reply"}; }, + [](std::exception_ptr const&) {}); + Outcome outcome; + observe(mapped, outcome); + source.second.resolve(1); + REQUIRE(pumpUntil(owner, [&outcome] { return !outcome.error.empty(); })); + CHECK(outcome.error == "unreadable reply"); +} + +TEST_CASE("mapCompletion runs nothing once its scope is stopped", "[examples-common][completion]") { + morph::exec::MainThreadExecutor owner; + morph::async::CallbackScope scope; + auto source = Completion::makeSettleable(&owner); + bool mapperRan = false; + auto mapped = morph::examples::mapCompletion( + owner, scope.token(), std::move(source.first), + [&mapperRan](int const&) { + mapperRan = true; + return std::string{}; + }, + [](std::exception_ptr const&) {}); + Outcome outcome; + observe(mapped, outcome); + scope.reset(); + source.second.resolve(1); + bool drained = false; + owner.post([&drained] { drained = true; }); + REQUIRE(pumpUntil(owner, [&drained] { return drained; })); + CHECK_FALSE(mapperRan); + CHECK_FALSE(outcome.value.has_value()); +} +``` + +The last case posts a sentinel after the resolve: the owner runs tasks in post order, so when the sentinel has run +the delivery has had its turn. + +Add `test_completion_map.cpp` after `test_poller.cpp` in `examples/common/app/tests/CMakeLists.txt`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target examples_common_app_tests` +Expected: FAIL — `'app/completion_map.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/common/app/wiring.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +/// @file +/// @brief `morph::examples::Wiring`: the collaborators every example controller is built from. + +namespace morph::examples { + +/// @brief What every controller of an example client is built from. All four are borrowed and outlive the +/// controllers. +struct Wiring { + /// @brief The runtime every signal, query and mutation belongs to. + reactive::Runtime& runtime; + /// @brief Timers on the runtime's owner; polling runs on it. + reactive::Scheduler& scheduler; + /// @brief The bridge every handler registers on. + bridge::Bridge& bridge; + /// @brief The runtime's owner; every handler delivers its completions here. + exec::IExecutor& callbacks; +}; + +} // namespace morph::examples +``` + +Create `examples/common/app/completion_map.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include + +/// @file +/// @brief `morph::examples::mapCompletion`: derive a completion from a handler's reply, on the owner. + +namespace morph::examples { + +/// @brief Derives a completion from @p from: @p onValue maps the value (and may act on it first), @p onError +/// observes a failure before it is passed on. +/// +/// Both callbacks run on @p owner and are gated by @p token: once the token's scope is stopped neither runs and +/// the derived completion never settles. A throw from @p onValue fails the derived completion with it. Every call +/// carries its own state, so two overlapping calls cannot see each other's values. +/// @tparam To The derived value type. +/// @tparam From The source value type. +/// @tparam OnValue Callable `To(From const&)`. +/// @tparam OnError Callable `void(std::exception_ptr const&)`. +/// @param owner Where @p from delivers and the derived completion belongs. Borrowed. +/// @param token Gates both callbacks. +/// @param from The source; it must belong to @p owner. +/// @param onValue Maps the source's value. +/// @param onError Observes the source's failure. +/// @return The derived completion, owned by @p owner. +template +[[nodiscard]] async::Completion mapCompletion(exec::IExecutor& owner, async::CallbackToken token, + async::Completion from, OnValue onValue, OnError onError) { + auto settleable = async::Completion::makeSettleable(&owner); + auto promise = std::make_shared::Promise>(std::move(settleable.second)); + from.then(token, + [promise, onValue = std::move(onValue)](From const& value) { + try { + promise->resolve(onValue(value)); + } catch (...) { + promise->reject(std::current_exception()); + } + }) + .onError(token, [promise, onError = std::move(onError)](std::exception_ptr error) { + onError(error); + promise->reject(std::move(error)); + }); + return std::move(settleable.first); +} + +} // namespace morph::examples +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target examples_common_app_tests && ./build/all/examples/common/app/tests/examples_common_app_tests "[completion]" +``` + +Expected: PASS, 5 test cases. Mutation check: in `mapCompletion`, drop the `token` argument from `from.then(...)` +(call the ungated `then(handler)`). Expected FAIL in "runs nothing once its scope is stopped" (`mapperRan` is +true). Restore. Second: in `onError`'s handler, delete `onError(error);`. Expected FAIL in "passes a failure on +after observing it" (`observed` is empty). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/common/app +git commit -m "wip(examples-common): Wiring and mapCompletion, what every example controller is built from + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 8: The frontend smoke harness + +Spec 4 §7 rule 5: one smoke test per frontend per app — the application mounts and quits on the TUI and on Qt +Quick offscreen. `smokeRun` drives a real `tui::Frontend` over a `core::tui::MockTerminalOutput` (subclassed to keep +the text written) and a `ScriptedInputSource` with no input channel, or a real `qt_quick::Frontend` on the +`offscreen` platform; it quits through the frontend's own scheduler once the view is visibly mounted, or with +`kSmokeTimedOutExit` when the budget runs out. "Mounted" is observed, not assumed: on the TUI, visible text +reached the terminal; on Qt Quick, an item named `w` (spec 3 §2) exists under a window's content item. + +`qt_quick::Frontend::run` constructs its own `QGuiApplication`, so the Qt half refuses to run — with an error +saying so — in a process that already has a Qt application object (the ladder testkit's Qt `main` creates one). +Smoke tests therefore live in binaries whose `main` is `morph_test_main` (Task 9 gives every rung one). + +**Files:** +- Create: `cmake/morph_example_app.cmake` (with `morph_example_frontends`; Task 9 adds `morph_add_example_ui`) +- Create: `examples/common/testkit/frontend_smoke.hpp`, `examples/common/testkit_src/frontend_smoke.cpp` +- Modify: `CMakeLists.txt` (root) — `include(${PROJECT_SOURCE_DIR}/cmake/morph_example_app.cmake)` directly above + `# ── Demo executable ──` +- Modify: `examples/common/app/CMakeLists.txt` — the `morph_example_testkit` target, inside + `if(MORPH_BUILD_TESTS AND NOT EMSCRIPTEN)` before `add_subdirectory(tests)` +- Modify: `examples/common/app/tests/CMakeLists.txt` — add `test_frontend_smoke.cpp` after `test_completion_map.cpp`, and + link `morph::example_testkit` +- Test: `examples/common/app/tests/test_frontend_smoke.cpp` + +**Interfaces:** +- Consumes: `ui::Application`, `ui::ApplicationFactory`, `ui::AppContext::{scheduler, quit}`, `ui::Frontend::run`, + `ui::TimerHandle`, `ui::text`, `ui::column` (Part 2); `tui::Frontend(tui::FrontendConfig{terminal, input, + mouse})` (Part 3, `morph/tui/frontend.hpp`); `qt_quick::Frontend(int&, char**)`, whose `run` constructs its own + `QGuiApplication` (Part 4, `morph/qt_quick/frontend.hpp`); core-cpp `core::tui::MockTerminalOutput(int, int)`, + `core::tui::Terminal(std::unique_ptr)`, `core::tui::runtime::testing::ScriptedInputSource()` + (default-constructed: no input channel); + `ladder::testkit::detail::scaledDeadlineMs`. +- Produces: the contract's `SmokeFrontend{Tui, QtQuick}` and `runFrontendSmoke(ui::ApplicationFactory const&, + SmokeFrontend)`, plus † `kSmokeTimedOutExit` (124), `SmokeResult{exitCode, viewBuilt, mounted, timedOut, error, + screenText}`, `frontendBuilt(SmokeFrontend)`, `smokeRun(factory, frontend, budget = 5000ms)`; target + `morph_example_testkit` / `morph::example_testkit`; CMake `morph_example_frontends( )`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/common/app/tests/test_frontend_smoke.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +namespace ui = morph::ui; +using morph::examples::testing::frontendBuilt; +using morph::examples::testing::kSmokeTimedOutExit; +using morph::examples::testing::runFrontendSmoke; +using morph::examples::testing::SmokeFrontend; +using morph::examples::testing::smokeRun; + +class TextApp final : public ui::Application { +public: + explicit TextApp(std::string text) : _text{std::move(text)} {} + [[nodiscard]] ui::Node view() override { return ui::text({.text = _text}); } + +private: + std::string _text; +}; + +class EmptyApp final : public ui::Application { +public: + [[nodiscard]] ui::Node view() override { return ui::column({}); } +}; + +class SelfQuittingApp final : public ui::Application { +public: + explicit SelfQuittingApp(ui::AppContext& ctx) + : _leave{ctx.scheduler().after(std::chrono::milliseconds{0}, [&ctx] { ctx.quit(3); })} {} + [[nodiscard]] ui::Node view() override { return ui::text({.text = "leaving"}); } + +private: + ui::TimerHandle _leave; +}; + +ui::ApplicationFactory textApp(std::string text) { + return [text = std::move(text)](ui::AppContext&) { return std::make_unique(text); }; +} + +void skipUnlessBuilt(SmokeFrontend frontend) { + if (!frontendBuilt(frontend)) { + SKIP("this configure did not build that frontend"); + } +} + +} // namespace + +TEST_CASE("runFrontendSmoke: a minimal application mounts and quits on the terminal UI", "[examples-common][smoke]") { + runFrontendSmoke(textApp("smoke screen"), SmokeFrontend::Tui); + auto const result = smokeRun(textApp("smoke screen"), SmokeFrontend::Tui); + CHECK_THAT(result.screenText, Catch::Matchers::ContainsSubstring("smoke screen")); +} + +TEST_CASE("runFrontendSmoke: a minimal application mounts and quits on Qt Quick", "[examples-common][smoke]") { + runFrontendSmoke(textApp("smoke window"), SmokeFrontend::QtQuick); +} + +TEST_CASE("smokeRun: an empty view times out instead of passing", "[examples-common][smoke]") { + skipUnlessBuilt(SmokeFrontend::Tui); + auto const result = smokeRun([](ui::AppContext&) { return std::make_unique(); }, SmokeFrontend::Tui, + std::chrono::milliseconds{200}); + CHECK(result.viewBuilt); + CHECK_FALSE(result.mounted); + CHECK(result.timedOut); + CHECK(result.exitCode == kSmokeTimedOutExit); +} + +TEST_CASE("smokeRun: a factory that throws is reported, not hung", "[examples-common][smoke]") { + auto const frontend = GENERATE(SmokeFrontend::Tui, SmokeFrontend::QtQuick); + skipUnlessBuilt(frontend); + auto const result = smokeRun( + [](ui::AppContext&) -> std::unique_ptr { throw std::runtime_error{"factory failed"}; }, + frontend); + CHECK(result.error == "factory failed"); + CHECK_FALSE(result.viewBuilt); +} + +TEST_CASE("smokeRun: the exit code is the one the application quit with", "[examples-common][smoke]") { + auto const frontend = GENERATE(SmokeFrontend::Tui, SmokeFrontend::QtQuick); + skipUnlessBuilt(frontend); + auto const result = smokeRun([](ui::AppContext& ctx) { return std::make_unique(ctx); }, frontend); + CHECK(result.error.empty()); + CHECK(result.exitCode == 3); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target examples_common_app_tests` +Expected: FAIL — `'testkit/frontend_smoke.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `cmake/morph_example_app.cmake`: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# +# Building an example application against whichever frontends this configure built. Included from the root +# CMakeLists.txt before the Demo block, so examples/bank, examples/tui and the ladder (through +# cmake/morph_add_rung.cmake) all reach it. Frontend targets are named, not tested with if(TARGET): they are +# resolved at generate time, wherever the root creates them. + +# morph_example_frontends( ) +# +# Links every frontend that was built and defines MORPH_EXAMPLE_HAS_TUI and MORPH_EXAMPLE_HAS_QT_QUICK to 1 or 0 +# (both always defined: -Wundef is on). The terminal UI is never built under Emscripten. +function(morph_example_frontends target scope) + set(_tui 0) + set(_qt_quick 0) + if(MORPH_BUILD_TUI AND NOT EMSCRIPTEN) + set(_tui 1) + target_link_libraries(${target} ${scope} morph::tui) + endif() + if(MORPH_BUILD_QT_QUICK) + set(_qt_quick 1) + find_package(Qt6 6.5 REQUIRED COMPONENTS Gui Qml Quick QuickControls2) + target_link_libraries(${target} ${scope} morph::qt_quick Qt6::Gui Qt6::Quick) + endif() + target_compile_definitions(${target} ${scope} + MORPH_EXAMPLE_HAS_TUI=${_tui} + MORPH_EXAMPLE_HAS_QT_QUICK=${_qt_quick}) +endfunction() +``` + +In the root `CMakeLists.txt`, directly above `# ── Demo executable ──`: + +```cmake +# Example applications build their one binary against whichever frontends were built (see the module). +include(${PROJECT_SOURCE_DIR}/cmake/morph_example_app.cmake) +``` + +In `examples/common/app/CMakeLists.txt`, inside `if(MORPH_BUILD_TESTS AND NOT EMSCRIPTEN)` and before +`add_subdirectory(tests)`: + +```cmake + # morph_example_testkit: the Qt-free waits and fake context (header-only) and the frontend smoke harness, + # which links every built frontend. Its TU lives in testkit_src/, outside testkit/.clang-tidy's Catch2-only + # suppression (see that file). + add_library(morph_example_testkit STATIC + "${PROJECT_SOURCE_DIR}/examples/common/testkit_src/frontend_smoke.cpp" + "${PROJECT_SOURCE_DIR}/examples/common/testkit/frontend_smoke.hpp" + "${PROJECT_SOURCE_DIR}/examples/common/testkit/fake_app_context.hpp" + "${PROJECT_SOURCE_DIR}/examples/common/testkit/wait.hpp") + add_library(morph::example_testkit ALIAS morph_example_testkit) + target_include_directories(morph_example_testkit PUBLIC "${PROJECT_SOURCE_DIR}/examples/common") + target_link_libraries(morph_example_testkit PUBLIC morph::morph Catch2::Catch2) + target_compile_features(morph_example_testkit PUBLIC cxx_std_23) + morph_example_frontends(morph_example_testkit PUBLIC) + apply_warnings(morph_example_testkit) + if(AF_COVERAGE) + apply_coverage(morph_example_testkit) + endif() + if(DEFINED AF_SANITIZER) + apply_sanitizers(morph_example_testkit ${AF_SANITIZER}) + endif() +``` + +In `examples/common/app/tests/CMakeLists.txt`, change the link line to +`target_link_libraries(examples_common_app_tests PRIVATE morph::ladder_app_common morph::example_testkit morph_test_main)`. + +Create `examples/common/testkit/frontend_smoke.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +/// @file +/// @brief The frontend smoke test every example application carries: its factory, run on a real frontend, +/// mounts a view that reaches the screen and quits with 0. + +namespace morph::examples::testing { + +/// @brief Which real frontend a smoke run uses. +enum class SmokeFrontend : std::uint8_t { + Tui, ///< `morph::tui` over a mock terminal and a scripted input source with no input. + QtQuick, ///< `morph::qt_quick` on Qt's `offscreen` platform. +}; + +/// @brief The exit code a smoke run quits with when the view does not reach the screen within its budget. +inline constexpr int kSmokeTimedOutExit = 124; + +/// @brief What one smoke run observed. +struct SmokeResult { + int exitCode = -1; ///< What `Frontend::run` returned; -1 when it threw. + bool viewBuilt = false; ///< The application's `view()` was called. + bool mounted = false; ///< The view reached the screen (TUI: visible text; Qt Quick: a `w` item). + bool timedOut = false; ///< The budget ran out first, and the run quit with `kSmokeTimedOutExit`. + std::string error; ///< What escaped `run` (or why the run was refused); empty when it returned. + std::string screenText; ///< Every piece of text the TUI wrote; empty on Qt Quick. +}; + +/// @brief Whether this build includes @p frontend. +/// @param frontend The frontend. +/// @return True when its CMake option was on. +[[nodiscard]] bool frontendBuilt(SmokeFrontend frontend) noexcept; + +/// @brief Runs @p factory on @p frontend until its view reaches the screen, then quits with 0. +/// +/// The quit comes from the frontend's own scheduler, checked every 20 ms; the budget is scaled by +/// `MORPH_LADDER_DEADLINE_MS`. On Qt Quick, `QT_QPA_PLATFORM` defaults to `offscreen`, and a process that already +/// has a Qt application object is refused with an error, because the frontend constructs its own. +/// @param factory The application factory under test. +/// @param frontend The frontend. +/// @param budget How long the view may take to appear, before scaling. +/// @return What was observed. +[[nodiscard]] SmokeResult smokeRun(ui::ApplicationFactory const& factory, SmokeFrontend frontend, + std::chrono::milliseconds budget = std::chrono::milliseconds{5000}); + +/// @brief The Catch2 smoke test: skips when @p frontend is not built, else asserts that the application built its +/// view, the view reached the screen, nothing escaped, and the run exited 0. +/// @param factory The application factory under test. +/// @param frontend The frontend. +void runFrontendSmoke(ui::ApplicationFactory const& factory, SmokeFrontend frontend); + +} // namespace morph::examples::testing +``` + +Create `examples/common/testkit_src/frontend_smoke.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "testkit/frontend_smoke.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "testkit/deadline.hpp" + +#if MORPH_EXAMPLE_HAS_TUI +#include +#include +#include +#include +#include +#endif + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace morph::examples::testing { + +namespace { + +constexpr bool kHasTui = MORPH_EXAMPLE_HAS_TUI == 1; +constexpr bool kHasQtQuick = MORPH_EXAMPLE_HAS_QT_QUICK == 1; +constexpr std::chrono::milliseconds kTick{20}; + +// Wraps the application under test: records that its view was built, and owns the smoke's timer, so the timer +// dies with the application, before the frontend's runtime and scheduler. +class SmokeProbe final : public ui::Application { +public: + SmokeProbe(std::unique_ptr inner, SmokeResult& result) + : _inner{std::move(inner)}, _result{&result} {} + + [[nodiscard]] ui::Node view() override { + _result->viewBuilt = true; + return _inner->view(); + } + + void arm(ui::TimerHandle timer) { _timer = std::move(timer); } + +private: + std::unique_ptr _inner; + SmokeResult* _result; + ui::TimerHandle _timer; +}; + +SmokeResult drive(ui::Frontend& frontend, ui::ApplicationFactory const& factory, std::chrono::milliseconds budget, + std::function const& mounted) { + SmokeResult result; + auto const limit = std::chrono::milliseconds{ladder::testkit::detail::scaledDeadlineMs(budget.count())}; + ui::ApplicationFactory const wrapped = [&](ui::AppContext& ctx) -> std::unique_ptr { + auto inner = factory(ctx); + if (!inner) { + throw std::logic_error{"the application factory returned no application"}; + } + auto probe = std::make_unique(std::move(inner), result); + auto const started = std::chrono::steady_clock::now(); + probe->arm(ctx.scheduler().every( + kTick, [&ctx, &result, &mounted, started, limit, quitting = false]() mutable { + if (quitting) { + return; + } + if (result.viewBuilt && mounted()) { + result.mounted = true; + quitting = true; + ctx.quit(0); + } else if (std::chrono::steady_clock::now() - started >= limit) { + result.timedOut = true; + quitting = true; + ctx.quit(kSmokeTimedOutExit); + } + })); + return probe; + }; + try { + result.exitCode = frontend.run(wrapped); + } catch (std::exception const& error) { + result.error = error.what(); + } catch (...) { + result.error = "a non-std::exception escaped Frontend::run"; + } + return result; +} + +#if MORPH_EXAMPLE_HAS_TUI +// Keeps the text the frontend writes, so "mounted" means the view's text reached the terminal. +class CapturingOutput final : public ::core::tui::MockTerminalOutput { +public: + using MockTerminalOutput::MockTerminalOutput; + + void writeText(std::string_view text, ::core::tui::Style const& style) override { + _text.append(text); + MockTerminalOutput::writeText(text, style); + } + + [[nodiscard]] std::string const& text() const noexcept { return _text; } + +private: + std::string _text; +}; + +SmokeResult runTui(ui::ApplicationFactory const& factory, std::chrono::milliseconds budget) { + auto output = std::make_unique(100, 40); + CapturingOutput const* const screen = output.get(); + ::core::tui::Terminal terminal{std::move(output)}; + ::core::tui::runtime::testing::ScriptedInputSource input; // no input channel: mount and quit only + tui::Frontend frontend{tui::FrontendConfig{.terminal = &terminal, .input = &input}}; + auto result = drive(frontend, factory, budget, [screen] { + return std::ranges::any_of(screen->text(), [](char glyph) { return glyph != ' ' && glyph != '\n'; }); + }); + result.screenText = screen->text(); + return result; +} +#else +SmokeResult runTui(ui::ApplicationFactory const& /*factory*/, std::chrono::milliseconds /*budget*/) { + return SmokeResult{.error = "the terminal UI frontend is not built (MORPH_BUILD_TUI=OFF)"}; +} +#endif + +#if MORPH_EXAMPLE_HAS_QT_QUICK +bool isWidgetName(QString const& name) { + return name.size() > 1 && name.front() == QLatin1Char('w') && + std::all_of(name.cbegin() + 1, name.cend(), [](QChar glyph) { return glyph.isDigit(); }); +} + +bool hasWidgetItem(QQuickItem const& item) { + auto const children = item.childItems(); + return std::ranges::any_of(children, [](QQuickItem const* child) { + return isWidgetName(child->objectName()) || hasWidgetItem(*child); + }); +} + +bool quickViewMounted() { + auto const windows = QGuiApplication::topLevelWindows(); + return std::ranges::any_of(windows, [](QWindow* window) { + auto const* quick = qobject_cast(window); + return quick != nullptr && quick->contentItem() != nullptr && hasWidgetItem(*quick->contentItem()); + }); +} + +SmokeResult runQtQuick(ui::ApplicationFactory const& factory, std::chrono::milliseconds budget) { + if (QCoreApplication::instance() != nullptr) { + return SmokeResult{.error = "a Qt application object already exists, and the Qt Quick frontend constructs its " + "own: run this smoke from a binary whose main creates none (morph_test_main)"}; + } + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + std::string program = "frontend_smoke"; + std::array argv{program.data(), nullptr}; + int argc = 1; + qt_quick::Frontend frontend{argc, argv.data()}; + return drive(frontend, factory, budget, [] { return quickViewMounted(); }); +} +#else +SmokeResult runQtQuick(ui::ApplicationFactory const& /*factory*/, std::chrono::milliseconds /*budget*/) { + return SmokeResult{.error = "the Qt Quick frontend is not built (MORPH_BUILD_QT_QUICK=OFF)"}; +} +#endif + +} // namespace + +bool frontendBuilt(SmokeFrontend frontend) noexcept { + return frontend == SmokeFrontend::Tui ? kHasTui : kHasQtQuick; +} + +SmokeResult smokeRun(ui::ApplicationFactory const& factory, SmokeFrontend frontend, std::chrono::milliseconds budget) { + return frontend == SmokeFrontend::Tui ? runTui(factory, budget) : runQtQuick(factory, budget); +} + +void runFrontendSmoke(ui::ApplicationFactory const& factory, SmokeFrontend frontend) { + if (!frontendBuilt(frontend)) { + SKIP((frontend == SmokeFrontend::Tui ? "the terminal UI frontend is not built" + : "the Qt Quick frontend is not built")); + } + auto const result = smokeRun(factory, frontend); + INFO("error: " << result.error); + REQUIRE(result.error.empty()); + CHECK(result.viewBuilt); + CHECK(result.mounted); + CHECK_FALSE(result.timedOut); + CHECK(result.exitCode == 0); +} + +} // namespace morph::examples::testing +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target examples_common_app_tests && ./build/all/examples/common/app/tests/examples_common_app_tests "[smoke]" +``` + +Expected: PASS, 5 test cases, none skipped on `build/all`. On `build/ex-tui` the Qt Quick generator rows and the +Qt Quick case report as skipped, and on `build/ex-qt` the TUI ones. Mutation check: in `drive`, replace +`result.viewBuilt && mounted()` with `result.viewBuilt`. Expected FAIL in "an empty view times out instead of +passing" (`mounted` is true, exit 0). Restore. Second: make `isWidgetName` return `false`. Expected FAIL in +"a minimal application mounts and quits on Qt Quick" (`mounted` false, the run times out with 124). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add CMakeLists.txt cmake/morph_example_app.cmake examples/common/testkit/frontend_smoke.hpp \ + examples/common/testkit_src/frontend_smoke.cpp examples/common/app +git commit -m "wip(examples-common): the frontend smoke harness + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 9: `morph_add_example_ui` and `morph_add_rung`'s `app/` + `ui/` layout + +Spec 4 §2 and §6: a rung's `app/*.cpp` becomes `ladder__app` (morph, the rung's library and +`morph_ladder_app_common` — no toolkit), its `ui/*.cpp` the one binary ``, built when at least one frontend +is and linking every frontend built. One more convention follows from Task 8: `tests/smoke/*.cpp` becomes +`ladder__smoke_tests`, whose `main` is `morph_test_main`, because the rung's ordinary test binary owns a +`QCoreApplication` and the Qt Quick frontend constructs its own. Every existing convention keeps working +unchanged; no rung has `app/`, `ui/` or `tests/smoke/` yet, so this task changes no existing target, and the new +blocks are proven on a probe rung layout that is created, built and deleted in Step 4. + +**Files:** +- Modify: `cmake/morph_example_app.cmake` — append `morph_add_example_ui` +- Modify: `cmake/morph_add_rung.cmake` — the header's directory table; a helper before `function(morph_add_rung)`; + a `ladder__app` block after the `ladder__lib` block; a `` block after it; in the tests block, + the smoke exclusion and the app link; a `ladder__smoke_tests` block after the tests block + +**Interfaces:** +- Consumes: `morph_example_frontends` (Task 8); targets `morph::ladder_app_common`, `morph::example_testkit`, + `morph_test_main`; `apply_warnings`, `apply_bigobj`, `apply_coverage`, `apply_sanitizers`. +- Produces: `morph_add_example_ui(TARGET SOURCES ... LIBRARIES ...)` (rungs and `examples/tui`; bank links `morph_example_frontends` directly, because the helper applies the strict warning set and bank's model headers include the ORM's, which are not `-Werror` clean); + per rung, when the directories exist: `ladder__app` / `morph::ladder__app` (PUBLIC include dirs + `/app` and `/include`), executable ``, `ladder__smoke_tests` (ctest prefix + `.smoke.`, label `ladder-`); `ladder__tests` additionally links `ladder__app` and + `morph::example_testkit` and no longer compiles `tests/smoke/`. + +- [ ] **Step 1: Write the failing check** + +The deliverable is CMake, so the failing check is a probe rung layout. Create these three files (they are +deleted in Step 4 and never committed): + +`examples/pastebin/app/probe_application.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +namespace pastebin_probe::client { +[[nodiscard]] std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env); +} +``` + +`examples/pastebin/app/probe_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "probe_application.hpp" + +#include + +namespace pastebin_probe::client { +namespace { +class Probe final : public morph::ui::Application { +public: + [[nodiscard]] morph::ui::Node view() override { return morph::ui::text({.text = "pastebin probe"}); } +}; +} // namespace + +std::unique_ptr makeApplication(morph::ui::AppContext& /*ctx*/, + morph::examples::AppEnvironment const& /*env*/) { + return std::make_unique(); +} +} // namespace pastebin_probe::client +``` + +`examples/pastebin/ui/main.cpp` (the shape every example's `main` has, spec 4 §3): + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "probe_application.hpp" + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#endif +#if MORPH_EXAMPLE_HAS_TUI +#include +#endif + +int main(int argc, char** argv) { + try { + auto const env = morph::examples::AppEnvironment::fromArgs(argc, argv); + std::vector built; +#if MORPH_EXAMPLE_HAS_QT_QUICK + built.push_back(morph::qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_EXAMPLE_HAS_TUI + built.push_back(morph::tui::frontendOption()); +#endif + auto const frontend = morph::ui::selectFrontend(built, argc, argv); + return frontend->run( + [&env](morph::ui::AppContext& ctx) { return pastebin_probe::client::makeApplication(ctx, env); }); + } catch (std::exception const& error) { + std::cerr << "pastebin: " << error.what() << '\n'; + } catch (...) { + std::cerr << "pastebin: unknown error\n"; + } + return 1; +} +``` + +`examples/pastebin/tests/smoke/test_probe_smoke.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "probe_application.hpp" + +using morph::examples::AppEnvironment; +using morph::examples::testing::runFrontendSmoke; +using morph::examples::testing::SmokeFrontend; + +TEST_CASE("pastebin probe: mounts and quits on the terminal UI", "[probe][smoke]") { + runFrontendSmoke( + [](morph::ui::AppContext& ctx) { return pastebin_probe::client::makeApplication(ctx, AppEnvironment{}); }, + SmokeFrontend::Tui); +} + +TEST_CASE("pastebin probe: mounts and quits on Qt Quick", "[probe][smoke]") { + runFrontendSmoke( + [](morph::ui::AppContext& ctx) { return pastebin_probe::client::makeApplication(ctx, AppEnvironment{}); }, + SmokeFrontend::QtQuick); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake -S . -B build/all && cmake --build build/all --target ladder_pastebin_app` +Expected: FAIL — `ninja: error: unknown target 'ladder_pastebin_app'`. (Building `ladder_pastebin_tests` now would +also fail: its glob picks up `tests/smoke/test_probe_smoke.cpp`, which cannot find `probe_application.hpp`.) + +- [ ] **Step 3: Implement** + +Append to `cmake/morph_example_app.cmake`: + +```cmake +# morph_add_example_ui(TARGET SOURCES ... LIBRARIES ...) +# +# An example's one binary: its main picks a frontend at runtime among those built. Built when at least one +# frontend is, linking every one that was, with MORPH_EXAMPLE_HAS_TUI / MORPH_EXAMPLE_HAS_QT_QUICK defined for +# main's #if. With Qt Quick it is a Qt executable, which is what a WebAssembly build of the same main needs. A +# skip is announced, never silent: a target that vanishes from a successful configure surfaces only later, as +# "unknown target". +function(morph_add_example_ui) + cmake_parse_arguments(UI "" "TARGET" "SOURCES;LIBRARIES" ${ARGN}) + if(NOT UI_TARGET OR NOT UI_SOURCES) + message(FATAL_ERROR "morph_add_example_ui() requires TARGET and SOURCES") + endif() + if(NOT ((MORPH_BUILD_TUI AND NOT EMSCRIPTEN) OR MORPH_BUILD_QT_QUICK)) + message(STATUS "morph_add_example_ui: ${UI_TARGET} is not built -- it needs MORPH_BUILD_TUI " + "or MORPH_BUILD_QT_QUICK") + return() + endif() + if(MORPH_BUILD_QT_QUICK) + find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2) + qt_add_executable(${UI_TARGET} ${UI_SOURCES}) + else() + add_executable(${UI_TARGET} ${UI_SOURCES}) + endif() + target_link_libraries(${UI_TARGET} PRIVATE ${UI_LIBRARIES}) + morph_example_frontends(${UI_TARGET} PRIVATE) + target_compile_features(${UI_TARGET} PRIVATE cxx_std_23) + apply_warnings(${UI_TARGET}) + apply_bigobj(${UI_TARGET}) + if(AF_COVERAGE) + apply_coverage(${UI_TARGET}) + endif() + # Every library it links is instrumented on a sanitizer preset; an uninstrumented binary fails to link. + if(DEFINED AF_SANITIZER) + apply_sanitizers(${UI_TARGET} ${AF_SANITIZER}) + endif() +endfunction() +``` + +In `cmake/morph_add_rung.cmake`, extend the header's directory table after the `src/headless/*.cpp` line: + +```cmake +# app/*.cpp -> ladder__app STATIC (controllers + views: morph, the rung's lib and morph_ladder_app_common; no toolkit) +# ui/*.cpp -> EXE (the one binary; needs MORPH_BUILD_TUI or MORPH_BUILD_QT_QUICK; Qt Quick only under Emscripten) +# tests/smoke/*.cpp -> ladder__smoke_tests EXE (frontend smoke tests; a main that owns no Qt application object) +``` + +Before `function(morph_add_rung)`: + +```cmake +# Lightweight's target_include_directories() is plain PUBLIC, not SYSTEM, so a target that sees its headers +# transitively and calls apply_warnings() would apply -Werror to them. This demotes them to SYSTEM for one +# target -- the same remedy the gui_lib and tests blocks below apply inline. +function(_morph_rung_system_lightweight target scope) + get_target_property(_includes Lightweight::Lightweight INTERFACE_INCLUDE_DIRECTORIES) + if(_includes) + target_include_directories(${target} SYSTEM ${scope} ${_includes}) + endif() +endfunction() +``` + +After the `ladder__lib` block's closing `endif()` (the `if(NOT EMSCRIPTEN)` one), add: + +```cmake + # ── ladder__app: controllers and views, toolkit-free ─────────── + # The application a frontend runs. Its own link set is morph, the rung's + # library and morph_ladder_app_common, which names no toolkit, so a Qt or + # frontend include in app/ has nothing to resolve against here. Built under + # Emscripten too, where there is no ladder__lib: the browser binary is + # the same ui/ main over the same library, with the models' headers only. + file(GLOB_RECURSE _app_sources CONFIGURE_DEPENDS "${_dir}/app/*.cpp") + if(_app_sources) + if(NOT TARGET morph_ladder_app_common) + message(FATAL_ERROR "morph_add_rung(NAME ${_rung}): app/ needs morph_ladder_app_common, which the root " + "CMakeLists.txt adds under MORPH_BUILD_LADDER -- configure from the root.") + endif() + add_library(ladder_${_rung}_app STATIC ${_app_sources}) + add_library(morph::ladder_${_rung}_app ALIAS ladder_${_rung}_app) + target_include_directories(ladder_${_rung}_app PUBLIC "${_dir}/app" "${_dir}/include") + target_link_libraries(ladder_${_rung}_app PUBLIC morph::morph morph::ladder_app_common) + if(TARGET ladder_${_rung}_lib) + target_link_libraries(ladder_${_rung}_app PUBLIC morph::ladder_${_rung}_lib) + _morph_rung_system_lightweight(ladder_${_rung}_app PUBLIC) + endif() + target_compile_features(ladder_${_rung}_app PUBLIC cxx_std_23) + apply_warnings(ladder_${_rung}_app) + apply_bigobj(ladder_${_rung}_app) + if(AF_COVERAGE) + apply_coverage(ladder_${_rung}_app) + endif() + # See ladder_${_rung}_lib's identical AF_SANITIZER block above. + if(DEFINED AF_SANITIZER) + apply_sanitizers(ladder_${_rung}_app ${AF_SANITIZER}) + endif() + endif() + + # ── : the one binary, native and WebAssembly ─────────────────── + # The rung's library is linked whole so its schema TU's static-init + # migrations reach the in-process database (see ladder__tests' + # WHOLE_ARCHIVE note below). A browser build dispatches every action to a + # server, so it needs MORPH_CLIENT_ONLY for the reason ladder__gui_wasm + # does. + file(GLOB _ui_sources CONFIGURE_DEPENDS "${_dir}/ui/*.cpp") + if(_ui_sources AND NOT TARGET ladder_${_rung}_app) + message(STATUS "morph_add_rung: rung '${_rung}' has ui/*.cpp but no app/*.cpp -- '${_rung}' is skipped") + elseif(_ui_sources) + if(EMSCRIPTEN AND NOT MORPH_CLIENT_ONLY) + message(FATAL_ERROR + "morph_add_rung: rung '${_rung}' builds '${_rung}' for WebAssembly, which needs " + "-DMORPH_CLIENT_ONLY=ON. See docs/spec/core/registry.md, \"MORPH_CLIENT_ONLY\".") + endif() + set(_ui_libraries morph::ladder_${_rung}_app) + if(TARGET ladder_${_rung}_lib) + list(APPEND _ui_libraries "$") + endif() + morph_add_example_ui(TARGET ${_rung} SOURCES ${_ui_sources} LIBRARIES ${_ui_libraries}) + endif() +``` + +In the `ladder__tests` block, directly after `file(GLOB_RECURSE _test_sources …)`: + +```cmake + # tests/smoke/ is its own binary (below): its main must own no Qt application object. + list(FILTER _test_sources EXCLUDE REGEX "/tests/smoke/") +``` + +and after the `if(TARGET ladder_${_rung}_gui_lib) … endif()` that links the gui_lib into the tests: + +```cmake + # Controller and view tests: the app library, and the Qt-free waits and fake context. + if(TARGET ladder_${_rung}_app) + target_link_libraries(ladder_${_rung}_tests PRIVATE morph::ladder_${_rung}_app morph::example_testkit) + endif() +``` + +After the tests block's closing `endif()` (the `if(NOT EMSCRIPTEN)` around `ladder__tests`), add: + +```cmake + # ── ladder__smoke_tests: the app on each real frontend ───────── + # A binary of its own because the Qt Quick frontend constructs its own + # QGuiApplication, and ladder__tests' main already owns a + # QCoreApplication; morph_test_main owns none. Same database lock and label + # as the rung's other tests; the ".smoke." prefix keeps the ctest names + # apart from them. + file(GLOB_RECURSE _smoke_sources CONFIGURE_DEPENDS "${_dir}/tests/smoke/*.cpp") + if(NOT EMSCRIPTEN AND _smoke_sources AND TARGET ladder_${_rung}_app) + add_executable(ladder_${_rung}_smoke_tests ${_smoke_sources}) + target_link_libraries(ladder_${_rung}_smoke_tests PRIVATE + morph::ladder_${_rung}_app morph::example_testkit morph_test_main) + if(TARGET ladder_${_rung}_lib) + target_link_libraries(ladder_${_rung}_smoke_tests PRIVATE + "$") + _morph_rung_system_lightweight(ladder_${_rung}_smoke_tests PRIVATE) + endif() + target_compile_features(ladder_${_rung}_smoke_tests PRIVATE cxx_std_23) + apply_warnings(ladder_${_rung}_smoke_tests) + apply_bigobj(ladder_${_rung}_smoke_tests) + if(AF_COVERAGE) + apply_coverage(ladder_${_rung}_smoke_tests) + endif() + if(DEFINED AF_SANITIZER) + apply_sanitizers(ladder_${_rung}_smoke_tests ${AF_SANITIZER}) + endif() + include(Catch) + get_target_property(_qt_core_dll Qt6::Core IMPORTED_LOCATION) + cmake_path(GET _qt_core_dll PARENT_PATH _qt_bin_dir) + catch_discover_tests(ladder_${_rung}_smoke_tests + DISCOVERY_MODE POST_BUILD + TEST_PREFIX "${_rung}.smoke." + DL_PATHS "${_qt_bin_dir}" + PROPERTIES LABELS ladder-${_rung} TIMEOUT 120 RESOURCE_LOCK morph_ladder_test_db) + elseif(_smoke_sources AND NOT TARGET ladder_${_rung}_app) + message(STATUS "morph_add_rung: rung '${_rung}' has tests/smoke/ but no app/*.cpp -- " + "ladder_${_rung}_smoke_tests is skipped") + endif() +``` + +- [ ] **Step 4: Run the check, then remove the probe** + +```bash +cmake -S . -B build/all +cmake --build build/all --target ladder_pastebin_app pastebin ladder_pastebin_tests ladder_pastebin_smoke_tests +ctest --test-dir build/all -R '^pastebin\.smoke\.' --output-on-failure +./build/all/examples/pastebin/ladder_pastebin_tests --list-tests | grep -c 'pastebin probe' +ctest --test-dir build/all -N -L ladder-pastebin | grep -c 'pastebin.smoke.' +cmake --build build/ex-tui --target help | grep -c 'ladder_pastebin' +``` + +Expected: the build succeeds; the two smoke cases pass (`100% tests passed, 0 tests failed out of 2`); the +`--list-tests` count is `0` (the smoke file is not in the ordinary binary); the label count is `2`; the last +count is `0` (no ladder in `build/ex-tui`, and nothing about the rung layout leaks into a non-ladder configure). +Mutation check: delete the `list(FILTER … "/tests/smoke/")` line, rebuild `ladder_pastebin_tests` and run +`./build/all/examples/pastebin/ladder_pastebin_tests "[probe]"`. Expected FAIL in "pastebin probe: mounts and +quits on Qt Quick" with "a Qt application object already exists" — the testkit's Qt `main` owns a +`QCoreApplication`, which is why smoke tests get their own binary. Restore. + +Then remove the probe and confirm the tree is as before: + +```bash +rm -r examples/pastebin/app examples/pastebin/ui examples/pastebin/tests/smoke +cmake -S . -B build/all && cmake --build build/all +git status --short examples/pastebin # prints nothing +``` + +- [ ] **Step 5: Commit** + +```bash +git add cmake/morph_example_app.cmake cmake/morph_add_rung.cmake +git commit -m "wip(examples-common): morph_add_example_ui, and morph_add_rung's app/, ui/ and tests/smoke/ + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 10: `examples/tui/gallery` — every node kind once, drag-and-drop included + +Spec 1 §8: a visual check for backend authors, running through `ui::selectFrontend` like every migrated app. +Its state lives in signals on the application; every control writes a line into "Last event", which is what the +view test reads back. It is registered from the root's "Example applications on an injected frontend" block (Task +2), not the Demo block: its tests call `include(Catch)` and link `morph_example_testkit`, both of which exist only +after the Tests block — the reason is stated in the squashed commit's body as a deviation from the plan's brief. + +**Files:** +- Create: `examples/tui/CMakeLists.txt`, `examples/tui/README.md` +- Create: `examples/tui/gallery/CMakeLists.txt`, `examples/tui/gallery/app/gallery_application.hpp`, + `examples/tui/gallery/app/gallery_application.cpp`, `examples/tui/gallery/ui/main.cpp` +- Create: `examples/tui/gallery/tests/.clang-tidy` (Task 2's content), `tests/test_gallery_view.cpp`, + `tests/test_gallery_smoke.cpp` +- Modify: `CMakeLists.txt` (root) — append to the "Example applications on an injected frontend" block + +**Interfaces:** +- Consumes: every builder of `ui/view.hpp` (Part 2 contract); `ui::Mounted`; + `ui::testing::RecordingBackend::{find, all, prop, exists, click, edit, choose, dismiss, drag}`; + `ui::selectFrontend`, `ui::FrontendOption`; `tui::frontendOption(tui::FrontendConfig = {})` + (`morph/tui/frontend.hpp`, Part 3), `qt_quick::frontendOption(int&, char**, ui::EnvironmentReader = + ui::processEnvironment())` (`morph/qt_quick/frontend.hpp`, Part 4); `reactive::Signal::{get, peek, set, mutate}`; + `examples::AppEnvironment::fromArgs` (Task 2); `FakeAppContext`, `runFrontendSmoke` (Tasks 4, 8); + `morph_add_example_ui` (Task 9). +- Produces: `gallery::client::makeApplication(ui::AppContext&, examples::AppEnvironment const&) -> + std::unique_ptr` (the gallery reads nothing from the environment); targets `gallery_app`, + `gallery`, `gallery_tests` (label `examples-tui`). + +- [ ] **Step 1: Write the failing tests** + +Create `examples/tui/gallery/tests/test_gallery_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gallery_application.hpp" + +namespace { + +using morph::examples::AppEnvironment; +using morph::examples::testing::FakeAppContext; +using morph::ui::testing::RecordingBackend; + +struct Rig { + FakeAppContext ctx; + std::unique_ptr app = gallery::client::makeApplication(ctx, AppEnvironment{}); + RecordingBackend rec; + morph::ui::Mounted mounted{ctx.runtime(), rec, app->view()}; + + void settle() { ctx.owner().drain(); } + + int widget(std::string_view kind, std::string_view prop, std::string_view value) { + auto const found = rec.find(kind, prop, value); + REQUIRE(found.has_value()); + return *found; + } + + [[nodiscard]] bool shows(std::string_view text) const { return rec.find("Text", "text", text).has_value(); } +}; + +} // namespace + +TEST_CASE("gallery: the view mounts every widget kind", "[gallery][view]") { + Rig rig; + rig.settle(); + // A stack is a Column or a Row by its axis (a ForEach is a Column); a Switch and each shown Tabs page are + // Slots; a closed Dialog is still a Dialog, only its content is absent. + constexpr std::array kKinds{ + "Text", "Button", "TextInput", "Checkbox", "Select", "Menu", "Column", "Row", "Grid", "Spacer", + "Panel", "Scroll", "Slot", "Tabs", "Dialog", "Busy", "Table", "DateTimeInput", "Slider", "FilePicker"}; + for (auto const kind : kKinds) { + INFO(kind); + CHECK_FALSE(rig.rec.all(kind).empty()); + } +} + +TEST_CASE("gallery: a click lands in the event line", "[gallery][view]") { + Rig rig; + rig.settle(); + CHECK(rig.shows("Last event: none")); + rig.rec.click(rig.widget("Button", "label", "Press me")); + rig.settle(); + CHECK(rig.shows("Last event: pressed")); +} + +TEST_CASE("gallery: the radio choice switches the Switch's content", "[gallery][view]") { + Rig rig; + rig.settle(); + CHECK(rig.shows("a circle")); + auto const selects = rig.rec.all("Select"); + REQUIRE(selects.size() == 2); // created in view order: the colour dropdown, then the shape radio + rig.rec.choose(selects.at(1), morph::ui::Key{std::int64_t{1}}); + rig.settle(); + CHECK(rig.shows("a square")); + CHECK_FALSE(rig.shows("a circle")); +} + +TEST_CASE("gallery: the keyed list adds, removes and keeps the rows it keeps", "[gallery][view]") { + Rig rig; + rig.settle(); + auto const banana = rig.widget("Text", "text", "banana"); + rig.rec.click(rig.widget("Button", "label", "Add")); + rig.settle(); + CHECK(rig.shows("fruit 4")); + rig.rec.click(rig.widget("Button", "label", "Remove first")); + rig.settle(); + CHECK_FALSE(rig.shows("apple")); + CHECK(rig.rec.exists(banana)); + CHECK(rig.widget("Text", "text", "banana") == banana); +} + +TEST_CASE("gallery: dragging a shelf item onto the basket moves it", "[gallery][view]") { + Rig rig; + rig.settle(); + auto const basket = rig.widget("Panel", "title", "Basket"); + CHECK(rig.rec.drag(rig.widget("Text", "text", "pear"), basket)); + rig.settle(); + CHECK(rig.shows("Last event: dropped pear in the basket")); + // Now in the basket: no longer on the shelf, so not something the basket takes. + CHECK_FALSE(rig.rec.drag(rig.widget("Text", "text", "pear"), basket)); +} + +TEST_CASE("gallery: the dialog opens, closes from its button and from a dismissal", "[gallery][view]") { + Rig rig; + rig.settle(); + auto const dialog = rig.widget("Dialog", "title", "A dialog"); + CHECK(rig.rec.prop(dialog, "open") == "false"); + CHECK_FALSE(rig.rec.find("Button", "label", "Close").has_value()); // the content mounts only while open + rig.rec.click(rig.widget("Button", "label", "Open dialog")); + rig.settle(); + CHECK(rig.rec.prop(dialog, "open") == "true"); + rig.rec.click(rig.widget("Button", "label", "Close")); + rig.settle(); + CHECK(rig.rec.prop(dialog, "open") == "false"); + CHECK(rig.shows("Last event: dialog closed")); + + rig.rec.click(rig.widget("Button", "label", "Open dialog")); + rig.settle(); + rig.rec.dismiss(dialog); + rig.settle(); + CHECK(rig.shows("Last event: dialog dismissed")); +} + +TEST_CASE("gallery: typing a name updates the greeting", "[gallery][view]") { + Rig rig; + rig.settle(); + CHECK(rig.shows("Hello, stranger")); + rig.rec.edit(rig.widget("TextInput", "placeholder", "Your name"), "Ada"); + rig.settle(); + CHECK(rig.shows("Hello, Ada")); +} + +TEST_CASE("gallery: Quit asks the frontend to quit with 0", "[gallery][view]") { + Rig rig; + rig.settle(); + rig.rec.click(rig.widget("Button", "label", "Quit")); + CHECK(rig.ctx.quitCode() == std::optional{0}); +} +``` + +Create `examples/tui/gallery/tests/test_gallery_smoke.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "gallery_application.hpp" + +using morph::examples::AppEnvironment; +using morph::examples::testing::runFrontendSmoke; +using morph::examples::testing::SmokeFrontend; + +TEST_CASE("gallery: mounts and quits on the terminal UI", "[gallery][smoke]") { + runFrontendSmoke( + [](morph::ui::AppContext& ctx) { return gallery::client::makeApplication(ctx, AppEnvironment{}); }, + SmokeFrontend::Tui); +} + +TEST_CASE("gallery: mounts and quits on Qt Quick", "[gallery][smoke]") { + runFrontendSmoke( + [](morph::ui::AppContext& ctx) { return gallery::client::makeApplication(ctx, AppEnvironment{}); }, + SmokeFrontend::QtQuick); +} +``` + +Create `examples/tui/gallery/CMakeLists.txt`: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# +# The gallery: every view-tree node kind once, drag-and-drop included. gallery_app links morph and the +# toolkit-free morph_ladder_app_common only, so a toolkit include in app/ does not compile. + +add_library(gallery_app STATIC app/gallery_application.cpp) +target_include_directories(gallery_app PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/app") +target_link_libraries(gallery_app PUBLIC morph::morph morph::ladder_app_common) +target_compile_features(gallery_app PUBLIC cxx_std_23) +apply_warnings(gallery_app) +if(AF_COVERAGE) + apply_coverage(gallery_app) +endif() +if(DEFINED AF_SANITIZER) + apply_sanitizers(gallery_app ${AF_SANITIZER}) +endif() + +morph_add_example_ui(TARGET gallery SOURCES ui/main.cpp LIBRARIES gallery_app) + +if(MORPH_BUILD_TESTS) + add_executable(gallery_tests tests/test_gallery_view.cpp tests/test_gallery_smoke.cpp) + target_link_libraries(gallery_tests PRIVATE gallery_app morph::example_testkit morph_test_main) + target_compile_features(gallery_tests PRIVATE cxx_std_23) + apply_warnings(gallery_tests) + if(AF_COVERAGE) + apply_coverage(gallery_tests) + endif() + if(DEFINED AF_SANITIZER) + apply_sanitizers(gallery_tests ${AF_SANITIZER}) + endif() + include(Catch) + catch_discover_tests(gallery_tests DISCOVERY_MODE PRE_TEST PROPERTIES LABELS examples-tui TIMEOUT 120) +endif() +``` + +Create `examples/tui/CMakeLists.txt`: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# +# Two example applications with no ladder rung, each an application library, its one binary and its tests: +# the gallery (every view-tree node kind, for frontend authors) and the workout dashboard (a Store-driven +# application with a timer). Added from the root when MORPH_BUILD_EXAMPLES and a frontend are on. + +add_subdirectory(gallery) +``` + +Append to the root's "Example applications on an injected frontend" block, after its `endif()`: + +```cmake +# examples/tui: two applications with no ladder rung. Natively only: a browser build of them would add a +# WebAssembly target nothing deploys. +if(MORPH_BUILD_EXAMPLES AND NOT EMSCRIPTEN AND (MORPH_BUILD_TUI OR MORPH_BUILD_QT_QUICK)) + add_subdirectory(examples/tui) +endif() +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake -S . -B build/all && cmake --build build/all --target gallery_tests` +Expected: FAIL — `Cannot find source file: app/gallery_application.cpp`. + +- [ ] **Step 3: Implement** + +Create `examples/tui/gallery/app/gallery_application.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// @brief The gallery: every view-tree node kind once, drag-and-drop included — a visual check for a frontend. + +namespace gallery::client { + +/// @brief Builds the gallery application. +/// @param ctx The frontend's context; its quit ends the gallery. +/// @param env The deployment choices; the gallery reads none of them. +/// @return The application. +[[nodiscard]] std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env); + +} // namespace gallery::client +``` + +Create `examples/tui/gallery/app/gallery_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "gallery_application.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace gallery::client { + +namespace { + +namespace ui = morph::ui; +using morph::reactive::Signal; +using ui::Node; + +enum class Shape : std::uint8_t { Circle, Square, Triangle }; + +struct Fruit { + std::int64_t id = 0; + std::string name; + bool operator==(Fruit const&) const = default; +}; + +std::string keyText(ui::Key const& key) { + if (auto const* number = std::get_if(&key)) { + return std::to_string(*number); + } + return std::get(key); +} + +ui::Key fruitKey(Fruit const& fruit) { + return ui::Key{fruit.id}; +} + +// A row's name. The row signal lives as long as the row's mount, which owns this binding. +Node fruitName(Signal const& row) { + return ui::text({.text = [row = &row] { return row->get().name; }}); +} + +ui::Node stretch() { + return ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}); +} + +class GalleryApplication final : public ui::Application { +public: + explicit GalleryApplication(ui::AppContext& ctx) + : _ctx{&ctx}, + _lastEvent{ctx.runtime(), std::string{"Last event: none"}}, + _subscribed{ctx.runtime(), false}, + _busy{ctx.runtime(), false}, + _colour{ctx.runtime(), std::optional{ui::Key{std::string{"green"}}}}, + _shape{ctx.runtime(), Shape::Circle}, + _name{ctx.runtime(), std::string{}}, + _notes{ctx.runtime(), std::string{}}, + _secret{ctx.runtime(), std::string{}}, + _when{ctx.runtime(), std::optional{}}, + _volume{ctx.runtime(), std::int64_t{50}}, + _file{ctx.runtime(), std::string{}}, + _detailsCollapsed{ctx.runtime(), true}, + _tab{ctx.runtime(), std::size_t{0}}, + _dialogOpen{ctx.runtime(), false}, + _fruits{ctx.runtime(), std::vector{Fruit{.id = 1, .name = "apple"}, Fruit{.id = 2, .name = "banana"}, + Fruit{.id = 3, .name = "cherry"}}}, + _selection{ctx.runtime(), std::vector{}}, + _shelf{ctx.runtime(), std::vector{Fruit{.id = 10, .name = "pear"}, Fruit{.id = 11, .name = "plum"}}}, + _basket{ctx.runtime(), std::vector{}} {} + + [[nodiscard]] Node view() override { + return ui::scroll({.child = ui::column({.children = {header(), controls(), choices(), containers(), + collections(), inputs(), dragAndDrop(), overlays()}, + .gap = 1})}); + } + +private: + void note(std::string const& what) { _lastEvent.set("Last event: " + what); } + + Node header() { + return ui::row({.children = {ui::text({.text = "morph UI gallery", .role = ui::TextRole::Heading}), stretch(), + ui::text({.text = [this] { return _lastEvent.get(); }, .role = ui::TextRole::Muted}), + ui::button({.label = "Quit", .onClick = [this] { _ctx->quit(0); }})}, + .gap = 2}); + } + + Node controls() { + auto buttons = ui::row( + {.children = {ui::button({.label = "Press me", .onClick = [this] { note("pressed"); }}), + ui::checkbox({.label = "Subscribed", + .checked = [this] { return _subscribed.get(); }, + .onToggle = + [this](bool subscribed) { + _subscribed.set(subscribed); + note(subscribed ? "subscribed" : "unsubscribed"); + }}), + ui::button({.label = "Toggle busy", .onClick = [this] { _busy.set(!_busy.peek()); }}), + ui::busy({.active = [this] { return _busy.get(); }, .label = "Working"})}, + .gap = 2}); + auto roles = ui::row({.children = {ui::text({.text = "normal"}), + ui::text({.text = "muted", .role = ui::TextRole::Muted}), + ui::text({.text = "heading", .role = ui::TextRole::Heading}), + ui::text({.text = "error", .role = ui::TextRole::Error}), + ui::text({.text = "success", .role = ui::TextRole::Success})}, + .gap = 2}); + return ui::panel({.title = "Controls", .padding = 1, .child = ui::column({.children = {buttons, roles}})}); + } + + Node choices() { + std::vector const colours{{.key = ui::Key{std::string{"red"}}, .label = "Red"}, + {.key = ui::Key{std::string{"green"}}, .label = "Green"}, + {.key = ui::Key{std::string{"blue"}}, .label = "Blue"}}; + std::vector const shapes{{.key = ui::Key{std::int64_t{0}}, .label = "Circle"}, + {.key = ui::Key{std::int64_t{1}}, .label = "Square"}, + {.key = ui::Key{std::int64_t{2}}, .label = "Triangle"}}; + auto colour = ui::select({.options = colours, + .selected = [this] { return _colour.get(); }, + .onSelect = + [this](ui::Key key) { + note("colour " + keyText(key)); + _colour.set(std::move(key)); + }, + .style = ui::SelectStyle::Dropdown}); + auto shape = ui::select( + {.options = shapes, + .selected = [this] { return std::optional{ui::Key{static_cast(_shape.get())}}; }, + .onSelect = + [this](ui::Key const& key) { + auto const* index = std::get_if(&key); + if (index != nullptr && *index >= 0 && *index <= 2) { + _shape.set(static_cast(*index)); + } + }, + .style = ui::SelectStyle::Radio}); + auto described = ui::switchOn([this] { return _shape.get(); }, + {{Shape::Circle, ui::text({.text = "a circle"})}, + {Shape::Square, ui::text({.text = "a square"})}, + {Shape::Triangle, ui::text({.text = "a triangle"})}}); + auto actions = ui::menu({.items = {{.label = "Say hello", .onSelect = [this] { note("hello"); }}, + {.label = "Say goodbye", .onSelect = [this] { note("goodbye"); }}}}); + return ui::panel({.title = "Choices", + .padding = 1, + .child = ui::column({.children = {colour, shape, described, actions}})}); + } + + Node containers() { + std::vector cells; + for (int i = 1; i <= 5; ++i) { + cells.push_back(ui::GridCell{.node = ui::text({.text = "cell " + std::to_string(i)}), .span = i == 5 ? 2 : 1}); + } + auto details = ui::panel({.title = "Details", + .padding = 1, + .child = ui::text({.text = "Collapsible content"}), + .collapsible = true, + .collapsed = [this] { return _detailsCollapsed.get(); }, + .onToggle = + [this](bool collapsed) { + _detailsCollapsed.set(collapsed); + note(collapsed ? "details collapsed" : "details expanded"); + }}); + return ui::panel( + {.title = "Containers", + .padding = 1, + .child = ui::column( + {.children = {ui::grid({.columns = 3, .cells = std::move(cells), .gap = 1}), + ui::row({.children = {ui::text({.text = "left"}), stretch(), ui::text({.text = "right"})}}), + details}})}); + } + + Node collections() { + auto buttons = ui::row({.children = {ui::button({.label = "Add", .onClick = [this] { addFruit(); }}), + ui::button({.label = "Remove first", .onClick = [this] { removeFirst(); }}), + ui::button({.label = "Reverse", .onClick = [this] { reverse(); }})}, + .gap = 1}); + auto list = ui::forEach(_fruits, fruitKey, fruitName); + auto table = ui::table( + {{.label = "Id", .width = ui::Sizing::fixed(4)}, {.label = "Name", .width = ui::Sizing::stretch()}}, + [this] { return _fruits.get(); }, fruitKey, + [](Signal const& row) { + return std::vector{ui::text({.text = [row = &row] { return std::to_string(row->get().id); }}), + fruitName(row)}; + }, + ui::TableOptions{.selectionMode = ui::SelectionMode::Multiple, + .selection = [this] { return _selection.get(); }, + .onSelectionChange = + [this](std::vector keys) { + note(std::to_string(keys.size()) + " selected"); + _selection.set(std::move(keys)); + }, + .onActivate = [this](ui::Key const& key) { note("activated " + keyText(key)); }}); + return ui::panel({.title = "Collections", .padding = 1, .child = ui::column({.children = {buttons, list, table}})}); + } + + Node inputs() { + auto name = ui::textInput({.value = [this] { return _name.get(); }, + .onChange = [this](std::string text) { _name.set(std::move(text)); }, + .onSubmit = [this](std::string const& text) { note("submitted " + text); }, + .placeholder = "Your name"}); + auto greeting = ui::text({.text = [this] { + return _name.get().empty() ? std::string{"Hello, stranger"} : "Hello, " + _name.get(); + }}); + auto notes = ui::textInput({.value = [this] { return _notes.get(); }, + .onChange = [this](std::string text) { _notes.set(std::move(text)); }, + .placeholder = "Notes", + .mode = ui::TextInputMode::Multiline}); + auto secret = ui::textInput({.value = [this] { return _secret.get(); }, + .onChange = [this](std::string text) { _secret.set(std::move(text)); }, + .placeholder = "Password", + .mode = ui::TextInputMode::Password}); + auto when = ui::dateTimeInput({.value = [this] { return _when.get(); }, + .onChange = + [this](std::optional value) { + _when.set(value); + note("date changed"); + }, + .mode = ui::DateMode::DateTime}); + auto volume = ui::row( + {.children = {ui::slider({.value = [this] { return _volume.get(); }, + .minimum = 0, + .maximum = 100, + .step = 5, + .onChange = [this](std::int64_t value) { _volume.set(value); }}), + ui::text({.text = [this] { return "Volume " + std::to_string(_volume.get()); }})}, + .gap = 1}); + auto file = ui::filePicker({.path = [this] { return _file.get(); }, + .mode = ui::FilePickerMode::Open, + .onPicked = + [this](std::string path) { + note("picked " + path); + _file.set(std::move(path)); + }}); + return ui::panel({.title = "Inputs", + .padding = 1, + .child = ui::column({.children = {name, greeting, notes, secret, when, volume, file}})}); + } + + Node dragAndDrop() { + auto const draggable = [](Signal const& row) { + return ui::text({.text = [row = &row] { return row->get().name; }, + .common = {.dragKey = [row = &row] { return std::optional{fruitKey(row->get())}; }}}); + }; + auto shelf = ui::panel({.title = "Shelf", .padding = 1, .child = ui::forEach(_shelf, fruitKey, draggable)}); + auto basket = ui::panel({.title = "Basket", + .padding = 1, + .child = ui::forEach(_basket, fruitKey, fruitName), + .common = {.accepts = [this](ui::Key const& key) { return onShelf(key); }, + .onDrop = [this](ui::Key const& key) { moveToBasket(key); }}}); + return ui::panel({.title = "Drag and drop", .padding = 1, .child = ui::row({.children = {shelf, basket}, .gap = 2})}); + } + + Node overlays() { + auto pages = ui::tabs({.tabs = {{.label = "First", .node = ui::text({.text = "The first tab"})}, + {.label = "Second", .node = ui::text({.text = "The second tab"})}}, + .selected = [this] { return _tab.get(); }, + .onSelect = + [this](std::size_t tab) { + _tab.set(tab); + note("tab " + std::to_string(tab)); + }}); + auto open = ui::button({.label = "Open dialog", .onClick = [this] { + _dialogOpen.set(true); + note("dialog opened"); + }}); + auto dialog = ui::dialog( + {.open = [this] { return _dialogOpen.get(); }, + .title = "A dialog", + .child = ui::column({.children = {ui::text({.text = "Focus stays here until the dialog closes."}), + ui::button({.label = "Close", .onClick = [this] { + _dialogOpen.set(false); + note("dialog closed"); + }})}}), + .onDismiss = [this] { + _dialogOpen.set(false); + note("dialog dismissed"); + }}); + return ui::panel({.title = "Overlays", .padding = 1, .child = ui::column({.children = {pages, open, dialog}})}); + } + + void addFruit() { + auto const fruitId = _nextFruit++; + _fruits.mutate([fruitId](std::vector& fruits) { + fruits.push_back(Fruit{.id = fruitId, .name = "fruit " + std::to_string(fruitId)}); + }); + note("added fruit " + std::to_string(fruitId)); + } + + void removeFirst() { + if (_fruits.peek().empty()) { + return; + } + _fruits.mutate([](std::vector& fruits) { fruits.erase(fruits.begin()); }); + note("removed the first fruit"); + } + + void reverse() { + _fruits.mutate([](std::vector& fruits) { std::ranges::reverse(fruits); }); + note("reversed the fruit"); + } + + [[nodiscard]] bool onShelf(ui::Key const& key) const { + return std::ranges::any_of(_shelf.peek(), [&key](Fruit const& fruit) { return fruitKey(fruit) == key; }); + } + + void moveToBasket(ui::Key const& key) { + auto shelf = _shelf.peek(); + auto const found = std::ranges::find_if(shelf, [&key](Fruit const& fruit) { return fruitKey(fruit) == key; }); + if (found == shelf.end()) { + return; + } + Fruit const moved = *found; + shelf.erase(found); + _shelf.set(std::move(shelf)); + _basket.mutate([&moved](std::vector& basket) { basket.push_back(moved); }); + note("dropped " + moved.name + " in the basket"); + } + + ui::AppContext* _ctx; + Signal _lastEvent; + Signal _subscribed; + Signal _busy; + Signal> _colour; + Signal _shape; + Signal _name; + Signal _notes; + Signal _secret; + Signal> _when; + Signal _volume; + Signal _file; + Signal _detailsCollapsed; + Signal _tab; + Signal _dialogOpen; + Signal> _fruits; + Signal> _selection; + Signal> _shelf; + Signal> _basket; + std::int64_t _nextFruit = 4; +}; + +} // namespace + +std::unique_ptr makeApplication(ui::AppContext& ctx, + morph::examples::AppEnvironment const& /*env*/) { + return std::make_unique(ctx); +} + +} // namespace gallery::client +``` + +Create `examples/tui/gallery/ui/main.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "gallery_application.hpp" + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#endif +#if MORPH_EXAMPLE_HAS_TUI +#include +#endif + +int main(int argc, char** argv) { + try { + auto const env = morph::examples::AppEnvironment::fromArgs(argc, argv); + std::vector built; +#if MORPH_EXAMPLE_HAS_QT_QUICK + built.push_back(morph::qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_EXAMPLE_HAS_TUI + built.push_back(morph::tui::frontendOption()); +#endif + auto const frontend = morph::ui::selectFrontend(built, argc, argv); + return frontend->run( + [&env](morph::ui::AppContext& ctx) { return gallery::client::makeApplication(ctx, env); }); + } catch (std::exception const& error) { + std::cerr << "gallery: " << error.what() << '\n'; + } catch (...) { + std::cerr << "gallery: unknown error\n"; + } + return 1; +} +``` + +Create `examples/tui/README.md`: + +```markdown +# examples/tui — the gallery and the workout dashboard + +Two applications with no ladder rung. Each is an application library (`app/`, toolkit-free), one binary +(`ui/main.cpp`) that picks its frontend at runtime, and tests (`tests/`): view tests on the headless +`RecordingBackend` and a smoke test per built frontend. + +| Binary | What it shows | +|---|---| +| `gallery` | Every view-tree node kind once — text roles, buttons, inputs, selects, a switch, a menu, a grid, panels, a keyed list, a table, tabs, a dialog, a busy indicator, date, slider and file inputs — and drag-and-drop from a shelf onto a basket. A visual check for anyone writing a frontend. | + +Build with `-DMORPH_BUILD_TUI=ON` and/or `-DMORPH_BUILD_QT_QUICK=ON` (examples are on by default). The binary +chooses with `--ui=tui` or `--ui=qt`, else `MORPH_UI`, else the first usable frontend (Qt Quick when a display +is available, the terminal when stdin is one): + + ./build/examples/tui/gallery/gallery --ui=tui +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/all --target gallery gallery_tests && ./build/all/examples/tui/gallery/gallery_tests` +Expected: PASS, 10 test cases; then `cmake --build build/ex-tui --target gallery gallery_tests` and +`build/ex-qt` likewise build, and their `gallery_tests` pass with the other frontend's smoke case skipped. +By hand once: `./build/all/examples/tui/gallery/gallery --ui=tui` shows the gallery; Tab moves focus, a drag from +"pear" onto "Basket" moves it, the Quit button exits. +Mutation check: delete `.common = {.dragKey = …}` from `draggable`. Expected FAIL in "dragging a shelf item onto +the basket moves it" (`drag` returns false). Restore. Toolkit check: add `#include ` at the top of +`gallery_application.cpp` and build `gallery_app` in `build/all`. Expected: compile error `'QObject' file not +found` — the library's link set names no toolkit. Remove the line. + +- [ ] **Step 5: Commit** + +```bash +git add CMakeLists.txt examples/tui +git commit -m "wip(examples-common): examples/tui/gallery, every node kind on the injected frontend + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 11: `examples/tui/workout` — a Store-driven dashboard with a ticker + +Spec 1 §8: a dashboard with a `Store` of signal fields and Msgs, `Tabs` (Dashboard / Statistics), a `Menu` +table, a keyed lap `forEach`, a Sprint `Button` with a bound `enabled`, a rider-name `TextInput`, a Quit +confirmation `Dialog`, and a `Scheduler`-driven ticker. It is shaped like the migrated apps: a controller (Store, +`Computed` projections, the ticker's handle last) tested headless, a view of bindings only, and an application +owning the controller. + +**Files:** +- Create: `examples/tui/workout/CMakeLists.txt`, `app/workout_controller.{hpp,cpp}`, `app/workout_view.{hpp,cpp}`, + `app/workout_application.{hpp,cpp}`, `ui/main.cpp` +- Create: `examples/tui/workout/tests/.clang-tidy` (Task 2's content), `tests/test_workout_controller.cpp`, + `tests/test_workout_view.cpp`, `tests/test_workout_smoke.cpp` +- Modify: `examples/tui/CMakeLists.txt` — `add_subdirectory(workout)` after `add_subdirectory(gallery)` +- Modify: `examples/tui/README.md` — the workout row + +**Interfaces:** +- Consumes: `reactive::Store(Runtime&, Init, Update)`, `Store::{send, action, state}`, + `reactive::Computed(Runtime&, F)`, `reactive::Signal::{get, peek, set, mutate}`, `reactive::Scheduler::every`, + `reactive::TimerHandle` (Part 1); the Part 2 builders, `Mounted`, `RecordingBackend::{find, all, prop, click, + edit, chooseIndex}`; `examples::AppEnvironment::fromArgs`; `tui::frontendOption`, `qt_quick::frontendOption` (as + Task 10); `FakeAppContext`, `runFrontendSmoke`; `morph_add_example_ui`. +- Produces: `workout::client::makeApplication(ui::AppContext&, examples::AppEnvironment const&)` (the workout reads + nothing from the environment); `workout::client::WorkoutController`, `WorkoutState`, `WorkoutMsg`, `Lap`, + `formatDuration`, `lapLine`, `kSprintCooldownSeconds`, `kTickPeriod`; targets `workout_app`, `workout`, + `workout_tests` (label `examples-tui`). + +- [ ] **Step 1: Write the failing tests** + +Create `examples/tui/workout/tests/test_workout_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "workout_controller.hpp" + +namespace { + +using morph::examples::testing::FakeAppContext; +using namespace workout::client; + +struct Rig { + FakeAppContext ctx; + std::unique_ptr controller = + std::make_unique(ctx.runtime(), ctx.manualScheduler()); + + void ride(int seconds) { ctx.manualScheduler().advance(std::chrono::seconds{seconds}); } +}; + +} // namespace + +TEST_CASE("workout: durations read mm:ss, and h:mm:ss past an hour", "[workout][controller]") { + CHECK(formatDuration(0) == "00:00"); + CHECK(formatDuration(65) == "01:05"); + CHECK(formatDuration(3600) == "1:00:00"); + CHECK(formatDuration(3725) == "1:02:05"); + CHECK(formatDuration(-5) == "00:00"); + CHECK(lapLine(Lap{.number = 1, .seconds = 62, .sprint = false}) == "Lap 1 01:02"); + CHECK(lapLine(Lap{.number = 2, .seconds = 40, .sprint = true}) == "Lap 2 00:40 sprint"); +} + +TEST_CASE("workout: the ticker counts only while riding", "[workout][controller]") { + Rig rig; + rig.ride(3); + CHECK(rig.controller->elapsedText() == "00:00"); + rig.controller->send(Start{}); + rig.ride(3); + CHECK(rig.controller->elapsedText() == "00:03"); + CHECK(rig.controller->lapText() == "00:03"); + rig.controller->send(Pause{}); + rig.ride(5); + CHECK(rig.controller->elapsedText() == "00:03"); +} + +TEST_CASE("workout: Sprint needs a ride under way, a lap started and no cooldown", "[workout][controller]") { + Rig rig; + CHECK_FALSE(rig.controller->canSprint()); + rig.controller->send(Start{}); + CHECK_FALSE(rig.controller->canSprint()); // nothing ridden in this lap yet + rig.ride(1); + CHECK(rig.controller->canSprint()); + CHECK(rig.controller->sprintLabel() == "Sprint"); + + rig.controller->send(Sprint{}); + REQUIRE(rig.controller->state().laps.peek().size() == 1); + CHECK(rig.controller->state().laps.peek().front().sprint); + CHECK_FALSE(rig.controller->canSprint()); + CHECK(rig.controller->sprintLabel() == "Sprint (30s)"); + + rig.ride(kSprintCooldownSeconds); + CHECK(rig.controller->canSprint()); + CHECK(rig.controller->sprintLabel() == "Sprint"); +} + +TEST_CASE("workout: a lap closes the current one and the statistics follow", "[workout][controller]") { + Rig rig; + CHECK(rig.controller->bestLapText() == "-"); + CHECK(rig.controller->averageLapText() == "-"); + rig.controller->send(Start{}); + rig.ride(10); + rig.controller->send(MarkLap{}); + rig.ride(20); + rig.controller->send(MarkLap{}); + CHECK(rig.controller->lapCountText() == "2"); + CHECK(rig.controller->bestLapText() == "00:10"); + CHECK(rig.controller->averageLapText() == "00:15"); + CHECK(rig.controller->sprintCountText() == "0"); + CHECK(rig.controller->lapText() == "00:00"); +} + +TEST_CASE("workout: a lap with nothing ridden is not recorded", "[workout][controller]") { + Rig rig; + rig.controller->send(Start{}); + rig.controller->send(MarkLap{}); + CHECK(rig.controller->state().laps.peek().empty()); +} + +TEST_CASE("workout: Reset clears the ride", "[workout][controller]") { + Rig rig; + rig.controller->send(Start{}); + rig.ride(12); + rig.controller->send(MarkLap{}); + rig.controller->send(Reset{}); + CHECK(rig.controller->elapsedText() == "00:00"); + CHECK(rig.controller->lapCountText() == "0"); + CHECK(rig.controller->statusText() == "Ready"); +} + +TEST_CASE("workout: the status says what the rider is doing", "[workout][controller]") { + Rig rig; + CHECK(rig.controller->statusText() == "Ready"); + rig.controller->send(Rename{.rider = "Ada"}); + CHECK(rig.controller->statusText() == "Ready, Ada"); + rig.controller->send(Start{}); + CHECK(rig.controller->statusText() == "Riding, Ada"); + rig.ride(1); + rig.controller->send(Pause{}); + CHECK(rig.controller->statusText() == "Paused, Ada"); +} + +TEST_CASE("workout: destroying the controller stops its ticker", "[workout][controller]") { + Rig rig; + CHECK(rig.ctx.manualScheduler().pendingTimers() == 1); + rig.controller.reset(); + CHECK(rig.ctx.manualScheduler().pendingTimers() == 0); +} +``` + +Create `examples/tui/workout/tests/test_workout_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "workout_application.hpp" + +namespace { + +using morph::examples::AppEnvironment; +using morph::examples::testing::FakeAppContext; +using morph::ui::testing::RecordingBackend; +using namespace std::chrono_literals; + +constexpr std::size_t kMenuStart = 0; +constexpr std::size_t kMenuQuit = 4; + +struct Rig { + FakeAppContext ctx; + std::unique_ptr app = workout::client::makeApplication(ctx, AppEnvironment{}); + RecordingBackend rec; + morph::ui::Mounted mounted{ctx.runtime(), rec, app->view()}; + + void settle() { ctx.owner().drain(); } + + int widget(std::string_view kind, std::string_view prop, std::string_view value) { + auto const found = rec.find(kind, prop, value); + REQUIRE(found.has_value()); + return *found; + } + + int only(std::string_view kind) { + auto const ids = rec.all(kind); + REQUIRE(ids.size() == 1); + return ids.front(); + } + + [[nodiscard]] bool shows(std::string_view text) const { return rec.find("Text", "text", text).has_value(); } +}; + +} // namespace + +TEST_CASE("workout: the view opens on the dashboard with Sprint disabled and the dialog closed", "[workout][view]") { + Rig rig; + rig.settle(); + static_cast(rig.only("Menu")); + static_cast(rig.only("Tabs")); + CHECK(rig.rec.prop(rig.widget("Button", "label", "Sprint"), "enabled") == "false"); + CHECK(rig.rec.prop(rig.only("Dialog"), "open") == "false"); + CHECK(rig.shows("Ready")); +} + +TEST_CASE("workout: Start from the menu, a tick and Sprint drive the dashboard", "[workout][view]") { + Rig rig; + rig.settle(); + auto const sprint = rig.widget("Button", "label", "Sprint"); + rig.rec.chooseIndex(rig.only("Menu"), kMenuStart); + rig.settle(); + rig.ctx.manualScheduler().advance(1000ms); + rig.settle(); + CHECK(rig.rec.prop(sprint, "enabled") == "true"); + rig.rec.click(sprint); + rig.settle(); + CHECK(rig.shows("Lap 1 00:01 sprint")); + CHECK(rig.rec.prop(sprint, "label") == "Sprint (30s)"); + CHECK(rig.rec.prop(sprint, "enabled") == "false"); +} + +TEST_CASE("workout: typing the rider's name shows in the status", "[workout][view]") { + Rig rig; + rig.settle(); + rig.rec.edit(rig.widget("TextInput", "placeholder", "Rider name"), "Ada"); + rig.settle(); + CHECK(rig.shows("Ready, Ada")); +} + +TEST_CASE("workout: Quit asks first; Keep riding cancels; Quit in the dialog exits 0", "[workout][view]") { + Rig rig; + rig.settle(); + auto const menu = rig.only("Menu"); + auto const dialog = rig.only("Dialog"); + rig.rec.chooseIndex(menu, kMenuQuit); + rig.settle(); + CHECK(rig.rec.prop(dialog, "open") == "true"); + rig.rec.click(rig.widget("Button", "label", "Keep riding")); + rig.settle(); + CHECK(rig.rec.prop(dialog, "open") == "false"); + CHECK_FALSE(rig.ctx.quitCode().has_value()); + + rig.rec.chooseIndex(menu, kMenuQuit); + rig.settle(); + rig.rec.click(rig.widget("Button", "label", "Quit")); + CHECK(rig.ctx.quitCode() == std::optional{0}); +} + +TEST_CASE("workout: the Statistics tab shows the lap figures", "[workout][view]") { + Rig rig; + rig.settle(); + rig.rec.chooseIndex(rig.only("Menu"), kMenuStart); + rig.settle(); + rig.ctx.manualScheduler().advance(10000ms); + rig.rec.click(rig.widget("Button", "label", "Lap")); + rig.settle(); + CHECK_FALSE(rig.shows("Best lap")); // tabs mount lazily + rig.rec.chooseIndex(rig.only("Tabs"), 1); + rig.settle(); + CHECK(rig.shows("Best lap")); + CHECK(rig.shows("1")); // laps ridden: only the Statistics tab shows a bare count +} +``` + +Create `examples/tui/workout/tests/test_workout_smoke.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "workout_application.hpp" + +using morph::examples::AppEnvironment; +using morph::examples::testing::runFrontendSmoke; +using morph::examples::testing::SmokeFrontend; + +TEST_CASE("workout: mounts and quits on the terminal UI", "[workout][smoke]") { + runFrontendSmoke( + [](morph::ui::AppContext& ctx) { return workout::client::makeApplication(ctx, AppEnvironment{}); }, + SmokeFrontend::Tui); +} + +TEST_CASE("workout: mounts and quits on Qt Quick", "[workout][smoke]") { + runFrontendSmoke( + [](morph::ui::AppContext& ctx) { return workout::client::makeApplication(ctx, AppEnvironment{}); }, + SmokeFrontend::QtQuick); +} +``` + +Create `examples/tui/workout/CMakeLists.txt`: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# +# The workout dashboard: a Store-driven controller with a scheduler ticker, its view, and its one binary. +# workout_app links morph and the toolkit-free morph_ladder_app_common only, so a toolkit include in app/ does +# not compile. + +add_library(workout_app STATIC + app/workout_controller.cpp + app/workout_view.cpp + app/workout_application.cpp) +target_include_directories(workout_app PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/app") +target_link_libraries(workout_app PUBLIC morph::morph morph::ladder_app_common) +target_compile_features(workout_app PUBLIC cxx_std_23) +apply_warnings(workout_app) +if(AF_COVERAGE) + apply_coverage(workout_app) +endif() +if(DEFINED AF_SANITIZER) + apply_sanitizers(workout_app ${AF_SANITIZER}) +endif() + +morph_add_example_ui(TARGET workout SOURCES ui/main.cpp LIBRARIES workout_app) + +if(MORPH_BUILD_TESTS) + add_executable(workout_tests + tests/test_workout_controller.cpp + tests/test_workout_view.cpp + tests/test_workout_smoke.cpp) + target_link_libraries(workout_tests PRIVATE workout_app morph::example_testkit morph_test_main) + target_compile_features(workout_tests PRIVATE cxx_std_23) + apply_warnings(workout_tests) + if(AF_COVERAGE) + apply_coverage(workout_tests) + endif() + if(DEFINED AF_SANITIZER) + apply_sanitizers(workout_tests ${AF_SANITIZER}) + endif() + include(Catch) + catch_discover_tests(workout_tests DISCOVERY_MODE PRE_TEST PROPERTIES LABELS examples-tui TIMEOUT 120) +endif() +``` + +Add `add_subdirectory(workout)` after `add_subdirectory(gallery)` in `examples/tui/CMakeLists.txt`. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake -S . -B build/all && cmake --build build/all --target workout_tests` +Expected: FAIL — `Cannot find source file: app/workout_controller.cpp`. + +- [ ] **Step 3: Implement** + +Create `examples/tui/workout/app/workout_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// @file +/// @brief The workout dashboard's controller: a `Store` of the ride, the projections its view binds to, and the +/// one-second ticker. + +namespace workout::client { + +/// @brief How long Sprint stays unavailable after a sprint. +inline constexpr std::int64_t kSprintCooldownSeconds = 30; +/// @brief The ticker's period: one tick is one ridden second. +inline constexpr std::chrono::milliseconds kTickPeriod{1000}; + +/// @brief One completed lap. +struct Lap { + std::int64_t number = 0; ///< 1-based, in order of completion; the lap list's key. + std::int64_t seconds = 0; ///< Its duration. + bool sprint = false; ///< Closed by Sprint rather than Lap. + bool operator==(Lap const&) const = default; +}; + +/// @brief The ride: what the user did and what the ticker counted. +struct WorkoutState { + morph::reactive::Signal rider; ///< The rider's name; may be empty. + morph::reactive::Signal running; ///< Whether the ticker counts. + morph::reactive::Signal elapsed; ///< Seconds ridden in total. + morph::reactive::Signal lapElapsed; ///< Seconds ridden in the current lap. + morph::reactive::Signal cooldown; ///< Seconds until Sprint is available again. + morph::reactive::Signal> laps; ///< Completed laps, oldest first. + morph::reactive::Signal tab; ///< The shown tab: 0 Dashboard, 1 Statistics. + morph::reactive::Signal confirmingQuit; ///< Whether the quit confirmation is open. +}; + +struct Start {}; ///< Start or resume riding. +struct Pause {}; ///< Stop the clock. +struct Tick {}; ///< One second passed (the ticker). +struct MarkLap {}; ///< Close the current lap. +struct Sprint {}; ///< Close the current lap as a sprint and start the cooldown. +struct Reset {}; ///< Clear the ride. +struct AskQuit {}; ///< Open the quit confirmation. +struct CancelQuit {}; ///< Close it. +/// @brief Rename the rider. +struct Rename { + std::string rider; ///< The new name. +}; +/// @brief Show a tab. +struct ShowTab { + std::size_t tab = 0; ///< Its index. +}; +/// @brief Everything the workout's view and ticker can ask of it. +using WorkoutMsg = std::variant; + +/// @brief A duration as `mm:ss`, or `h:mm:ss` from an hour on; negative reads as zero. +/// @param seconds The duration. +/// @return The text. +[[nodiscard]] std::string formatDuration(std::int64_t seconds); + +/// @brief One lap's line in the lap list: `Lap 2 00:40`, with ` sprint` appended for a sprint. +/// @param lap The lap. +/// @return The text. +[[nodiscard]] std::string lapLine(Lap const& lap); + +/// @brief The ride's state, its updates, and the projections the view binds to. +/// +/// Owns, in order: the store, the projections, and the ticker's handle, which goes first so no tick reaches a +/// half-destroyed store. +class WorkoutController { +public: + /// @param runtime The runtime. Borrowed: it must outlive the controller. + /// @param scheduler Runs the ticker. Borrowed: it must outlive the controller. + WorkoutController(morph::reactive::Runtime& runtime, morph::reactive::Scheduler& scheduler); + ~WorkoutController() = default; + WorkoutController(WorkoutController const&) = delete; + WorkoutController& operator=(WorkoutController const&) = delete; + WorkoutController(WorkoutController&&) = delete; + WorkoutController& operator=(WorkoutController&&) = delete; + + /// @brief Applies @p msg. + /// @param msg The message. + void send(WorkoutMsg msg) { _store.send(std::move(msg)); } + + /// @brief A callback that sends @p msg each time it runs — a button's or a menu item's action. + /// @param msg The message. + /// @return The callback. + [[nodiscard]] std::function action(WorkoutMsg msg) { return _store.action(std::move(msg)); } + + /// @brief The ride. + /// @return The state; its fields are tracked signals. + [[nodiscard]] WorkoutState const& state() const { return _store.state(); } + + /// @brief `Ready`, `Riding` or `Paused`, then `, ` when named. Tracked. + /// @return The text. + [[nodiscard]] std::string const& statusText() const { return _status.get(); } + /// @brief Total ridden time. Tracked. + /// @return The text. + [[nodiscard]] std::string const& elapsedText() const { return _elapsed.get(); } + /// @brief The current lap's time. Tracked. + /// @return The text. + [[nodiscard]] std::string const& lapText() const { return _lap.get(); } + /// @brief `Sprint`, or `Sprint (Ns)` during the cooldown. Tracked. + /// @return The text. + [[nodiscard]] std::string const& sprintLabel() const { return _sprintLabel.get(); } + /// @brief The number of completed laps. Tracked. + /// @return The text. + [[nodiscard]] std::string const& lapCountText() const { return _lapCount.get(); } + /// @brief The fastest lap, or `-` before the first. Tracked. + /// @return The text. + [[nodiscard]] std::string const& bestLapText() const { return _bestLap.get(); } + /// @brief The mean lap, or `-` before the first. Tracked. + /// @return The text. + [[nodiscard]] std::string const& averageLapText() const { return _averageLap.get(); } + /// @brief The number of sprint laps. Tracked. + /// @return The text. + [[nodiscard]] std::string const& sprintCountText() const { return _sprintCount.get(); } + /// @brief Whether Sprint does anything now: riding, a lap under way, no cooldown. Tracked. + /// @return The answer. + [[nodiscard]] bool canSprint() const { return _canSprint.get(); } + +private: + morph::reactive::Store _store; + morph::reactive::Computed _status; + morph::reactive::Computed _elapsed; + morph::reactive::Computed _lap; + morph::reactive::Computed _sprintLabel; + morph::reactive::Computed _lapCount; + morph::reactive::Computed _bestLap; + morph::reactive::Computed _averageLap; + morph::reactive::Computed _sprintCount; + morph::reactive::Computed _canSprint; + morph::reactive::TimerHandle _ticker; +}; + +} // namespace workout::client +``` + +Create `examples/tui/workout/app/workout_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "workout_controller.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace workout::client { + +namespace { + +using morph::reactive::Runtime; + +WorkoutState initialState(Runtime& runtime) { + return WorkoutState{.rider{runtime, std::string{}}, + .running{runtime, false}, + .elapsed{runtime, std::int64_t{0}}, + .lapElapsed{runtime, std::int64_t{0}}, + .cooldown{runtime, std::int64_t{0}}, + .laps{runtime, std::vector{}}, + .tab{runtime, std::size_t{0}}, + .confirmingQuit{runtime, false}}; +} + +void closeLap(WorkoutState& state, bool sprint) { + auto const seconds = state.lapElapsed.peek(); + if (seconds == 0) { + return; + } + auto const number = static_cast(state.laps.peek().size()) + 1; + state.laps.mutate([&](std::vector& laps) { + laps.push_back(Lap{.number = number, .seconds = seconds, .sprint = sprint}); + }); + state.lapElapsed.set(0); +} + +// One handler per message; the Store refuses at compile time an update that misses one. +struct WorkoutUpdate { + void operator()(WorkoutState& state, Start const& /*msg*/) const { state.running.set(true); } + void operator()(WorkoutState& state, Pause const& /*msg*/) const { state.running.set(false); } + + void operator()(WorkoutState& state, Tick const& /*msg*/) const { + if (!state.running.peek()) { + return; + } + state.elapsed.set(state.elapsed.peek() + 1); + state.lapElapsed.set(state.lapElapsed.peek() + 1); + state.cooldown.set(std::max(0, state.cooldown.peek() - 1)); + } + + void operator()(WorkoutState& state, MarkLap const& /*msg*/) const { closeLap(state, false); } + + void operator()(WorkoutState& state, Sprint const& /*msg*/) const { + if (!state.running.peek() || state.cooldown.peek() > 0 || state.lapElapsed.peek() == 0) { + return; + } + closeLap(state, true); + state.cooldown.set(kSprintCooldownSeconds); + } + + void operator()(WorkoutState& state, Reset const& /*msg*/) const { + state.running.set(false); + state.elapsed.set(0); + state.lapElapsed.set(0); + state.cooldown.set(0); + state.laps.set({}); + } + + void operator()(WorkoutState& state, AskQuit const& /*msg*/) const { state.confirmingQuit.set(true); } + void operator()(WorkoutState& state, CancelQuit const& /*msg*/) const { state.confirmingQuit.set(false); } + void operator()(WorkoutState& state, Rename const& msg) const { state.rider.set(msg.rider); } + void operator()(WorkoutState& state, ShowTab const& msg) const { state.tab.set(msg.tab); } +}; + +} // namespace + +std::string formatDuration(std::int64_t seconds) { + auto const total = std::max(0, seconds); + auto const hours = total / 3600; + auto const minutes = (total % 3600) / 60; + auto const rest = total % 60; + if (hours > 0) { + return std::format("{}:{:02}:{:02}", hours, minutes, rest); + } + return std::format("{:02}:{:02}", minutes, rest); +} + +std::string lapLine(Lap const& lap) { + return std::format("Lap {} {}{}", lap.number, formatDuration(lap.seconds), lap.sprint ? " sprint" : ""); +} + +WorkoutController::WorkoutController(Runtime& runtime, morph::reactive::Scheduler& scheduler) + : _store{runtime, initialState, WorkoutUpdate{}}, + _status{runtime, + [this] { + auto const& state = _store.state(); + std::string status = state.running.get() ? "Riding" : state.elapsed.get() > 0 ? "Paused" : "Ready"; + if (auto const& rider = state.rider.get(); !rider.empty()) { + status += ", " + rider; + } + return status; + }}, + _elapsed{runtime, [this] { return formatDuration(_store.state().elapsed.get()); }}, + _lap{runtime, [this] { return formatDuration(_store.state().lapElapsed.get()); }}, + _sprintLabel{runtime, + [this] { + auto const cooldown = _store.state().cooldown.get(); + return cooldown > 0 ? std::format("Sprint ({}s)", cooldown) : std::string{"Sprint"}; + }}, + _lapCount{runtime, [this] { return std::to_string(_store.state().laps.get().size()); }}, + _bestLap{runtime, + [this] { + auto const& laps = _store.state().laps.get(); + if (laps.empty()) { + return std::string{"-"}; + } + return formatDuration(std::ranges::min(laps, {}, &Lap::seconds).seconds); + }}, + _averageLap{runtime, + [this] { + auto const& laps = _store.state().laps.get(); + if (laps.empty()) { + return std::string{"-"}; + } + auto const total = std::accumulate(laps.begin(), laps.end(), std::int64_t{0}, + [](std::int64_t sum, Lap const& lap) { return sum + lap.seconds; }); + return formatDuration(total / static_cast(laps.size())); + }}, + _sprintCount{runtime, + [this] { + return std::to_string(std::ranges::count_if(_store.state().laps.get(), &Lap::sprint)); + }}, + _canSprint{runtime, + [this] { + auto const& state = _store.state(); + return state.running.get() && state.cooldown.get() == 0 && state.lapElapsed.get() > 0; + }}, + _ticker{scheduler.every(kTickPeriod, [this] { _store.send(Tick{}); })} {} + +} // namespace workout::client +``` + +Create `examples/tui/workout/app/workout_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "workout_controller.hpp" + +/// @file +/// @brief The workout's view: bindings to a `WorkoutController`, nothing else. + +namespace workout::client { + +/// @brief Builds the workout's view tree. +/// @param controller The controller every binding reads and every control writes. Borrowed: it must outlive the +/// mounted view. +/// @param ctx The frontend's context; the confirmation's Quit button quits it. Borrowed. +/// @return The root node. +[[nodiscard]] morph::ui::Node workoutView(WorkoutController& controller, morph::ui::AppContext& ctx); + +} // namespace workout::client +``` + +Create `examples/tui/workout/app/workout_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "workout_view.hpp" + +#include +#include +#include +#include +#include + +namespace workout::client { + +namespace { + +namespace ui = morph::ui; + +ui::Node labelled(std::string label, std::function value) { + return ui::row({.children = {ui::text({.text = std::move(label), .role = ui::TextRole::Muted}), + ui::text({.text = std::move(value)})}, + .gap = 2}); +} + +ui::Node dashboard(WorkoutController& controller) { + auto rider = ui::textInput({.value = [&controller] { return controller.state().rider.get(); }, + .onChange = [&controller](std::string name) { controller.send(Rename{.rider = std::move(name)}); }, + .placeholder = "Rider name"}); + auto buttons = ui::row( + {.children = {ui::button({.label = [&controller] { return controller.sprintLabel(); }, + .onClick = controller.action(Sprint{}), + .common = {.enabled = [&controller] { return controller.canSprint(); }}}), + ui::button({.label = "Lap", .onClick = controller.action(MarkLap{})})}, + .gap = 2}); + auto laps = ui::forEach( + controller.state().laps, [](Lap const& lap) { return ui::Key{lap.number}; }, + [](morph::reactive::Signal const& lap) { + return ui::text({.text = [lap = &lap] { return lapLine(lap->get()); }}); + }); + return ui::column({.children = {rider, labelled("Elapsed", [&controller] { return controller.elapsedText(); }), + labelled("This lap", [&controller] { return controller.lapText(); }), buttons, + ui::text({.text = "Laps", .role = ui::TextRole::Heading}), laps}, + .gap = 1}); +} + +ui::Node statistics(WorkoutController& controller) { + return ui::column({.children = {labelled("Laps ridden", [&controller] { return controller.lapCountText(); }), + labelled("Best lap", [&controller] { return controller.bestLapText(); }), + labelled("Average lap", [&controller] { return controller.averageLapText(); }), + labelled("Sprints", [&controller] { return controller.sprintCountText(); }), + labelled("Total", [&controller] { return controller.elapsedText(); })}, + .gap = 1}); +} + +} // namespace + +ui::Node workoutView(WorkoutController& controller, ui::AppContext& ctx) { + auto title = ui::row({.children = {ui::text({.text = "Workout", .role = ui::TextRole::Heading}), + ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::text({.text = [&controller] { return controller.statusText(); }, + .role = ui::TextRole::Muted})}}); + // One table: each item's label and what it does, side by side. + auto actions = ui::menu({.items = {{.label = "Start", .onSelect = controller.action(Start{})}, + {.label = "Pause", .onSelect = controller.action(Pause{})}, + {.label = "Lap", .onSelect = controller.action(MarkLap{})}, + {.label = "Reset", .onSelect = controller.action(Reset{})}, + {.label = "Quit", .onSelect = controller.action(AskQuit{})}}}); + auto pages = ui::tabs({.tabs = {{.label = "Dashboard", .node = dashboard(controller)}, + {.label = "Statistics", .node = statistics(controller)}}, + .selected = [&controller] { return controller.state().tab.get(); }, + .onSelect = [&controller](std::size_t tab) { controller.send(ShowTab{.tab = tab}); }}); + auto confirm = ui::dialog( + {.open = [&controller] { return controller.state().confirmingQuit.get(); }, + .title = "Quit the workout?", + .child = ui::row({.children = {ui::button({.label = "Quit", .onClick = [&ctx] { ctx.quit(0); }}), + ui::button({.label = "Keep riding", .onClick = controller.action(CancelQuit{})})}, + .gap = 2}), + .onDismiss = controller.action(CancelQuit{})}); + return ui::column({.children = {title, ui::row({.children = {actions, pages}, .gap = 2}), confirm}, .gap = 1}); +} + +} // namespace workout::client +``` + +Create `examples/tui/workout/app/workout_application.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// @brief The workout dashboard application. + +namespace workout::client { + +/// @brief Builds the workout application: its controller, ticking on the frontend's scheduler, and its view. +/// @param ctx The frontend's context. +/// @param env The deployment choices; the workout reads none of them. +/// @return The application. +[[nodiscard]] std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env); + +} // namespace workout::client +``` + +Create `examples/tui/workout/app/workout_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "workout_application.hpp" + +#include +#include + +#include "workout_controller.hpp" +#include "workout_view.hpp" + +namespace workout::client { + +namespace { + +class WorkoutApplication final : public morph::ui::Application { +public: + explicit WorkoutApplication(morph::ui::AppContext& ctx) + : _ctx{&ctx}, _controller{ctx.runtime(), ctx.scheduler()} {} + + [[nodiscard]] morph::ui::Node view() override { return workoutView(_controller, *_ctx); } + +private: + morph::ui::AppContext* _ctx; + WorkoutController _controller; +}; + +} // namespace + +std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& /*env*/) { + return std::make_unique(ctx); +} + +} // namespace workout::client +``` + +Create `examples/tui/workout/ui/main.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "workout_application.hpp" + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#endif +#if MORPH_EXAMPLE_HAS_TUI +#include +#endif + +int main(int argc, char** argv) { + try { + auto const env = morph::examples::AppEnvironment::fromArgs(argc, argv); + std::vector built; +#if MORPH_EXAMPLE_HAS_QT_QUICK + built.push_back(morph::qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_EXAMPLE_HAS_TUI + built.push_back(morph::tui::frontendOption()); +#endif + auto const frontend = morph::ui::selectFrontend(built, argc, argv); + return frontend->run( + [&env](morph::ui::AppContext& ctx) { return workout::client::makeApplication(ctx, env); }); + } catch (std::exception const& error) { + std::cerr << "workout: " << error.what() << '\n'; + } catch (...) { + std::cerr << "workout: unknown error\n"; + } + return 1; +} +``` + +In `examples/tui/README.md`, add the row: + +```markdown +| `workout` | A dashboard driven by a `Store` of signal fields and messages: a one-second ticker on the frontend's scheduler, a menu, Dashboard and Statistics tabs, a keyed lap list, a Sprint button whose `enabled` is bound, a rider-name input and a quit confirmation. The shape every migrated example's controller and view follow. | +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/all --target workout workout_tests && ./build/all/examples/tui/workout/workout_tests` +Expected: PASS, 15 test cases; `build/ex-tui` and `build/ex-qt` build and pass with the other frontend's smoke +case skipped. By hand once: `./build/all/examples/tui/workout/workout --ui=tui` — Start from the menu, the clock +runs, Sprint enables after a second, Quit asks first. +Mutation check: in `_canSprint`, drop `&& state.cooldown.get() == 0`. Expected FAIL in "Sprint needs a ride under +way, a lap started and no cooldown" and in the view test "Start from the menu, a tick and Sprint drive the +dashboard" (`enabled` stays `"true"`). Restore. Second: give `_ticker` a discarded handle +(`static_cast(scheduler.every(…))` in the body, `_ticker` default-constructed). Expected FAIL in "the +ticker counts only while riding". Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/tui +git commit -m "wip(examples-common): examples/tui/workout, a Store-driven dashboard with a ticker + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 12: Whole-part verification and the squash + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Strict builds and the full suites, in all three configurations** + +```bash +for dir in build/all build/ex-tui build/ex-qt; do + cmake -S . -B "$dir" | grep 'morph: warnings' + cmake --build "$dir" && ctest --test-dir "$dir" --output-on-failure || break +done +ctest --test-dir build/all -L 'examples-common|examples-tui' --output-on-failure +ctest --test-dir build/all -L ladder -LE stress --output-on-failure +``` + +Expected: each configure prints `morph: warnings: ... strict=ON`; every build and suite passes. The labelled run +lists `examples_common_app_tests`, `gallery_tests` and `workout_tests` cases (check the count is non-zero: +`ctest --test-dir build/all -N -L 'examples-common|examples-tui' | tail -1`); the ladder run shows every rung +unchanged — this part adds targets and changes none. + +- [ ] **Step 2: Sanitizers** (Linux; on macOS use an ASan configure of the same tree) + +```bash +cmake --preset clang-asan -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_NET=ON && cmake --build --preset clang-asan \ + --target examples_common_app_tests gallery_tests workout_tests morph_net_tests +for bin in examples/common/app/tests/examples_common_app_tests examples/tui/gallery/gallery_tests \ + examples/tui/workout/workout_tests tests/net/morph_net_tests; do + bash scripts/check_sanitizer_instrumentation.sh --binary "build/clang-asan/$bin" asan + "./build/clang-asan/$bin" +done +cmake --preset clang-tsan -DMORPH_BUILD_NET=ON && cmake --build --preset clang-tsan --target morph_net_tests examples_common_app_tests +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-tsan/tests/net/morph_net_tests tsan +./build/clang-tsan/tests/net/morph_net_tests "[notifications]" +./build/clang-tsan/examples/common/app/tests/examples_common_app_tests "[transport]" +``` + +Expected: clean. "a connect notification after the connection is gone touches nothing" and the destroyed-poller +case rely on ASan as their observer; the handler hand-off from the I/O loop to the owner (Task 1, Task 5) is what +TSan checks. + +- [ ] **Step 3: clang-tidy over the changed lines** — the recipe in CONTRIBUTING, "Running the `clang-tidy-diff` + gate locally", configured with `-DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT_QUICK=ON` added to its flags so + `examples/common/app`, `examples/tui` and both smoke halves have compile commands; diff base + `origin/master...HEAD`, file count asserted non-zero. + +Expected: no findings. + +- [ ] **Step 4: Docs and install** — `include/morph/net/socket_backend.hpp` changed. + +```bash +cmake -S . -B build/doc -G Ninja -DMORPH_BUILD_DOCUMENTATION=ON -DMORPH_BUILD_TESTS=OFF -DMORPH_BUILD_EXAMPLES=OFF +cmake --build build/doc --target doc +bash scripts/check_install_export.sh +``` + +Expected: both pass (the install check configures `MORPH_BUILD_NET=ON` and compiles every installed header). + +- [ ] **Step 5: WebAssembly** — where an Emscripten toolchain and a wasm Qt kit are available, configure as + `.github/workflows/wasm-ladder.yml` does (`-DMORPH_BUILD_QT=ON -DMORPH_BUILD_LADDER=ON -DMORPH_CLIENT_ONLY=ON + -DMORPH_BUILD_TESTS=OFF -DMORPH_BUILD_EXAMPLES=OFF …`) and build `morph_ladder_app_common`: it compiles the + `EM_JS` query reader and the Qt transport there. Where none is available, say so in the hand-off: the + `wasm-ladder` CI job builds every target of that configure, this one included. + +- [ ] **Step 6: Commit any fixes** + +```bash +git add -A cmake examples/common examples/tui include/morph/net tests/net docs CHANGELOG.md CMakeLists.txt +git commit -m "wip(examples-common): fixes from the sanitizer, tidy, docs and install gates + +Signed-off-by: Christian Parpart " +``` + +Skip the commit if there was nothing to fix, and say so in the hand-off. + +- [ ] **Step 7: Squash this part into its one commit** + +Follow the master plan's "Squashing a part" procedure with key `examples-common` and this message: + +```text +examples/common: Qt-free app environment, transport, poller and test waits; gallery and workout + +morph_ladder_app_common gives every example application a toolkit-free +foundation: AppEnvironment::fromArgs, connect() building the Bridge in-process +or over QtWebSocketBackend (Qt Quick) or morph::net's SocketBackend (the TUI) +with a tracked ready() signal, newUuid, id helpers, a Poller over a +refreshed Query, and the Wiring and mapCompletion every example controller +is built from. The testkit gains pumpUntil, FakeAppContext and a frontend +smoke harness; morph_add_rung learns app/, ui/ and tests/smoke/; examples/tui +holds the gallery and workout. SocketBackend now reports connects and drops. +examples/tui is added after the Tests block, not the Demo block: its tests +need include(Catch) and the testkit target. + +Signed-off-by: Christian Parpart +``` + +The last line of the procedure's output lists, after the commits of Parts 0–5, this commit as +`examples/common: Qt-free app environment, transport, poller and test waits; gallery and workout`. diff --git a/docs/superpowers/plans/2026-10-04-declarative-ui-tui-7-examples-bank.md b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-7-examples-bank.md new file mode 100644 index 000000000..db3233b84 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-7-examples-bank.md @@ -0,0 +1,6909 @@ +# Declarative UI, Part 7 — `examples/bank`: One App, Any Frontend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Replace the bank's QML GUI, its seven QObject controllers and its WebAssembly copy with `bank_app` — a +toolkit-free library of six declarative controllers and their `morph::ui` views — plus one binary, `bank`, that +runs on the terminal or Qt Quick (native) and on Qt Quick alone (WebAssembly, over the in-memory models). + +**Architecture:** Each screen is a controller (a `Store` of user intent, `Query`s keyed on the signed-in state and +the selection, `Mutation`s that invalidate those queries, `Computed` projections that hold every format and +validation) and a view function that only binds. `BankApplication` owns the controllers, a shared `Notices` strip +and the route; `bank::client::makeApplication(ctx, env)` connects through `examples::connect`. The client lives in +`bank::client`, because `bank::app` is the domain library's Qt-free `App` (`include/bank/app/app.hpp`); the directory +`examples/bank/app/` and the target `bank_app` keep their names. `ui/main.cpp` is the composition root of spec 4 §3; +the browser build compiles the same sources against the shadow models in `examples/bank/wasm/`. + +**Tech Stack:** C++23; `morph::reactive` (Part 1), `morph::ui` and `ui::testing::RecordingBackend` (Part 2), +`morph::tui` (Part 3), `morph::qt_quick` (Part 4), `examples/common`'s app layer, `FakeAppContext`, test waits and +smoke harness (Part 6); bank's +`bank_lib` (Lightweight ORM over SQLite/ODBC); Catch2 v3; Emscripten + Qt for WebAssembly. + +**Spec:** `docs/superpowers/specs/2026-10-04-examples-migration-design.md` (spec 4) §1, §2, §3, §5 (bank), §6, §7, +§8, §9; `docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md` (spec 1) §4b (controllers, and the +`TransactionController` A/B stale-selection race). + +This is **Part 7 of 11** of the declarative-UI program. Read the master plan +`docs/superpowers/plans/2026-10-04-declarative-ui-tui.md` first: it fixes the branch, the commit layout and the +`wip(...)` commit convention every task below follows, and this part ends by squashing its `wip(bank)` commits +into one commit, `examples/bank: one app, any frontend`. Parts 1–6 have landed. + +## Global Constraints + +- Part 1's constraints apply unchanged (SPDX line first, `#pragma once`, clang-tidy naming, present-tense comments + with no history or issue numbers, sign-off trailer), except the two that concern `include/morph` only: bank code + is not in the Doxygen input (`docs/CMakeLists.txt` reads `include/morph` alone) and is not strict-warning code. +- `bank_app`, like `bank_lib`, is compiled **without** `apply_warnings()`: its controllers include the model + headers, which include the Lightweight ORM's, and those are not `-Werror` clean. clang-tidy-diff still applies. +- `bank_app` links `morph::morph`, `bank_lib` and `morph::ladder_app_common` and nothing else: no Qt, no + `morph::tui`, no `morph::qt_quick`. A configure-time check of its link set and `app/toolkit_free.cpp`'s + `#error` enforce it (spec 4 §7 rule 1). +- Controllers include nothing from `morph::ui`; a view function includes `morph/ui/view.hpp`, its controller and + `views/style.hpp`, and holds no conditional, format or validation (spec 4 §7 rule 2). `views/style.hpp`'s four + adapters (tone → role, choices → options, id ↔ key) are the only translation, because a controller cannot name + a `ui` type. +- A controller's members are declared in this order: borrowed pointers, `Store`, `Query`s (a `Computed` a later + query's key reads sits directly before that query, because the query's effect reads it in the constructor), + `Mutation`s, `Computed` projections, `FailureReporter`, and a `CallbackScope` last. +- Every failure reaches the user through the shared `Notices` (`reactive::errorMessage`), never through a + per-controller error string; every confirmation (`Transaction posted`, `Bill paid`) too. +- A button whose input is invalid is disabled (`common.enabled` bound to the controller's `can…()` Computed), and + the controller's command refuses the same input (spec 1 §4b `action(make)`): the two agree by construction. +- Tests never sleep: they wait through `settle()` / `drain()` / `awaitReply()` (Task 1), which are + `examples::testing::pumpUntil` / `awaitOn` over Part 6's `FakeAppContext`'s `MainThreadExecutor` with a 10 s + budget. +- Client code lives in `namespace bank::client` (`bank::client::views` for the views); `bank::app` is the domain + library's `App`. Parameters and locals are at least three characters long (clang-tidy + `readability-identifier-length`; only `i j k x y n N fn cb op` and `lk` are exempt). +- Every bank target carries the `if(DEFINED AF_SANITIZER) apply_sanitizers(...)` block: an instrumented `bank_lib` + with an uninstrumented executable does not link (`examples/bank/CMakeLists.txt`'s own measurement). +- Bank lists its sources and test sources by hand in `examples/bank/CMakeLists.txt`; nothing is globbed. +- Example-only change: no `CHANGELOG.md` entry, no `docs/spec/` file (CONTRIBUTING, "The changelog"). + +## Review Focus + +1. **A late history reply for the previously selected account** must not replace the history of the account now + selected — the defect spec 1 §4b names (Task 4 test "a late reply for the previously selected account does not + replace the shown history"). +2. **The refresh a deposit causes** must keep the user's selection, so the next deposit lands in the account the + picker names — the property the old live-engine QML test held (Task 4 test "the selection survives the refresh + a deposit causes, and the next deposit lands in the shown account"). +3. **Signing out and in again as the same user** produces a reply identical to the previous one; adoption must + not depend on the reply differing (Task 2 test "signing out and in again as the same user signs in"). +4. **An amount outside the int64 minor-unit range, `inf` or `nan`** must be rejected before it is rounded — + rounding it is undefined behaviour (Task 1 test "parseMinor rejects amounts that do not fit in int64 minor + units"). +5. **Signing out** must leave nothing of the previous user on screen: every query keyed on the session goes idle + and clears (Task 3 test "signing out empties the list and the total returns to —"). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `examples/bank/app/tone.hpp` | `Tone` — status/amount emphasis without toolkit colours | +| `examples/bank/app/choice.hpp` | `Choice` (id + label for a picker), `resolveChoice` (the user's pick, else the first row) | +| `examples/bank/app/format.hpp` | `fmt::` — `money`, `accountKind`, `txnKind`, `last4`, `trimmed`, `parseMinor`, `parseWhole` | +| `examples/bank/app/notices.hpp`, `.cpp` | `Notice`, `Notices` (the shared strip, hidden after 3.2 s), `FailureReporter`, `confirmed()` | +| `examples/bank/app/bank_handlers.hpp`, `.cpp` | `BankHandlers` — one `BridgeHandler` per model the client calls | +| `examples/bank/app/local_setup.hpp`, `.cpp` | `databaseConnection(--db)`, `localSetup()` (native: migrate the SQLite database) | +| `examples/bank/app/toolkit_free.cpp` | The compile-time guard: a Qt module on `bank_app`'s compile line is an `#error` | +| `examples/bank/app/account_choices.hpp` | `openAccountChoices()` — the open accounts as picker choices | +| `examples/bank/app/controllers/auth_controller.hpp`, `.cpp` | `login::` Store, `AuthController` | +| `examples/bank/app/controllers/accounts_controller.hpp`, `.cpp` | `opening::` Store, `AccountCard`, `AccountsController` | +| `examples/bank/app/controllers/transactions_controller.hpp`, `.cpp` | `movemoney::` Store, `HistoryRow`, `HistoryFetch`, `TransactionsController` | +| `examples/bank/app/controllers/cards_controller.hpp`, `.cpp` | `issuing::` Store, `CardRow`, `CardsController` | +| `examples/bank/app/controllers/payees_controller.hpp`, `.cpp` | `payee::` Store, `PayeeRow`, `PayeesController` | +| `examples/bank/app/controllers/loans_controller.hpp`, `.cpp` | `lending::` Store, `LoanRow`, `InstallmentRow`, `LoansController` | +| `examples/bank/app/views/style.hpp` | `roleOf`, `optionsOf`, `keyOf`, `accountKeyOf`, `idOf` | +| `examples/bank/app/views/*_view.hpp`, `.cpp` | `loginView`, `accountsView`, `moveMoneyView`, `cardsView`, `payeesView`, `loansView`, `shellView`, `appView`, `noticeView` | +| `examples/bank/app/demo_seed.hpp`, `.cpp` | `DemoSeed` — signs in as `demo`, creating it with two EUR accounts on first use | +| `examples/bank/app/bank_application.hpp`, `.cpp` | `Route`, `Screen`, `DemoData`, `BankApplication`, `makeApplication` | +| `examples/bank/ui/main.cpp` | The composition root (spec 4 §3), native and WebAssembly | +| `examples/bank/wasm/` (moved from `gui_wasm/`) | Shadow model headers, `bank::wasm` store, in-memory models, `local_setup_wasm.cpp`, the browser target | +| `examples/bank/tests/app/test_app_context.hpp` | `settle`, `drain`, `awaitReply` over Part 6's `FakeAppContext` (no Lightweight) | +| `examples/bank/tests/app/bank_app_test_support.hpp` | `localEnvironment`, `signUp`, `BankWiring` (a local bridge, handlers, notices, auth) | +| `examples/bank/tests/app/recorded.hpp` | `RecordingBackend` name helpers, `mount`, `checkGolden` | +| `examples/bank/tests/app/test_*.cpp`, `golden/*.txt` | Controller and view tests per screen; tree-dump goldens | +| `examples/bank/tests/inmemory/test_inmemory_models.cpp` | The app over the WebAssembly model set, natively | +| `examples/bank/tests/smoke/test_bank_frontend_smoke.cpp` | `bank_smoke_tests`, a binary of its own: mounts and quits on each built frontend | +| `examples/bank/CMakeLists.txt` | `BANK_APP_SOURCES`, `bank_app`, `bank`, the three new test binaries, the WASM branch | +| Removed: `examples/bank/gui/`, `examples/bank/tests/gui/`, `MORPH_BUILD_BANK_GUI` | The QML client, its tests and its option | +| `CMakeLists.txt`, `CMakePresets.json`, `.github/workflows/{ci,nightly-slow-checks,wasm-demo}.yml`, `scripts/check_coverage_objects.sh` | Option and target renames | +| `examples/bank/README.md`, `examples/TESTING.md`, `examples/LADDER.md`, `README.md` | The client section and every sentence the removal falsifies | + +## Build and test commands (used by every task) + +```bash +cmake -S . -B build/bank -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_NET=ON # once +cmake --build build/bank --target bank_app_tests +./build/bank/examples/bank/bank_app_tests "[bank][app]" +``` + +The bank example needs unixODBC and the SQLite3 ODBC driver (`examples/bank/README.md`, "Build & run"). +Configuring prints `morph: warnings: ... strict=ON`; if it does not, stop and say so (CONTRIBUTING). + +### What this plan takes from Part 6 and Part 2 + +Part 6 (`2026-10-04-declarative-ui-tui-6-examples-foundation.md`) defines every name below; Task 1 Step 0 confirms +them in the tree before anything is built on them. + +1. Target `morph_ladder_app_common` / `morph::ladder_app_common` (Part 6 Task 2), in `examples/common/app/`, added + once by the root `CMakeLists.txt` whenever the ladder, the bank example or `examples/tui` is on. Its interface is + Qt-free (the Qt and `morph::net` transports are PRIVATE), and its PUBLIC include root is `examples/common`, so + headers are `` and ``. Bank links it by name, never behind `if(TARGET …)`. +2. `app/app_environment.hpp`: `AppEnvironment{server, db, user, seed, pollId}`, `fromArgs(argc, argv)`. `db` is + empty by default, and bank maps empty to its own default file in the temp directory + (`databaseConnection("")`). A non-empty `user` becomes the bridge's default session (`connect` installs it). +3. `app/transport.hpp`: `TransportError`, `LocalSetup{setupDatabase, workers}`, `Link{Local, Remote}`, + `Connection{bridge(), callbacks(), ready(), link()}` and `connect(ctx, env, LocalSetup)`. `ready()` is a tracked + `Signal`, true at once for a local link and true for a remote one only while its transport is up; bank's + demo seed waits for it (Task 8). `app/uuid.hpp`: `newUuid()`. +4. Header-only `testkit/wait.hpp`: `pumpUntil(exec::MainThreadExecutor&, Pred, budget = 2000ms)` and + `awaitOn(exec::MainThreadExecutor&, Completion, budget = 2000ms) -> T`, budgets scaled by + `MORPH_LADDER_DEADLINE_MS`. Header-only `testkit/fake_app_context.hpp`: `FakeAppContext(frontendName = "test", + ioLoop = nullptr)` with `owner()`, `manualScheduler()`, `quitCode()`. `bank_app_tests` and `bank_inmemory_tests` + reach both through `morph::ladder_app_common`'s include root and link no frontend. +5. The smoke harness `testkit/frontend_smoke.hpp` (`SmokeFrontend`, `smokeRun`, `runFrontendSmoke`, which SKIPs a + frontend this configure did not build) is compiled into the STATIC target `morph_example_testkit` / + `morph::example_testkit`, which links every built frontend PUBLIC and exists when `MORPH_BUILD_TESTS` is on + outside Emscripten. `qt_quick::Frontend::run` constructs its own `QGuiApplication`, so smoke tests run from a + binary of their own whose `main` is `morph_test_main` — for bank, `bank_smoke_tests` (Task 9). +6. CMake `morph_example_frontends( )` (`cmake/morph_example_app.cmake`) links each built frontend and + defines `MORPH_EXAMPLE_HAS_TUI` / `MORPH_EXAMPLE_HAS_QT_QUICK` to 0 or 1. The `bank` binary uses it directly + rather than `morph_add_example_ui`, because that helper calls `apply_warnings()` and bank's targets cannot: the + model headers include the Lightweight ORM's, which are not `-Werror` clean. + +Part 2's `RecordingBackend` is read through `tests/app/recorded.hpp`, in Part 2's spellings: kinds `Text`, `Button`, +`TextInput`, `Select`, `Menu` (a stack is `Column` or `Row` by its axis, a `Switch` is a `Slot`, a vertical +`forEach` a `Column`, a table a `Table` of `Row`s); properties `text`, `label`, `placeholder`, `enabled`, +`visible`; booleans print as `true`/`false`, and `enabled`/`visible` are set only when bound or `false`; +`chooseIndex` activates a `Menu` entry, and a hidden or disabled widget ignores every interaction helper. Task 2 +Step 0 confirms them against the header. + +--- + +### Task 1: `bank_app` — formatting, notices, handlers, the local database and the toolkit-free guard + +**Files:** +- Create: `examples/bank/app/tone.hpp`, `examples/bank/app/choice.hpp`, `examples/bank/app/format.hpp` +- Create: `examples/bank/app/notices.hpp`, `examples/bank/app/notices.cpp` +- Create: `examples/bank/app/bank_handlers.hpp`, `examples/bank/app/bank_handlers.cpp` +- Create: `examples/bank/app/local_setup.hpp`, `examples/bank/app/local_setup.cpp`, + `examples/bank/app/toolkit_free.cpp` +- Create: `examples/bank/tests/app/test_app_context.hpp` +- Modify: `examples/bank/CMakeLists.txt` — three places: (a) directly after the `if(NOT TARGET morph::morph) … endif()` + block and before `if(EMSCRIPTEN)`, the app-layer subdirectory and `BANK_APP_SOURCES`; (b) after the + `# ── CLI driver` block (ending `apply_sanitizers(bank_cli …)` / `endif()`), the `bank_app` library; (c) inside + `if(MORPH_BUILD_TESTS)` … `else()`, directly after `catch_discover_tests(bank_tests … PROPERTIES LABELS "bank")`, + the `bank_app_tests` executable +- Test: `examples/bank/tests/app/test_format.cpp`, `examples/bank/tests/app/test_notices.cpp` + +**Interfaces:** +- Consumes: `morph::reactive::{Runtime, Signal, Effect, Scheduler, TimerHandle, errorMessage}` and + `reactive::testing::ManualScheduler{advance, pendingTimers}` (Part 1); `morph::ui::{AppContext, Scheduler}` + (Part 2, `ui/frontend.hpp`); `morph::examples::LocalSetup{setupDatabase, workers}` (`app/transport.hpp`), + `morph::examples::testing::pumpUntil(exec::MainThreadExecutor&, Pred, std::chrono::milliseconds)` and + `awaitOn(exec::MainThreadExecutor&, async::Completion, std::chrono::milliseconds) -> T` (`testkit/wait.hpp`), + `morph::examples::testing::FakeAppContext{owner(), manualScheduler(), quitCode()}` + (`testkit/fake_app_context.hpp`) (Part 6); `bank::format(Money)`, `bank::pow10i`, `bank::AccountKind`, `bank::TxnKind` + (`include/bank/core/{money,types}.hpp`); `bank::db::setup(std::string const&)` (`include/bank/db/database.hpp`); + `morph::bridge::BridgeHandler::execute`, `async::Completion::then(CallbackScope const&, …)` + (`include/morph/core/{bridge,completion}.hpp`). +- Produces (every later task uses these exact names, all in `namespace bank::client`): + - `enum class Tone : std::uint8_t { Neutral, Good, Warn, Bad }`. + - `struct Choice { std::int64_t id; std::string label; }` (equality-comparable); + `template std::int64_t resolveChoice(std::vector const&, std::int64_t chosen)`. + - `fmt::trimmed(std::string_view)`, `fmt::money(std::int64_t, int)`, `fmt::accountKind(int)`, `fmt::txnKind(int)`, + `fmt::last4(std::string_view)`, `fmt::kMinorUnitsBound`, `fmt::parseMinor(std::string_view, int = 2) -> + std::optional`, `fmt::parseWhole(std::string_view) -> std::optional`. + - `struct Notice { std::string text; Tone tone; }`; `class Notices { static constexpr kShownFor; Notices(Runtime&, + Scheduler&); error(std::string); success(std::string); dismiss(); visible() -> bool; text() -> std::string + const&; tone() -> Tone; }` (reads tracked). + - `class FailureReporter { using Source = std::function; FailureReporter(Runtime&, Notices&, + std::vector); }`. + - `template confirmed(BridgeHandler&, Notices&, async::CallbackScope + const&, std::string text) -> std::function(Action)>`. + - `struct BankHandlers { BankHandlers(bridge::Bridge&, exec::IExecutor&); auth, customers, transactions, cards, + payees, payments, loans; }`. + - `databaseConnection(std::string_view) -> std::string`, `localSetup() -> morph::examples::LocalSetup`. + - Test support (`namespace bank::testing`): `using morph::examples::testing::FakeAppContext`, `kSettleBudget`, + `settle(FakeAppContext&, Pred) -> bool`, `drain(FakeAppContext&)`, `awaitReply(FakeAppContext&, + async::Completion) -> T`. + - CMake: `BANK_APP_SOURCES` (absolute paths), targets `bank_app`, `bank_app_tests`, definition `BANK_GOLDEN_DIR`. + +- [ ] **Step 0: Confirm what this part takes from Part 6** + +```bash +grep -n 'add_library(morph_ladder_app_common\|add_library(morph::ladder_app_common\|add_library(morph_example_testkit' \ + examples/common/app/CMakeLists.txt +grep -n 'target_link_libraries(morph_ladder_app_common' examples/common/app/CMakeLists.txt +grep -n 'struct AppEnvironment\|struct LocalSetup\|enum class Link\|ready() const\|connect(ui::AppContext' \ + examples/common/app/app_environment.hpp examples/common/app/transport.hpp +grep -n 'newUuid' examples/common/app/uuid.hpp +grep -n 'bool pumpUntil(exec::MainThreadExecutor\|T awaitOn(' examples/common/testkit/wait.hpp +grep -n 'class FakeAppContext\|manualScheduler()\|quitCode()' examples/common/testkit/fake_app_context.hpp +grep -n 'void runFrontendSmoke\|SmokeResult smokeRun' examples/common/testkit/frontend_smoke.hpp +grep -n 'function(morph_example_frontends' cmake/morph_example_app.cmake +``` + +Expected: the first `grep` prints the three `add_library` lines; the second prints `PUBLIC morph::morph`, +`PRIVATE morph::qt morph_qt_impl Qt6::Core` and `PRIVATE morph::net` (the transports are PRIVATE, so no Qt compile +definition or include reaches `bank_app`); every other `grep` prints each name it names. +These are the names "What this plan takes from Part 6 and Part 2" lists; a missing one means Part 6 has not landed +as planned — stop and say so rather than substituting. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/bank/tests/app/test_app_context.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +/// @file +/// The bank's waits over Part 6's headless `FakeAppContext`, with the budget the bank's models need. +/// Nothing here includes Lightweight, so the in-memory model tests use it as well. + +namespace bank::testing { + +/// @brief The headless `ui::AppContext` every bank controller and view test builds on: a `MainThreadExecutor` +/// the test pumps (`owner()`), the `Runtime` on it, and a `ManualScheduler` (`manualScheduler()`). +using morph::examples::testing::FakeAppContext; + +/// @brief How long a test waits for the bank's models before it fails. +inline constexpr std::chrono::milliseconds kSettleBudget{10000}; + +/// @brief Pumps the owner until @p done holds or `kSettleBudget` runs out. +/// @tparam Pred A predicate taking no arguments. +/// @param ctx The test's context. +/// @param done The condition to wait for. +/// @return Whether @p done held in time. +template +[[nodiscard]] bool settle(FakeAppContext& ctx, Pred done) { + return morph::examples::testing::pumpUntil(ctx.owner(), std::move(done), kSettleBudget); +} + +/// @brief Runs everything already posted to the owner, and the flush it requests, then returns. +/// @param ctx The test's context. +inline void drain(FakeAppContext& ctx) { + auto const reached = std::make_shared(false); + ctx.owner().post([reached] { *reached = true; }); + REQUIRE(settle(ctx, [&ctx, reached] { return *reached && !ctx.runtime().isFlushRequested(); })); +} + +/// @brief Waits for @p reply by pumping the owner, and returns its value. +/// @tparam T The reply's type. +/// @param ctx The test's context; @p reply must deliver on its owner. +/// @param reply A call made behind every controller's back. +/// @return The value; rethrows the call's failure, and throws when `kSettleBudget` runs out. +template +T awaitReply(FakeAppContext& ctx, morph::async::Completion reply) { + return morph::examples::testing::awaitOn(ctx.owner(), std::move(reply), kSettleBudget); +} + +} // namespace bank::testing +``` + +Create `examples/bank/tests/app/test_format.cpp` — `Format.hpp`'s `parseMinor` cases ported unchanged, plus the +helpers it lost nothing of: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// fmt::parseMinor turns arbitrary user text into an integer, so it has to survive arbitrary text. +// The out-of-range cases assert rejection rather than a value: converting an out-of-range double to +// an integer is undefined behaviour, which a UBSan build reports as an abort and other builds as a +// wrong number, and only "rejected" fails on both. + +#include +#include +#include + +#include "format.hpp" +#include "local_setup.hpp" + +using bank::client::fmt::parseMinor; +using bank::client::fmt::parseWhole; + +TEST_CASE("parseMinor turns well-formed amounts into minor units", "[bank][app][format]") { + CHECK(parseMinor("12.34") == 1234); + CHECK(parseMinor("0") == 0); + CHECK(parseMinor(" 7.5 ") == 750); + // Half a minor unit rounds away from zero. + CHECK(parseMinor("0.005") == 1); + // The decimals come from the selected currency; JPY has none. + CHECK(parseMinor("1200", 0) == 1200); +} + +TEST_CASE("parseMinor rejects text that is not a non-negative amount", "[bank][app][format]") { + CHECK_FALSE(parseMinor("").has_value()); + CHECK_FALSE(parseMinor("abc").has_value()); + CHECK_FALSE(parseMinor("-1.00").has_value()); + CHECK_FALSE(parseMinor("12abc").has_value()); + CHECK_FALSE(parseMinor("1 2").has_value()); + CHECK_FALSE(parseMinor("0x10").has_value()); + CHECK_FALSE(parseMinor("1e").has_value()); +} + +// Review Focus 4. +TEST_CASE("parseMinor rejects amounts that do not fit in int64 minor units", "[bank][app][format]") { + CHECK_FALSE(parseMinor("1e30").has_value()); + CHECK_FALSE(parseMinor("1e300").has_value()); + // Not an absurd magnitude: above ~9.2e16 major units, scaling by 100 overflows. + CHECK_FALSE(parseMinor("9.3e16").has_value()); + CHECK_FALSE(parseMinor("inf").has_value()); + CHECK_FALSE(parseMinor("nan").has_value()); + // The scale decides the ceiling: what overflows at two decimals fits at none. + CHECK_FALSE(parseMinor("1e17", 2).has_value()); + CHECK(parseMinor("1e17", 0).has_value()); +} + +TEST_CASE("parseMinor's ceiling is the int64 range, not an arbitrary cap", "[bank][app][format]") { + // 9.2e16 major units scale to 9.2e18 minor, just inside 2^63-1. + CHECK(parseMinor("92000000000000000") == 9200000000000000000LL); + CHECK_FALSE(parseMinor("920000000000000000").has_value()); +} + +TEST_CASE("parseMinor rounds a value just below half a minor unit down", "[bank][app][format]") { + // Scales to 0.49999999999999994, the largest double below 0.5: `+ 0.5` and a truncation give 1, + // std::llround gives 0. + CHECK(parseMinor("0.004999999999999999") == 0); + CHECK(parseMinor("0.0049999999999999994") == 0); + CHECK_FALSE(parseMinor("1e30").has_value()); +} + +TEST_CASE("parseWhole accepts whole numbers only", "[bank][app][format]") { + CHECK(parseWhole("600") == 600); + CHECK(parseWhole(" 12 ") == 12); + CHECK(parseWhole("0") == 0); + CHECK(parseWhole("-3") == -3); + CHECK_FALSE(parseWhole("").has_value()); + CHECK_FALSE(parseWhole("12abc").has_value()); + CHECK_FALSE(parseWhole("1.5").has_value()); +} + +TEST_CASE("the display helpers name what the screens show", "[bank][app][format]") { + using namespace bank::client::fmt; + CHECK(money(1234, 0) == "12.34 USD"); + CHECK(money(-5, 1) == "-0.05 EUR"); + CHECK(money(1200, 4) == "1200 JPY"); + CHECK(accountKind(1) == "Savings"); + CHECK(accountKind(9) == "Account"); + CHECK(txnKind(2) == "Transfer in"); + CHECK(txnKind(7) == "Loan in"); + CHECK(txnKind(99) == "Entry"); + CHECK(last4("DE00500700100200300400") == "•••• 0400"); + CHECK(last4("12") == "•••• 12"); + CHECK(trimmed(" a b \t") == "a b"); +} + +TEST_CASE("databaseConnection names the database --db gives", "[bank][app][format]") { + using bank::client::databaseConnection; + std::string const fallback = databaseConnection(""); + CHECK(fallback.starts_with("DRIVER=SQLite3;Database=")); + CHECK(fallback.ends_with("morph_bank.db")); + CHECK(databaseConnection("/tmp/b.db") == "DRIVER=SQLite3;Database=/tmp/b.db"); + CHECK(databaseConnection("DRIVER=SQLite3;Database=x.db;Timeout=5000") == + "DRIVER=SQLite3;Database=x.db;Timeout=5000"); +} +``` + +Create `examples/bank/tests/app/test_notices.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "notices.hpp" +#include "test_app_context.hpp" + +using bank::client::FailureReporter; +using bank::client::Notices; +using bank::client::Tone; +using bank::testing::FakeAppContext; +using std::chrono::milliseconds; + +TEST_CASE("Notices: an error shows, then hides after its period", "[bank][app][notices]") { + FakeAppContext ctx; + Notices notices{ctx.runtime(), ctx.scheduler()}; + CHECK_FALSE(notices.visible()); + notices.error("boom"); + CHECK(notices.visible()); + CHECK(notices.text() == "boom"); + CHECK(notices.tone() == Tone::Bad); + ctx.manualScheduler().advance(Notices::kShownFor - milliseconds{1}); + CHECK(notices.visible()); + ctx.manualScheduler().advance(milliseconds{1}); + CHECK_FALSE(notices.visible()); + CHECK(notices.text() == "boom"); // the strip hides; the text stays +} + +TEST_CASE("Notices: a second notice restarts the period", "[bank][app][notices]") { + FakeAppContext ctx; + Notices notices{ctx.runtime(), ctx.scheduler()}; + notices.error("first"); + ctx.manualScheduler().advance(milliseconds{3000}); + notices.success("second"); + ctx.manualScheduler().advance(milliseconds{3000}); + CHECK(notices.visible()); + CHECK(notices.text() == "second"); + CHECK(notices.tone() == Tone::Good); + ctx.manualScheduler().advance(milliseconds{200}); + CHECK_FALSE(notices.visible()); +} + +TEST_CASE("Notices: dismiss hides at once and cancels the pending hide", "[bank][app][notices]") { + FakeAppContext ctx; + Notices notices{ctx.runtime(), ctx.scheduler()}; + notices.error("gone"); + notices.dismiss(); + CHECK_FALSE(notices.visible()); + CHECK(ctx.manualScheduler().pendingTimers() == 0); +} + +TEST_CASE("FailureReporter: each new failure becomes one error notice", "[bank][app][notices]") { + FakeAppContext ctx; + Notices notices{ctx.runtime(), ctx.scheduler()}; + morph::reactive::Signal failure{ctx.runtime(), nullptr}; + FailureReporter const reporter{ctx.runtime(), notices, {[&failure] { return failure.get(); }}}; + CHECK_FALSE(notices.visible()); + + failure.set(std::make_exception_ptr(std::runtime_error{"first"})); + bank::testing::drain(ctx); + CHECK(notices.visible()); + CHECK(notices.text() == "first"); + + notices.dismiss(); + failure.set(nullptr); + bank::testing::drain(ctx); + CHECK_FALSE(notices.visible()); + + failure.set(std::make_exception_ptr(std::runtime_error{"second"})); + bank::testing::drain(ctx); + CHECK(notices.visible()); + CHECK(notices.text() == "second"); +} +``` + +In `examples/bank/CMakeLists.txt`, place (c) — the test binary, after `catch_discover_tests(bank_tests …)`: + +```cmake + # ── bank_app_tests: the client's controllers and views, headless ───── + # Links bank_app and no frontend: controllers run over a local bridge on + # examples/common's FakeAppContext, and views mount on + # ui::testing::RecordingBackend. The goldens under tests/app/golden are + # the views' tree dumps. + add_executable(bank_app_tests + tests/app/test_format.cpp + tests/app/test_notices.cpp + ) + target_include_directories(bank_app_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/tests + ${CMAKE_CURRENT_SOURCE_DIR}/tests/app) + target_link_libraries(bank_app_tests PRIVATE bank_app morph_test_main) + target_compile_definitions(bank_app_tests PRIVATE + BANK_GOLDEN_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/app/golden") + target_compile_features(bank_app_tests PRIVATE cxx_std_23) + apply_bigobj(bank_app_tests) + if(DEFINED AF_SANITIZER) + apply_sanitizers(bank_app_tests ${AF_SANITIZER}) + endif() + catch_discover_tests(bank_app_tests + DISCOVERY_MODE PRE_TEST + PROPERTIES LABELS "bank") +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake -S . -B build/bank && cmake --build build/bank --target bank_app_tests` +Expected: FAIL — `fatal error: 'format.hpp' file not found` (and `'notices.hpp' file not found`). + +- [ ] **Step 3: Implement** + +Create `examples/bank/app/tone.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +/// @file +/// How a status, an amount or a notice is emphasised, named without a toolkit's colours; a view +/// maps it to a text role. + +namespace bank::client { + +/// @brief The emphasis a value carries on screen. +enum class Tone : std::uint8_t { + Neutral, ///< nothing to signal + Good, ///< open, active, paid off, money in, an action that went through + Warn, ///< frozen: usable again later + Bad, ///< cancelled, money out, a failure +}; + +} // namespace bank::client +``` + +Create `examples/bank/app/choice.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// One entry of a picker, and how a picker's selection follows a list that refreshes. + +namespace bank::client { + +/// @brief One pickable entry: the id the controller acts on and the label the user reads. +struct Choice { + std::int64_t id = 0; ///< the entity id, or the enum value for a fixed list + std::string label; ///< display text + bool operator==(Choice const&) const = default; +}; + +/// @brief The row the user chose while it is still listed, else the first row, else 0. +/// +/// A picker names its first entry until the user picks one, and a refresh that replaces the rows +/// keeps the pick as long as it is still among them. +/// @tparam Row Anything with an `id` member. +/// @param rows The rows as listed now. +/// @param chosen The user's pick; 0 for none. +/// @return The id the screen acts on. +template +[[nodiscard]] std::int64_t resolveChoice(std::vector const& rows, std::int64_t chosen) { + for (Row const& row : rows) { + if (row.id == chosen) { + return chosen; + } + } + return rows.empty() ? 0 : rows.front().id; +} + +} // namespace bank::client +``` + +Create `examples/bank/app/format.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/core/money.hpp" +#include "bank/core/types.hpp" + +/// @file +/// Display text and amount parsing for the bank's screens. Controllers hand views display-ready +/// strings, so no view does currency arithmetic. + +namespace bank::client::fmt { + +/// @brief @p text without its leading and trailing ASCII whitespace. +/// @param text Any text. +/// @return A view into @p text. +[[nodiscard]] inline std::string_view trimmed(std::string_view text) noexcept { + constexpr std::string_view kSpace = " \t\n\r\f\v"; + std::size_t const first = text.find_first_not_of(kSpace); + if (first == std::string_view::npos) { + return {}; + } + return text.substr(first, text.find_last_not_of(kSpace) - first + 1); +} + +/// @brief An amount as display text, e.g. `1234` in USD as `"12.34 USD"`. +/// @param minor The amount in minor units. +/// @param currency A `bank::Currency` value. +/// @return The text. +[[nodiscard]] inline std::string money(std::int64_t minor, int currency) { + return bank::format(bank::Money{.minor = minor, .currency = static_cast(currency)}); +} + +/// @brief An account kind's display name. +/// @param kind A `bank::AccountKind` value. +/// @return The name; `"Account"` for a value the enum does not name. +[[nodiscard]] inline std::string accountKind(int kind) { + switch (static_cast(kind)) { + case bank::AccountKind::Checking: + return "Checking"; + case bank::AccountKind::Savings: + return "Savings"; + case bank::AccountKind::Credit: + return "Credit"; + } + return "Account"; +} + +/// @brief A ledger entry's display name. +/// @param kind A `bank::TxnKind` value. +/// @return The name; `"Entry"` for a value the enum does not name. +[[nodiscard]] inline std::string txnKind(int kind) { + switch (static_cast(kind)) { + case bank::TxnKind::Deposit: + return "Deposit"; + case bank::TxnKind::Withdrawal: + return "Withdrawal"; + case bank::TxnKind::TransferIn: + return "Transfer in"; + case bank::TxnKind::TransferOut: + return "Transfer out"; + case bank::TxnKind::Payment: + return "Payment"; + case bank::TxnKind::Fee: + return "Fee"; + case bank::TxnKind::Interest: + return "Interest"; + case bank::TxnKind::LoanDisbursement: + return "Loan in"; + case bank::TxnKind::LoanRepayment: + return "Loan repay"; + case bank::TxnKind::CardPurchase: + return "Card"; + case bank::TxnKind::Exchange: + return "Exchange"; + } + return "Entry"; +} + +/// @brief An account number masked to its last four characters, e.g. `"•••• 0400"`. +/// @param number The full number. +/// @return The masked text. +[[nodiscard]] inline std::string last4(std::string_view number) { + std::size_t const keep = number.size() < 4 ? number.size() : 4; + return "•••• " + std::string{number.substr(number.size() - keep)}; +} + +/// @brief 2^63, the first `double` that no longer fits in a `std::int64_t`. +/// +/// `std::numeric_limits::max()` is 2^63-1, which a `double` cannot represent: +/// converting it rounds up to 2^63, so a bound written from it is off by one in the unsafe +/// direction. 2^63 is exact. +inline constexpr double kMinorUnitsBound = 0x1p63; + +/// @brief Parses a user-entered amount in major units into minor units. +/// +/// Accepts a plain decimal number — digits, a sign, a decimal point, an exponent — read in the +/// classic locale whatever the process locale is (Qt sets it from the environment), after +/// trimming whitespace. Returns `nullopt` for anything that is not a non-negative amount whose +/// scaled value fits in `std::int64_t`. The range check bounds the scaled, unrounded value and runs +/// before the rounding, because rounding a value outside `long long` is undefined; it is written +/// negated so a NaN fails it. Rounding is `std::llround` — to nearest, halves away from zero — not +/// `+ 0.5` and a truncation, which rounds the double just below one half up to one. +/// @param text The user's text. +/// @param decimals The target currency's minor-unit digits. +/// @return The amount in minor units, or `nullopt`. +[[nodiscard]] inline std::optional parseMinor(std::string_view text, int decimals = 2) { + std::string_view const number = trimmed(text); + if (number.empty() || number.find_first_not_of("0123456789.eE+-") != std::string_view::npos) { + return std::nullopt; + } + std::istringstream stream{std::string{number}}; + stream.imbue(std::locale::classic()); + double major = 0.0; + stream >> major; + if (stream.fail() || !stream.eof() || major < 0.0) { + return std::nullopt; + } + double const scaled = major * static_cast(bank::pow10i(decimals)); + if (!(scaled < kMinorUnitsBound)) { + return std::nullopt; + } + return static_cast(std::llround(scaled)); +} + +/// @brief Parses a whole number, such as a rate in basis points or a term in months. +/// @param text The user's text; surrounding whitespace is ignored. +/// @return The number, or `nullopt` when @p text is not exactly one integer. +[[nodiscard]] inline std::optional parseWhole(std::string_view text) { + std::string_view const number = trimmed(text); + if (number.empty()) { + return std::nullopt; + } + char const* const last = std::to_address(number.end()); + int value = 0; + auto const [end, error] = std::from_chars(std::to_address(number.begin()), last, value); + if (error != std::errc{} || end != last) { + return std::nullopt; + } + return value; +} + +} // namespace bank::client::fmt +``` + +Create `examples/bank/app/notices.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tone.hpp" + +/// @file +/// The bank's one notice strip: every failure and every confirmation the screens show, held as +/// reactive state and hidden again after a fixed time on the frontend's scheduler. + +namespace bank::client { + +/// @brief One message for the strip. +struct Notice { + std::string text; ///< what the strip says + Tone tone = Tone::Bad; ///< `Bad` for a failure, `Good` for a confirmation + bool operator==(Notice const&) const = default; +}; + +/// @brief The shared notice strip. Every controller reports into the same one. +class Notices { +public: + /// @brief How long a notice stays visible. + static constexpr std::chrono::milliseconds kShownFor{3200}; + + /// @param runtime The runtime. Borrowed: it must outlive this object. + /// @param scheduler Hides a notice after `kShownFor`. Borrowed: it must outlive this object. + Notices(morph::reactive::Runtime& runtime, morph::reactive::Scheduler& scheduler); + + /// @brief Shows @p text as a failure. + void error(std::string text); + /// @brief Shows @p text as a confirmation. + void success(std::string text); + /// @brief Hides the strip now. + void dismiss(); + + /// @brief Whether the strip is showing. Tracked. + [[nodiscard]] bool visible() const; + /// @brief The last notice's text; kept after the strip hides. Tracked. + [[nodiscard]] std::string const& text() const; + /// @brief The last notice's tone. Tracked. + [[nodiscard]] Tone tone() const; + +private: + void show(Notice notice); + + morph::reactive::Runtime* _rt; + morph::reactive::Scheduler* _scheduler; + morph::reactive::Signal _notice; + morph::reactive::Signal _visible; + // Last member, so it is destroyed first: a hide never fires into destroyed signals. + morph::reactive::TimerHandle _hide; +}; + +/// @brief Shows each new failure of each source as an error notice. +/// +/// One effect per source, so a failure in one does not re-report another's standing error. A +/// source reads a tracked `error()` — a `Query`'s or a `Mutation`'s. +class FailureReporter { +public: + /// @brief A tracked read of the latest failure, or null. + using Source = std::function; + + /// @param runtime The runtime. Borrowed. + /// @param notices Receives the failures. Borrowed: it must outlive this object. + /// @param sources What to watch. + FailureReporter(morph::reactive::Runtime& runtime, Notices& notices, std::vector sources); + +private: + std::vector> _effects; +}; + +/// @brief A `Mutation` runner that also shows @p text as a confirmation when a call succeeds. +/// @tparam Action The action. +/// @tparam Model The handler's model. +/// @tparam Sharing The handler's sharing policy. +/// @param handler Executes the call. Borrowed. +/// @param notices Shows the confirmation. Borrowed. +/// @param scope Gates the confirmation; the owning controller's last member. +/// @param text The confirmation. +/// @return The runner to construct the `Mutation` with. +template +[[nodiscard]] std::function::Result>(Action)> +confirmed(morph::bridge::BridgeHandler& handler, Notices& notices, + morph::async::CallbackScope const& scope, std::string text) { + using Result = typename morph::model::ActionTraits::Result; + return [&handler, ¬ices, &scope, text = std::move(text)](Action action) { + morph::async::Completion reply = handler.execute(std::move(action)); + reply.then(scope, [¬ices, text](Result const& /*result*/) { notices.success(text); }); + return reply; + }; +} + +} // namespace bank::client +``` + +Create `examples/bank/app/notices.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "notices.hpp" + +#include + +namespace bank::client { + +Notices::Notices(morph::reactive::Runtime& runtime, morph::reactive::Scheduler& scheduler) + : _rt{&runtime}, _scheduler{&scheduler}, _notice{runtime, Notice{}}, _visible{runtime, false} {} + +void Notices::error(std::string text) { show(Notice{.text = std::move(text), .tone = Tone::Bad}); } + +void Notices::success(std::string text) { show(Notice{.text = std::move(text), .tone = Tone::Good}); } + +void Notices::dismiss() { + _hide.cancel(); + _visible.set(false); +} + +bool Notices::visible() const { return _visible.get(); } + +std::string const& Notices::text() const { return _notice.get().text; } + +Tone Notices::tone() const { return _notice.get().tone; } + +void Notices::show(Notice notice) { + _hide.cancel(); + _rt->batch([&] { + _notice.set(std::move(notice)); + _visible.set(true); + }); + _hide = _scheduler->after(kShownFor, [this] { _visible.set(false); }); +} + +FailureReporter::FailureReporter(morph::reactive::Runtime& runtime, Notices& notices, std::vector sources) { + _effects.reserve(sources.size()); + for (Source& source : sources) { + _effects.push_back(std::make_unique(runtime, [¬ices, source = std::move(source)] { + if (std::exception_ptr const error = source(); error != nullptr) { + notices.error(morph::reactive::errorMessage(error)); + } + })); + } +} + +} // namespace bank::client +``` + +Create `examples/bank/app/bank_handlers.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "bank/models/auth_model.hpp" +#include "bank/models/card_model.hpp" +#include "bank/models/customer_model.hpp" +#include "bank/models/loan_model.hpp" +#include "bank/models/payee_model.hpp" +#include "bank/models/payment_model.hpp" +#include "bank/models/transaction_model.hpp" + +/// @file +/// One bridge handler per model the client calls, shared by every controller. Including the +/// model headers here is also what registers those models in any binary that links the app. + +namespace bank::client { + +/// @brief The client's bridge handlers, all delivering on the frontend's executor. +struct BankHandlers { + /// @param bridge The connection's bridge. Borrowed: it must outlive this object. + /// @param callbacks The executor replies land on: the runtime's owner. + BankHandlers(morph::bridge::Bridge& bridge, morph::exec::IExecutor& callbacks); + + morph::bridge::BridgeHandler auth; ///< sign in, register + morph::bridge::BridgeHandler customers; ///< list and open accounts + morph::bridge::BridgeHandler transactions; ///< deposit, withdraw, transfer, history + morph::bridge::BridgeHandler cards; ///< cards + morph::bridge::BridgeHandler payees; ///< payees + morph::bridge::BridgeHandler payments; ///< bill payments + morph::bridge::BridgeHandler loans; ///< loans and schedules +}; + +} // namespace bank::client +``` + +Create `examples/bank/app/bank_handlers.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "bank_handlers.hpp" + +namespace bank::client { + +BankHandlers::BankHandlers(morph::bridge::Bridge& bridge, morph::exec::IExecutor& callbacks) + : auth{bridge, &callbacks}, + customers{bridge, &callbacks}, + transactions{bridge, &callbacks}, + cards{bridge, &callbacks}, + payees{bridge, &callbacks}, + payments{bridge, &callbacks}, + loans{bridge, &callbacks} {} + +} // namespace bank::client +``` + +Create `examples/bank/app/local_setup.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +/// @file +/// How a local (in-process) bank comes up. Native builds migrate a SQLite database; the browser +/// build defines `localSetup()` over its in-memory store instead (wasm/local_setup_wasm.cpp). + +namespace bank::client { + +/// @brief The ODBC connection string for `--db`. +/// @param database Empty for the default file in the temp directory, a full `DRIVER=…` string as it is, +/// or a path to a SQLite file. +/// @return The connection string. +[[nodiscard]] std::string databaseConnection(std::string_view database); + +/// @brief What `examples::connect` runs before a local bridge serves its first call. +/// @return The setup. +[[nodiscard]] morph::examples::LocalSetup localSetup(); + +} // namespace bank::client +``` + +Create `examples/bank/app/local_setup.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "local_setup.hpp" + +#include +#include +#include + +#include "bank/db/database.hpp" + +namespace bank::client { + +std::string databaseConnection(std::string_view database) { + if (database.empty()) { + return "DRIVER=SQLite3;Database=" + (std::filesystem::temp_directory_path() / "morph_bank.db").string(); + } + if (database.starts_with("DRIVER=")) { + return std::string{database}; + } + return "DRIVER=SQLite3;Database=" + std::string{database}; +} + +morph::examples::LocalSetup localSetup() { + return morph::examples::LocalSetup{ + .setupDatabase = [](std::string const& database) { bank::db::setup(databaseConnection(database)); }, + }; +} + +} // namespace bank::client +``` + +Create `examples/bank/app/toolkit_free.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// bank_app is toolkit-free: the same controllers and views run on the terminal and on Qt Quick, and +// only the binary that picks one links either. Qt's imported targets define QT_CORE_LIB (and +// QT_GUI_LIB, QT_QUICK_LIB) on every consumer however indirectly it links them, so a Qt module +// reaching this library's compile line stops the build here instead of letting a controller +// include a Qt header. The browser build links Qt by design and does not compile this file. + +#if defined(QT_CORE_LIB) || defined(QT_GUI_LIB) || defined(QT_QUICK_LIB) +#error "bank_app links a Qt module; it must stay toolkit-free" +#endif + +namespace bank::client::detail { + +/// @brief Gives this translation unit a symbol, so an archiver has nothing to warn about. +extern int const kToolkitFree; +int const kToolkitFree = 1; + +} // namespace bank::client::detail +``` + +In `examples/bank/CMakeLists.txt`, place (a) — after the `if(NOT TARGET morph::morph) … endif()` block: + +```cmake +# morph::ladder_app_common (examples/common/app) is added by the root +# CMakeLists.txt whenever the ladder, the bank example or examples/tui is on; +# bank links it by name, which CMake resolves at generate time. Its PUBLIC +# include root, examples/common, is also where the tests find Part 6's +# header-only testkit/fake_app_context.hpp and testkit/wait.hpp. + +# The client's sources: bank_app below compiles them against bank_lib's +# models, and the browser build (wasm/) against the in-memory ones. Absolute, +# so wasm/CMakeLists.txt can name them. +set(BANK_APP_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/app/notices.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/app/bank_handlers.cpp +) +``` + +and place (b) — after the `# ── CLI driver` block: + +```cmake +# ── bank_app: the toolkit-free application ─────────────────────────────────── +# Controllers, views and the ui::Application over bank_lib's models. It links +# no toolkit and no frontend: the one binary, bank, links the frontends and +# picks one at run time, and the library's tests need neither. The link check +# below and app/toolkit_free.cpp keep it so. No apply_warnings(), for +# bank_lib's reason: the controllers include the model headers and, through +# them, the ORM's, which are not -Werror clean. +add_library(bank_app STATIC + ${BANK_APP_SOURCES} + app/local_setup.cpp + app/toolkit_free.cpp +) +target_include_directories(bank_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/app) +target_link_libraries(bank_app PUBLIC morph::morph bank_lib morph::ladder_app_common) +target_compile_features(bank_app PUBLIC cxx_std_23) +apply_bigobj(bank_app) +if(DEFINED AF_SANITIZER) + apply_sanitizers(bank_app ${AF_SANITIZER}) +endif() +get_target_property(_bank_app_links bank_app LINK_LIBRARIES) +foreach(_bank_app_link IN LISTS _bank_app_links) + if(_bank_app_link MATCHES "^(Qt6::|morph::qt|morph_qt|morph::tui|morph_tui)") + message(FATAL_ERROR "bank_app must stay toolkit-free, but it links ${_bank_app_link}") + endif() +endforeach() +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cmake --build build/bank --target bank_app_tests && ./build/bank/examples/bank/bank_app_tests "[bank][app]"` +Expected: PASS — 12 test cases. + +Mutation checks, each restored afterwards: +1. In `fmt::parseMinor`, delete the `if (!(scaled < kMinorUnitsBound)) { return std::nullopt; }` block. Expected + FAIL in "parseMinor rejects amounts that do not fit in int64 minor units" at `parseMinor("1e30")`. +2. In `Notices::show`, delete `_hide = _scheduler->after(…);`. Expected FAIL in "Notices: an error shows, then + hides after its period" at the last `CHECK_FALSE(notices.visible())`. +3. Add `Qt6::Core` to `bank_app`'s `target_link_libraries` and reconfigure. Expected: configure fails with + `bank_app must stay toolkit-free, but it links Qt6::Core`. Then, instead, add + `target_compile_definitions(bank_app PRIVATE QT_CORE_LIB)` and build. Expected: `#error "bank_app links a Qt + module; it must stay toolkit-free"`. + +- [ ] **Step 5: Commit** + +```bash +git add examples/bank/app examples/bank/tests/app examples/bank/CMakeLists.txt +git commit -m "wip(bank): bank_app with formatting, notices, handlers and the toolkit-free guard + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 2: `AuthController` and the login screen + +The old `AppController` + `Login.qml`. Sign-in and registration are `Mutation`s whose runner also adopts the reply; +adoption hangs off the reply itself, not off a change of `lastResult()`, so a reply identical to the previous one +(the same user signing in again) still signs in. + +**Files:** +- Create: `examples/bank/app/controllers/auth_controller.hpp`, `examples/bank/app/controllers/auth_controller.cpp` +- Create: `examples/bank/app/views/style.hpp`, `examples/bank/app/views/login_view.hpp`, + `examples/bank/app/views/login_view.cpp` +- Create: `examples/bank/tests/app/bank_app_test_support.hpp`, `examples/bank/tests/app/recorded.hpp` +- Create: `examples/bank/tests/app/golden/login.txt` (generated in Step 4) +- Modify: `examples/bank/CMakeLists.txt` — append `${CMAKE_CURRENT_SOURCE_DIR}/app/controllers/auth_controller.cpp` + and `${CMAKE_CURRENT_SOURCE_DIR}/app/views/login_view.cpp` to `set(BANK_APP_SOURCES …)`; append + `tests/app/test_auth.cpp` to `add_executable(bank_app_tests …)` +- Test: `examples/bank/tests/app/test_auth.cpp` + +**Interfaces:** +- Consumes: Task 1's `Notices`, `FailureReporter`, `BankHandlers`, `localSetup`, `FakeAppContext`, `settle`, + `drain`, `awaitReply`; `reactive::{Store, Mutation, Computed, Signal}` (Part 1); `bridge::Bridge::setDefaultSession( + session::Context)` (`include/morph/core/bridge.hpp:988`), `session::Context::principal` + (`include/morph/session/session.hpp:35`); `dto::{LoginRequest, RegisterUser, AuthResult}` + (`include/bank/dto/auth_dto.hpp`); `ui::{Node, Text, TextInput, Button, Busy, Panel, Column, TextRole, + TextInputMode, Sizing, SelectOption, Key, Mounted}` and `ui::testing::RecordingBackend{dump, find, prop, edit, + click}` (Part 2); `examples::{AppEnvironment, Connection, connect, newUuid}` (Part 6); + `bank::testing::connectionString()` (`examples/bank/tests/bank_test_support.hpp`). +- Produces: + - `namespace bank::client::login { struct State { Signal username, password, displayName; }; + EditUsername, EditPassword, EditDisplayName, ClearForm; using Msg; State init(Runtime&); struct Update; }`. + - `class AuthController { AuthController(Runtime&, bridge::Bridge&, BankHandlers&, Notices&); editUsername, + editPassword, editDisplayName, signIn(), createAccount(), signOut(), adopt(dto::AuthResult const&); form(); + signedIn(); principal(); displayName(); handle(); canSignIn(); canCreate(); busy(); }`. + - `namespace bank::client::views`: `roleOf(Tone)`, `optionsOf(std::vector const&)`, `keyOf(std::int64_t)`, + `accountKeyOf(std::int64_t)`, `idOf(ui::Key const&)` (`views/style.hpp`); `loginView(AuthController&)`. + - Test support: `bank::testing::{kPassword, localEnvironment(), signUp(FakeAppContext&, AuthController&, + std::string_view), BankWiring{ctx, connection, handlers, notices, auth, accountReads; runtime(), signUp(prefix), + openAccount(kind, currency, overdraftMinor = 0), depositInto(id, minor), balanceOf(id)}}`; + `recorded.hpp`: `RecordingBackend`, `kText`, `kButton`, `kTextInput`, `kSelect`, `kMenu`, `mount`, `node`, + `button`, `input`, `enabled`, `visible`, `shows`, `checkGolden(name, dump, replacements = {})`. + +- [ ] **Step 0: Confirm RecordingBackend's spellings** + +```bash +grep -n '"Text"\|"Button"\|"TextInput"\|"Select"\|"Menu"\|"Column"\|"Slot"\|"label"\|"placeholder"\|"enabled"\|"visible"' \ + include/morph/ui/testing/recording_backend.hpp +grep -n 'void chooseIndex\|void choose(\|void edit(\|void click(' include/morph/ui/testing/recording_backend.hpp +``` + +Expected: every quoted name appears, and the four helpers are declared as Part 2 Task 2 gives them (`chooseIndex` +activates a `Menu` entry). These are Part 2's spellings, which `recorded.hpp` below and every golden use; a +difference means Part 2 has not landed as planned — stop and say so. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/bank/tests/app/bank_app_test_support.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/dto/account_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank/models/account_model.hpp" +#include "bank_handlers.hpp" +#include "bank_test_support.hpp" +#include "controllers/auth_controller.hpp" +#include "local_setup.hpp" +#include "notices.hpp" +#include "test_app_context.hpp" + +/// @file +/// A local bank on a test AppContext — the wiring `makeApplication` does, without the frontend — and +/// the calls the controller tests use to arrange data behind a controller's back. + +namespace bank::testing { + +/// @brief The password every test user registers with. +inline constexpr std::string_view kPassword = "hunter2demo"; + +/// @brief A local environment over this process's own test database. +[[nodiscard]] inline morph::examples::AppEnvironment localEnvironment() { + morph::examples::AppEnvironment env; + env.db = connectionString(); + return env; +} + +/// @brief Registers a fresh user through @p auth's form and waits until it is signed in. +/// @param prefix Names the test; a random suffix keeps names unique in one database. +/// @return The principal. +inline std::string signUp(FakeAppContext& ctx, bank::client::AuthController& auth, std::string_view prefix) { + std::string name = std::string{prefix} + "-" + morph::examples::newUuid().substr(0, 8); + auth.editUsername(name); + auth.editPassword(std::string{kPassword}); + auth.editDisplayName("Tester"); + auth.createAccount(); + REQUIRE(settle(ctx, [&auth] { return auth.signedIn(); })); + return name; +} + +/// @brief A connected local bank: the bridge, the handlers, the notice strip and the auth controller +/// every other controller needs. +struct BankWiring { + FakeAppContext ctx; + std::unique_ptr connection = + morph::examples::connect(ctx, localEnvironment(), bank::client::localSetup()); + bank::client::BankHandlers handlers{connection->bridge(), connection->callbacks()}; + bank::client::Notices notices{ctx.runtime(), ctx.scheduler()}; + bank::client::AuthController auth{ctx.runtime(), connection->bridge(), handlers, notices}; + morph::bridge::BridgeHandler accountReads{connection->bridge(), &connection->callbacks()}; + + [[nodiscard]] morph::reactive::Runtime& runtime() { return ctx.runtime(); } + + std::string signUp(std::string_view prefix) { return bank::testing::signUp(ctx, auth, prefix); } + + /// @brief Opens an account for the signed-in user, behind every controller's back. + std::int64_t openAccount(int kind, int currency, std::int64_t overdraftMinor = 0) { + return awaitReply(ctx, handlers.customers.execute(dto::OpenAccount{ + .kind = kind, .currency = currency, .overdraftMinor = overdraftMinor})) + .id; + } + + void depositInto(std::int64_t accountId, std::int64_t minor) { + static_cast( + awaitReply(ctx, handlers.transactions.execute(dto::Deposit{.accountId = accountId, .amountMinor = minor}))); + } + + /// @brief The balance the model holds, read independently of every controller. + [[nodiscard]] std::int64_t balanceOf(std::int64_t accountId) { + return awaitReply(ctx, accountReads.execute(dto::GetAccount{.id = accountId})).balanceMinor; + } +}; + +} // namespace bank::testing +``` + +Create `examples/bank/tests/app/recorded.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_app_context.hpp" + +/// @file +/// The bank's view tests read RecordingBackend through these helpers only, so its kind and property +/// names are written once. + +namespace bank::testing { + +using morph::ui::testing::RecordingBackend; + +inline constexpr std::string_view kText = "Text"; +inline constexpr std::string_view kButton = "Button"; +inline constexpr std::string_view kTextInput = "TextInput"; +inline constexpr std::string_view kSelect = "Select"; +inline constexpr std::string_view kMenu = "Menu"; + +/// @brief Mounts @p root on @p backend under the context's runtime. +[[nodiscard]] inline std::unique_ptr mount(FakeAppContext& ctx, RecordingBackend& backend, + morph::ui::Node root) { + return std::make_unique(ctx.runtime(), backend, std::move(root)); +} + +/// @brief The widget of @p kind whose @p prop is @p value; fails the test, printing the tree, if none. +[[nodiscard]] inline int node(RecordingBackend const& backend, std::string_view kind, std::string_view prop, + std::string_view value) { + std::optional const found = backend.find(kind, prop, value); + INFO(backend.dump()); + REQUIRE(found.has_value()); + return found.value_or(-1); +} + +[[nodiscard]] inline int button(RecordingBackend const& backend, std::string_view label) { + return node(backend, kButton, "label", label); +} + +[[nodiscard]] inline int input(RecordingBackend const& backend, std::string_view placeholder) { + return node(backend, kTextInput, "placeholder", placeholder); +} + +/// @brief Whether widget @p widgetId is enabled; `enabled` is only set when bound or `false`, so unset means yes. +[[nodiscard]] inline bool enabled(RecordingBackend const& backend, int widgetId) { + return backend.prop(widgetId, "enabled") != "false"; +} + +/// @brief Whether widget @p widgetId is visible; `visible` is only set when bound or `false`, so unset means yes. +[[nodiscard]] inline bool visible(RecordingBackend const& backend, int widgetId) { + return backend.prop(widgetId, "visible") != "false"; +} + +/// @brief Whether a visible Text says exactly @p text. +[[nodiscard]] inline bool shows(RecordingBackend const& backend, std::string_view text) { + std::optional const found = backend.find(kText, "text", text); + return found.has_value() && visible(backend, *found); +} + +/// @brief Compares a tree dump with `golden/.txt`. +/// +/// With `BANK_UPDATE_GOLDEN` set, writes the file instead and warns: review the diff before +/// committing it, because a golden written from a wrong tree pins the wrong tree. +/// @param replacements Run-specific text (a generated user name) mapped to a stable stand-in first. +inline void checkGolden(std::string_view name, std::string dump, + std::vector> const& replacements = {}) { + for (auto const& [from, standIn] : replacements) { + for (std::size_t pos = dump.find(from); pos != std::string::npos; pos = dump.find(from, pos + standIn.size())) { + dump.replace(pos, from.size(), standIn); + } + } + std::filesystem::path const path = std::filesystem::path{BANK_GOLDEN_DIR} / (std::string{name} + ".txt"); + if (morph::ui::processEnvironment()("BANK_UPDATE_GOLDEN").has_value()) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream{path} << dump; + WARN("rewrote " << path.string()); + return; + } + std::ifstream file{path}; + INFO("no golden at " << path.string() << "; generate it with BANK_UPDATE_GOLDEN=1 and review it"); + REQUIRE(file.good()); + std::string const expected{std::istreambuf_iterator{file}, std::istreambuf_iterator{}}; + CHECK(dump == expected); +} + +} // namespace bank::testing +``` + +Create `examples/bank/tests/app/test_auth.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "bank_app_test_support.hpp" +#include "recorded.hpp" +#include "views/login_view.hpp" + +using bank::client::Tone; +using bank::testing::BankWiring; +using bank::testing::kPassword; +using bank::testing::RecordingBackend; +using bank::testing::settle; + +TEST_CASE("AuthController: creating an account signs in and installs the session", "[bank][app][auth]") { + BankWiring wiring; + std::string const name = wiring.signUp("auth-create"); + CHECK(wiring.auth.principal() == name); + CHECK(wiring.auth.displayName() == "Tester"); + CHECK(wiring.auth.handle() == "@" + name); + CHECK(wiring.auth.form().password.peek().empty()); + // Every later call carries the session: OpenAccount refuses a call without a principal. + CHECK(wiring.openAccount(0, 0) > 0); +} + +TEST_CASE("AuthController: a rejected sign-in shows the model's message and stays signed out", + "[bank][app][auth]") { + BankWiring wiring; + wiring.auth.editUsername("nobody-" + morph::examples::newUuid().substr(0, 8)); + wiring.auth.editPassword("whatever"); + wiring.auth.signIn(); + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.notices.visible(); })); + CHECK(wiring.notices.text() == "no such user"); + CHECK(wiring.notices.tone() == Tone::Bad); + CHECK_FALSE(wiring.auth.signedIn()); +} + +// Review Focus 3. +TEST_CASE("AuthController: signing out and in again as the same user signs in", "[bank][app][auth]") { + BankWiring wiring; + std::string const name = wiring.signUp("auth-again"); + for (int round = 0; round < 2; ++round) { + wiring.auth.signOut(); + CHECK_FALSE(wiring.auth.signedIn()); + CHECK(wiring.auth.principal().empty()); + wiring.auth.editUsername(name); + wiring.auth.editPassword(std::string{kPassword}); + wiring.auth.signIn(); + // The second round's reply equals the first's; it must still sign in. + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.auth.signedIn(); })); + CHECK(wiring.auth.principal() == name); + } +} + +TEST_CASE("AuthController: the buttons are enabled only for complete input", "[bank][app][auth]") { + BankWiring wiring; + CHECK_FALSE(wiring.auth.canSignIn()); + CHECK_FALSE(wiring.auth.canCreate()); + wiring.auth.editUsername("someone"); + CHECK_FALSE(wiring.auth.canSignIn()); + wiring.auth.editPassword("abc"); + CHECK(wiring.auth.canSignIn()); + CHECK_FALSE(wiring.auth.canCreate()); // RegisterUser needs four characters + wiring.auth.editPassword("abcd"); + CHECK(wiring.auth.canCreate()); +} + +TEST_CASE("AuthController: busy while a sign-in is in flight", "[bank][app][auth]") { + BankWiring wiring; + wiring.auth.editUsername("nobody-" + morph::examples::newUuid().substr(0, 8)); + wiring.auth.editPassword("whatever"); + wiring.auth.signIn(); + CHECK(wiring.auth.busy()); + CHECK_FALSE(wiring.auth.canSignIn()); + REQUIRE(settle(wiring.ctx, [&wiring] { return !wiring.auth.busy(); })); +} + +TEST_CASE("Login view: the form drives the controller", "[bank][app][view][auth]") { + BankWiring wiring; + RecordingBackend rec; + auto const mounted = bank::testing::mount(wiring.ctx, rec, bank::client::views::loginView(wiring.auth)); + bank::testing::checkGolden("login", rec.dump()); + + int const signIn = bank::testing::button(rec, "Sign in"); + int const create = bank::testing::button(rec, "Create account"); + CHECK_FALSE(bank::testing::enabled(rec, signIn)); + std::string const name = "view-login-" + morph::examples::newUuid().substr(0, 8); + rec.edit(bank::testing::input(rec, "Username"), name); + rec.edit(bank::testing::input(rec, "Password"), std::string{kPassword}); + rec.edit(bank::testing::input(rec, "Display name (for new accounts)"), "Viewer"); + bank::testing::drain(wiring.ctx); + CHECK(wiring.auth.form().username.peek() == name); + CHECK(bank::testing::enabled(rec, signIn)); + CHECK(bank::testing::enabled(rec, create)); + + rec.click(create); + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.auth.signedIn(); })); + CHECK(wiring.auth.displayName() == "Viewer"); + CHECK(wiring.auth.form().username.peek().empty()); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Add `tests/app/test_auth.cpp` to `bank_app_tests` first. Run: `cmake --build build/bank --target bank_app_tests` +Expected: FAIL — `fatal error: 'controllers/auth_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bank/app/controllers/auth_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/dto/auth_dto.hpp" +#include "bank_handlers.hpp" +#include "notices.hpp" + +/// @file +/// Signing in, creating a user and signing out: the login form, the two calls, and the session the +/// bridge then applies to every call. + +namespace bank::client { + +/// @brief The login form's user intent. +namespace login { + +/// @brief What the user has typed. +struct State { + morph::reactive::Signal username; ///< the user name + morph::reactive::Signal password; ///< the password + morph::reactive::Signal displayName; ///< used when creating a user +}; + +struct EditUsername { + std::string text; +}; +struct EditPassword { + std::string text; +}; +struct EditDisplayName { + std::string text; +}; +/// @brief Empties every field, after a successful sign-in. +struct ClearForm {}; + +using Msg = std::variant; + +/// @brief An empty form. +[[nodiscard]] State init(morph::reactive::Runtime& runtime); + +/// @brief Applies one message. +struct Update { + void operator()(State& state, EditUsername const& msg) const; + void operator()(State& state, EditPassword const& msg) const; + void operator()(State& state, EditDisplayName const& msg) const; + void operator()(State& state, ClearForm const& msg) const; +}; + +} // namespace login + +/// @brief The session: who is signed in, and the form that signs someone in. +class AuthController { +public: + /// @param runtime The runtime. Borrowed. + /// @param bridge Receives the session on sign-in and sign-out. Borrowed. + /// @param handlers The client's handlers. Borrowed. + /// @param notices Shows a refusal or failure. Borrowed. + AuthController(morph::reactive::Runtime& runtime, morph::bridge::Bridge& bridge, BankHandlers& handlers, + Notices& notices); + + void editUsername(std::string text); + void editPassword(std::string text); + void editDisplayName(std::string text); + + /// @brief Signs in with the form's user name and password; does nothing while `canSignIn()` is false. + void signIn(); + /// @brief Registers the form's user and signs in; does nothing while `canCreate()` is false. + void createAccount(); + /// @brief Clears the session. + void signOut(); + /// @brief Installs @p result's principal as the session, or shows its message when it is a refusal. + void adopt(dto::AuthResult const& result); + + [[nodiscard]] login::State const& form() const noexcept { return _form.state(); } + /// @brief Whether someone is signed in. Tracked. + [[nodiscard]] bool signedIn() const { return _signedIn.get(); } + [[nodiscard]] std::string const& principal() const { return _principal.get(); } + [[nodiscard]] std::string const& displayName() const { return _displayName.get(); } + /// @brief `"@"` and the principal, as the sidebar shows it. Tracked. + [[nodiscard]] std::string const& handle() const { return _handle.get(); } + [[nodiscard]] bool canSignIn() const { return _canSignIn.get(); } + [[nodiscard]] bool canCreate() const { return _canCreate.get(); } + /// @brief Whether a sign-in or registration is in flight. Tracked. + [[nodiscard]] bool busy() const { return _busy.get(); } + +private: + morph::reactive::Runtime* _rt; + morph::bridge::Bridge* _bridge; + Notices* _notices; + morph::reactive::Store _form; + morph::reactive::Signal _principal; + morph::reactive::Signal _displayName; + morph::reactive::Mutation _login; + morph::reactive::Mutation _register; + morph::reactive::Computed _signedIn; + morph::reactive::Computed _handle; + morph::reactive::Computed _busy; + morph::reactive::Computed _canSignIn; + morph::reactive::Computed _canCreate; + FailureReporter _failures; + // Last member, so it is destroyed first: a reply still in flight is not adopted. + morph::async::CallbackScope _adoptions; +}; + +} // namespace bank::client +``` + +Create `examples/bank/app/controllers/auth_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "controllers/auth_controller.hpp" + +#include +#include +#include + +namespace bank::client { + +namespace login { + +State init(morph::reactive::Runtime& runtime) { + return State{.username{runtime, std::string{}}, + .password{runtime, std::string{}}, + .displayName{runtime, std::string{}}}; +} + +void Update::operator()(State& state, EditUsername const& msg) const { state.username.set(msg.text); } +void Update::operator()(State& state, EditPassword const& msg) const { state.password.set(msg.text); } +void Update::operator()(State& state, EditDisplayName const& msg) const { state.displayName.set(msg.text); } + +void Update::operator()(State& state, ClearForm const& /*msg*/) const { + state.username.set(std::string{}); + state.password.set(std::string{}); + state.displayName.set(std::string{}); +} + +} // namespace login + +AuthController::AuthController(morph::reactive::Runtime& runtime, morph::bridge::Bridge& bridge, BankHandlers& handlers, + Notices& notices) + : _rt{&runtime}, + _bridge{&bridge}, + _notices{¬ices}, + _form{runtime, &login::init, login::Update{}}, + _principal{runtime, std::string{}}, + _displayName{runtime, std::string{}}, + // Adoption is attached to each reply, so every successful sign-in installs its session — two + // identical replies in a row included. + _login{runtime, + [this, &handlers](dto::LoginRequest request) { + morph::async::Completion reply = handlers.auth.execute(std::move(request)); + reply.then(_adoptions, [this](dto::AuthResult const& result) { adopt(result); }); + return reply; + }}, + _register{runtime, + [this, &handlers](dto::RegisterUser request) { + morph::async::Completion reply = handlers.auth.execute(std::move(request)); + reply.then(_adoptions, [this](dto::AuthResult const& result) { adopt(result); }); + return reply; + }}, + _signedIn{runtime, [this] { return !_principal.get().empty(); }}, + _handle{runtime, [this] { return "@" + _principal.get(); }}, + _busy{runtime, [this] { return _login.pending() || _register.pending(); }}, + _canSignIn{runtime, + [this] { + login::State const& form = _form.state(); + return !_busy.get() && !form.username.get().empty() && !form.password.get().empty(); + }}, + _canCreate{runtime, + [this] { + login::State const& form = _form.state(); + dto::RegisterUser const draft{.username = form.username.get(), + .password = form.password.get(), + .displayName = form.displayName.get()}; + return !_busy.get() && draft.validate(); + }}, + _failures{runtime, notices, {[this] { return _login.error(); }, [this] { return _register.error(); }}} {} + +void AuthController::editUsername(std::string text) { _form.send(login::EditUsername{std::move(text)}); } +void AuthController::editPassword(std::string text) { _form.send(login::EditPassword{std::move(text)}); } +void AuthController::editDisplayName(std::string text) { _form.send(login::EditDisplayName{std::move(text)}); } + +void AuthController::signIn() { + if (!_canSignIn.peek()) { + return; + } + login::State const& form = _form.state(); + _login.run(dto::LoginRequest{.username = form.username.peek(), .password = form.password.peek()}); +} + +void AuthController::createAccount() { + if (!_canCreate.peek()) { + return; + } + login::State const& form = _form.state(); + _register.run(dto::RegisterUser{.username = form.username.peek(), + .password = form.password.peek(), + .displayName = form.displayName.peek()}); +} + +void AuthController::signOut() { + _bridge->setDefaultSession(morph::session::Context{}); + _rt->batch([this] { + _principal.set(std::string{}); + _displayName.set(std::string{}); + }); +} + +void AuthController::adopt(dto::AuthResult const& result) { + if (!result.ok) { + _notices->error(result.message); + return; + } + // The session first: the queries keyed on signedIn() fetch in the flush this batch posts, and + // their calls must already carry the principal. + morph::session::Context session; + session.principal = result.principal; + _bridge->setDefaultSession(std::move(session)); + _rt->batch([&] { + _principal.set(result.principal); + _displayName.set(result.displayName); + _form.send(login::ClearForm{}); + }); +} + +} // namespace bank::client +``` + +Create `examples/bank/app/views/style.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include "choice.hpp" +#include "tone.hpp" + +/// @file +/// The only translation between controllers and views: a controller cannot name a `ui` type, so a +/// tone becomes a text role and a choice a select option here, and nowhere else. + +namespace bank::client::views { + +/// @brief The text role that shows @p tone. The palette has no warning role, so `Warn` shares the +/// error role. +[[nodiscard]] inline morph::ui::TextRole roleOf(Tone tone) noexcept { + switch (tone) { + case Tone::Good: + return morph::ui::TextRole::Success; + case Tone::Warn: + case Tone::Bad: + return morph::ui::TextRole::Error; + case Tone::Neutral: + break; + } + return morph::ui::TextRole::Muted; +} + +/// @brief @p choices as select options, keyed by id. +[[nodiscard]] inline std::vector optionsOf(std::vector const& choices) { + std::vector options; + options.reserve(choices.size()); + for (Choice const& choice : choices) { + options.push_back(morph::ui::SelectOption{.key = choice.id, .label = choice.label}); + } + return options; +} + +/// @brief The key of a fixed-list choice, where 0 is a value like any other. +[[nodiscard]] inline std::optional keyOf(std::int64_t value) { return morph::ui::Key{value}; } + +/// @brief The key of an entity choice, where 0 means nothing is selected. +[[nodiscard]] inline std::optional accountKeyOf(std::int64_t entityId) { + if (entityId == 0) { + return std::nullopt; + } + return morph::ui::Key{entityId}; +} + +/// @brief The id a select reported; 0 for a key that is not an id. +[[nodiscard]] inline std::int64_t idOf(morph::ui::Key const& key) noexcept { + std::int64_t const* const entityId = std::get_if(&key); + return entityId == nullptr ? 0 : *entityId; +} + +} // namespace bank::client::views +``` + +Create `examples/bank/app/views/login_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "controllers/auth_controller.hpp" + +namespace bank::client::views { + +/// @brief The sign-in screen. +/// @param auth Owns the form; must outlive the mounted view. +/// @return The view. +[[nodiscard]] morph::ui::Node loginView(AuthController& auth); + +} // namespace bank::client::views +``` + +Create `examples/bank/app/views/login_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "views/login_view.hpp" + +#include +#include + +namespace bank::client::views { + +namespace ui = morph::ui; + +ui::Node loginView(AuthController& auth) { + AuthController* const ctl = &auth; + login::State const* const form = &auth.form(); + return ui::panel({ + .title = "Morph Bank", + .padding = 1, + .child = ui::column({ + .children = + { + ui::text({.text = "Sign in to your account", .role = ui::TextRole::Muted}), + ui::textInput({.value = [form] { return form->username.get(); }, + .onChange = [ctl](std::string text) { ctl->editUsername(std::move(text)); }, + .placeholder = "Username"}), + ui::textInput({.value = [form] { return form->password.get(); }, + .onChange = [ctl](std::string text) { ctl->editPassword(std::move(text)); }, + .onSubmit = [ctl](std::string /*text*/) { ctl->signIn(); }, + .placeholder = "Password", + .mode = ui::TextInputMode::Password}), + ui::textInput({.value = [form] { return form->displayName.get(); }, + .onChange = [ctl](std::string text) { ctl->editDisplayName(std::move(text)); }, + .placeholder = "Display name (for new accounts)"}), + ui::button({.label = "Sign in", + .onClick = [ctl] { ctl->signIn(); }, + .common = {.enabled = [ctl] { return ctl->canSignIn(); }}}), + ui::button({.label = "Create account", + .onClick = [ctl] { ctl->createAccount(); }, + .common = {.enabled = [ctl] { return ctl->canCreate(); }}}), + ui::busy({.active = [ctl] { return ctl->busy(); }, .label = "Signing in…"}), + }, + .gap = 1, + }), + .common = {.layout = {.width = ui::Sizing::fixed(48)}}, + }); +} + +} // namespace bank::client::views +``` + +Append to `BANK_APP_SOURCES` in `examples/bank/CMakeLists.txt`: + +```cmake + ${CMAKE_CURRENT_SOURCE_DIR}/app/controllers/auth_controller.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/app/views/login_view.cpp +``` + +- [ ] **Step 4: Generate the golden, then run the tests to verify they pass** + +```bash +cmake --build build/bank --target bank_app_tests +BANK_UPDATE_GOLDEN=1 ./build/bank/examples/bank/bank_app_tests "Login view: the form drives the controller" +cat examples/bank/tests/app/golden/login.txt +``` + +Review the dump before keeping it: one `Panel` titled `Morph Bank` (`layout=fixed(48)/content`) holding a `Column` +with a Text `role=Muted text=Sign in to your account`, three `TextInput`s (placeholders `Username`, `Password` with +`mode=Password`, `Display name (for new accounts)`), Buttons `Sign in` and `Create account` with `enabled=false`, and +a `Busy` with `active=false label=Signing in…` — nothing else. A golden written from a wrong tree pins the wrong +tree. + +Run: `./build/bank/examples/bank/bank_app_tests "[bank][app]"` +Expected: PASS — 18 test cases. + +Mutation checks, each restored afterwards: +1. In `AuthController::adopt`, delete `_bridge->setDefaultSession(std::move(session));`. Expected FAIL in + "creating an account signs in and installs the session" — `openAccount` rethrows the model's + `no session principal`. +2. In `_canSignIn`, delete `&& !form.password.get().empty()`. Expected FAIL in "the buttons are enabled only for + complete input" at the second `CHECK_FALSE(w.auth.canSignIn())`. +3. In `login_view.cpp`, change the `Create account` button's `onClick` to `[ctl] { ctl->signIn(); }`. Expected FAIL + in "Login view: the form drives the controller" (times out waiting for `signedIn()`: the user does not exist). + +- [ ] **Step 5: Commit** + +```bash +git add examples/bank/app examples/bank/tests/app examples/bank/CMakeLists.txt +git commit -m "wip(bank): AuthController and the login screen + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 3: `AccountsController` and the accounts screen + +The old `AccountController` + `AccountsPage.qml`: the account list, its total, and the open-account form. The list +is a `Query` keyed on the session, so signing out clears it and signing in fetches it; opening an account +invalidates it. + +**Files:** +- Create: `examples/bank/app/controllers/accounts_controller.hpp`, + `examples/bank/app/controllers/accounts_controller.cpp` +- Create: `examples/bank/app/views/accounts_view.hpp`, `examples/bank/app/views/accounts_view.cpp` +- Create: `examples/bank/tests/app/golden/accounts.txt` (generated in Step 4) +- Modify: `examples/bank/CMakeLists.txt` — append `app/controllers/accounts_controller.cpp` and + `app/views/accounts_view.cpp` (absolute, as above) to `BANK_APP_SOURCES`; append `tests/app/test_accounts.cpp` to + `bank_app_tests` +- Test: `examples/bank/tests/app/test_accounts.cpp` + +**Interfaces:** +- Consumes: Tasks 1–2 (`Tone`, `Choice`, `fmt::*`, `Notices`, `FailureReporter`, `BankHandlers`, + `AuthController::signedIn()`, `views::{roleOf, optionsOf, keyOf, idOf}`, `BankWiring`, `recorded.hpp`); + `reactive::{Query, Mutation, MutationOptions, Store, Computed}` (Part 1); `ui::{forEach, panel, row, column, + scroll, spacer, select, textInput, button, text, Key, Axis, Sizing}`, `RecordingBackend::{all, choose}` (Part 2); + `dto::{ListAccounts, AccountList, AccountInfo, OpenAccount, CloseAccount}`, `bank::AccountStatus`. +- Produces: `namespace bank::client::opening { State{kind, currency, overdraft}; ChooseKind, ChooseCurrency, + EditOverdraft, ClearOverdraft; Msg; init; Update; }`; `struct AccountCard`; `struct AccountsSummary`; + `class AccountsController { AccountsController(Runtime&, BankHandlers&, AuthController const&, Notices&); + chooseKind, chooseCurrency, editOverdraft, openAccount(), refresh(); static kindChoices(), currencyChoices(); + form(), cards(), totalText(), openCountText(), loaded(); }`; `views::accountsView(AccountsController&)`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/bank/tests/app/test_accounts.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "bank/dto/account_dto.hpp" +#include "bank_app_test_support.hpp" +#include "controllers/accounts_controller.hpp" +#include "recorded.hpp" +#include "views/accounts_view.hpp" + +using bank::client::AccountCard; +using bank::client::AccountsController; +using bank::client::Tone; +using bank::testing::BankWiring; +using bank::testing::RecordingBackend; +using bank::testing::settle; + +TEST_CASE("AccountsController: opening an account lists it with its formatted balance", "[bank][app][accounts]") { + BankWiring wiring; + wiring.signUp("acc-open"); + AccountsController accounts{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + CHECK(accounts.totalText() == "—"); + REQUIRE(settle(wiring.ctx, [&accounts] { return accounts.loaded(); })); + CHECK(accounts.totalText() == "0"); + CHECK(accounts.openCountText() == "0 open account(s)"); + + accounts.chooseKind(0); + accounts.chooseCurrency(0); + accounts.editOverdraft("500"); + accounts.openAccount(); + CHECK(accounts.form().overdraft.peek().empty()); + REQUIRE(settle(wiring.ctx, [&accounts] { return accounts.cards().size() == 1; })); + AccountCard const card = accounts.cards().front(); + CHECK(card.kind == "Checking"); + CHECK(card.number.starts_with("•••• ")); + CHECK(card.balanceText == "0.00 USD"); + CHECK(card.statusText == "Open"); + CHECK(card.statusTone == Tone::Good); + CHECK(card.hasOverdraft); + CHECK(card.overdraftText == "Overdraft 500.00 USD"); + CHECK(accounts.totalText() == "0.00 USD"); + CHECK(accounts.openCountText() == "1 open account(s)"); +} + +TEST_CASE("AccountsController: the total is a sum in one currency and a count across several", + "[bank][app][accounts]") { + BankWiring wiring; + wiring.signUp("acc-total"); + wiring.depositInto(wiring.openAccount(0, 0), 1250); + AccountsController accounts{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&accounts] { return accounts.cards().size() == 1; })); + CHECK(accounts.totalText() == "12.50 USD"); + + static_cast(wiring.openAccount(1, 1)); // EUR + accounts.refresh(); + REQUIRE(settle(wiring.ctx, [&accounts] { return accounts.cards().size() == 2; })); + CHECK(accounts.totalText() == "2"); + CHECK(accounts.openCountText() == "2 open account(s)"); +} + +TEST_CASE("AccountsController: an overdraft that does not parse opens the account with none", + "[bank][app][accounts]") { + BankWiring wiring; + wiring.signUp("acc-overdraft"); + AccountsController accounts{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&accounts] { return accounts.loaded(); })); + accounts.editOverdraft("abc"); + accounts.openAccount(); + REQUIRE(settle(wiring.ctx, [&accounts] { return accounts.cards().size() == 1; })); + CHECK_FALSE(accounts.cards().front().hasOverdraft); +} + +TEST_CASE("AccountsController: a closed account is listed but not counted", "[bank][app][accounts]") { + BankWiring wiring; + wiring.signUp("acc-closed"); + static_cast(wiring.openAccount(0, 0)); + std::int64_t const closed = wiring.openAccount(1, 0); + static_cast( + bank::testing::awaitReply(wiring.ctx, wiring.accountReads.execute(bank::dto::CloseAccount{.id = closed}))); + AccountsController accounts{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&accounts] { return accounts.cards().size() == 2; })); + CHECK(accounts.cards().back().statusText == "Closed"); + CHECK(accounts.cards().back().statusTone == Tone::Neutral); + CHECK(accounts.openCountText() == "1 open account(s)"); + CHECK(accounts.totalText() == "0.00 USD"); +} + +// Review Focus 5. +TEST_CASE("AccountsController: signing out empties the list and the total returns to —", "[bank][app][accounts]") { + BankWiring wiring; + wiring.signUp("acc-signout"); + static_cast(wiring.openAccount(0, 0)); + AccountsController accounts{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&accounts] { return accounts.cards().size() == 1; })); + wiring.auth.signOut(); + bank::testing::drain(wiring.ctx); + CHECK(accounts.cards().empty()); + CHECK(accounts.totalText() == "—"); + CHECK_FALSE(accounts.loaded()); +} + +TEST_CASE("Accounts view: the empty screen, then an account opened through it", "[bank][app][view][accounts]") { + BankWiring wiring; + wiring.signUp("view-acc"); + AccountsController accounts{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&accounts] { return accounts.loaded(); })); + RecordingBackend rec; + auto const mounted = bank::testing::mount(wiring.ctx, rec, bank::client::views::accountsView(accounts)); + bank::testing::checkGolden("accounts", rec.dump()); + + std::vector const selects = rec.all(bank::testing::kSelect); + REQUIRE(selects.size() == 2); + rec.choose(selects.at(0), morph::ui::Key{std::int64_t{1}}); // Savings + rec.choose(selects.at(1), morph::ui::Key{std::int64_t{1}}); // EUR + rec.edit(bank::testing::input(rec, "Overdraft (opt.)"), "25"); + rec.click(bank::testing::button(rec, "Open account")); + REQUIRE(settle(wiring.ctx, [&accounts] { return accounts.cards().size() == 1; })); + bank::testing::drain(wiring.ctx); + CHECK(bank::testing::shows(rec, "Savings")); + CHECK(bank::testing::shows(rec, "Overdraft 25.00 EUR")); + CHECK(bank::testing::shows(rec, "0.00 EUR")); + CHECK(bank::testing::shows(rec, "1 open account(s)")); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/bank --target bank_app_tests` +Expected: FAIL — `fatal error: 'controllers/accounts_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bank/app/controllers/accounts_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/dto/account_dto.hpp" +#include "bank_handlers.hpp" +#include "choice.hpp" +#include "notices.hpp" +#include "tone.hpp" + +/// @file +/// The accounts screen: the signed-in user's accounts, their total, and the open-account form. + +namespace bank::client { + +class AuthController; + +/// @brief The open-account form's user intent. +namespace opening { + +struct State { + morph::reactive::Signal kind; ///< a `bank::AccountKind` value + morph::reactive::Signal currency; ///< a `bank::Currency` value + morph::reactive::Signal overdraft; ///< as typed; blank for none +}; + +struct ChooseKind { + std::int64_t kind = 0; +}; +struct ChooseCurrency { + std::int64_t currency = 0; +}; +struct EditOverdraft { + std::string text; +}; +struct ClearOverdraft {}; + +using Msg = std::variant; + +/// @brief Checking, USD, no overdraft. +[[nodiscard]] State init(morph::reactive::Runtime& runtime); + +struct Update { + void operator()(State& state, ChooseKind const& msg) const; + void operator()(State& state, ChooseCurrency const& msg) const; + void operator()(State& state, EditOverdraft const& msg) const; + void operator()(State& state, ClearOverdraft const& msg) const; +}; + +} // namespace opening + +/// @brief One account as the screen shows it. +struct AccountCard { + std::int64_t id = 0; + std::string kind; ///< `Checking`, `Savings`, `Credit` + std::string number; ///< masked to its last four + std::string balanceText; ///< formatted in the account's currency + std::string statusText; ///< `Open` or `Closed` + Tone statusTone = Tone::Neutral; + bool hasOverdraft = false; ///< whether to show `overdraftText` + std::string overdraftText; ///< `Overdraft …` + bool operator==(AccountCard const&) const = default; +}; + +/// @brief The summary above the list. +struct AccountsSummary { + std::string totalText; ///< the sum when the open accounts share a currency, else their count; `—` before a load + std::string openCountText; ///< `N open account(s)` + bool operator==(AccountsSummary const&) const = default; +}; + +/// @brief The accounts screen's state and commands. +class AccountsController { +public: + AccountsController(morph::reactive::Runtime& runtime, BankHandlers& handlers, AuthController const& auth, + Notices& notices); + + void chooseKind(std::int64_t kind); + void chooseCurrency(std::int64_t currency); + void editOverdraft(std::string text); + /// @brief Opens an account of the chosen kind and currency. A blank or unparseable overdraft opens + /// with none, read at two decimals whatever the currency. + void openAccount(); + /// @brief Fetches the list again. + void refresh(); + + [[nodiscard]] static std::vector const& kindChoices(); + [[nodiscard]] static std::vector const& currencyChoices(); + + [[nodiscard]] opening::State const& form() const noexcept { return _form.state(); } + [[nodiscard]] std::vector const& cards() const { return _cards.get(); } + [[nodiscard]] std::string const& totalText() const { return _summary.get().totalText; } + [[nodiscard]] std::string const& openCountText() const { return _summary.get().openCountText; } + /// @brief Whether a list has arrived for the current session. Tracked. + [[nodiscard]] bool loaded() const { return _list.value().has_value(); } + +private: + morph::reactive::Store _form; + morph::reactive::Query _list; + morph::reactive::Mutation _open; + morph::reactive::Computed> _cards; + morph::reactive::Computed _summary; + FailureReporter _failures; +}; + +} // namespace bank::client +``` + +Create `examples/bank/app/controllers/accounts_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "controllers/accounts_controller.hpp" + +#include +#include +#include + +#include "bank/core/types.hpp" +#include "controllers/auth_controller.hpp" +#include "format.hpp" + +namespace bank::client { + +namespace opening { + +State init(morph::reactive::Runtime& runtime) { + return State{.kind{runtime, 0}, .currency{runtime, 0}, .overdraft{runtime, std::string{}}}; +} + +void Update::operator()(State& state, ChooseKind const& msg) const { state.kind.set(msg.kind); } +void Update::operator()(State& state, ChooseCurrency const& msg) const { state.currency.set(msg.currency); } +void Update::operator()(State& state, EditOverdraft const& msg) const { state.overdraft.set(msg.text); } +void Update::operator()(State& state, ClearOverdraft const& /*msg*/) const { state.overdraft.set(std::string{}); } + +} // namespace opening + +namespace { + +AccountCard cardOf(dto::AccountInfo const& account) { + bool const closed = account.status == static_cast(bank::AccountStatus::Closed); + return AccountCard{ + .id = account.id, + .kind = fmt::accountKind(account.kind), + .number = fmt::last4(account.number), + .balanceText = fmt::money(account.balanceMinor, account.currency), + .statusText = closed ? "Closed" : "Open", + .statusTone = closed ? Tone::Neutral : Tone::Good, + .hasOverdraft = account.overdraftMinor > 0, + .overdraftText = "Overdraft " + fmt::money(account.overdraftMinor, account.currency), + }; +} + +// The currency compared against is the first account's, closed or not; only open accounts count. +AccountsSummary summarize(std::optional const& list) { + if (!list.has_value()) { + return AccountsSummary{.totalText = "—", .openCountText = "0 open account(s)"}; + } + int const currency = list->accounts.empty() ? 0 : list->accounts.front().currency; + std::int64_t total = 0; + int openCount = 0; + bool sameCurrency = true; + for (dto::AccountInfo const& account : list->accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + ++openCount; + total += account.balanceMinor; + sameCurrency = sameCurrency && account.currency == currency; + } + return AccountsSummary{ + .totalText = sameCurrency && openCount > 0 ? fmt::money(total, currency) : std::to_string(openCount), + .openCountText = std::to_string(openCount) + " open account(s)", + }; +} + +} // namespace + +AccountsController::AccountsController(morph::reactive::Runtime& runtime, BankHandlers& handlers, + AuthController const& auth, Notices& notices) + : _form{runtime, &opening::init, opening::Update{}}, + _list{runtime, handlers.customers, + [session = &auth]() -> std::optional { + if (!session->signedIn()) { + return std::nullopt; + } + return dto::ListAccounts{}; + }}, + _open{runtime, handlers.customers, morph::reactive::MutationOptions{.invalidates = {&_list}}}, + _cards{runtime, + [this] { + std::vector cards; + if (std::optional const& list = _list.value(); list.has_value()) { + cards.reserve(list->accounts.size()); + for (dto::AccountInfo const& account : list->accounts) { + cards.push_back(cardOf(account)); + } + } + return cards; + }}, + _summary{runtime, [this] { return summarize(_list.value()); }}, + _failures{runtime, notices, {[this] { return _list.error(); }, [this] { return _open.error(); }}} {} + +void AccountsController::chooseKind(std::int64_t kind) { _form.send(opening::ChooseKind{kind}); } +void AccountsController::chooseCurrency(std::int64_t currency) { _form.send(opening::ChooseCurrency{currency}); } +void AccountsController::editOverdraft(std::string text) { _form.send(opening::EditOverdraft{std::move(text)}); } + +void AccountsController::openAccount() { + opening::State const& form = _form.state(); + std::string_view const overdraft = fmt::trimmed(form.overdraft.peek()); + std::int64_t const minor = overdraft.empty() ? 0 : fmt::parseMinor(overdraft).value_or(0); + _open.run(dto::OpenAccount{.kind = static_cast(form.kind.peek()), + .currency = static_cast(form.currency.peek()), + .overdraftMinor = minor}); + _form.send(opening::ClearOverdraft{}); +} + +void AccountsController::refresh() { _list.refetch(); } + +std::vector const& AccountsController::kindChoices() { + static std::vector const choices{ + {.id = 0, .label = "Checking"}, {.id = 1, .label = "Savings"}, {.id = 2, .label = "Credit"}}; + return choices; +} + +std::vector const& AccountsController::currencyChoices() { + static std::vector const choices{{.id = 0, .label = "USD"}, {.id = 1, .label = "EUR"}, + {.id = 2, .label = "GBP"}, {.id = 3, .label = "CHF"}, + {.id = 4, .label = "JPY"}}; + return choices; +} + +} // namespace bank::client +``` + +Create `examples/bank/app/views/accounts_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "controllers/accounts_controller.hpp" + +namespace bank::client::views { + +/// @brief The accounts screen: the summary, the open-account form, one panel per account. +/// @param accounts Must outlive the mounted view. +/// @return The view. +[[nodiscard]] morph::ui::Node accountsView(AccountsController& accounts); + +} // namespace bank::client::views +``` + +Create `examples/bank/app/views/accounts_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "views/accounts_view.hpp" + +#include +#include +#include + +#include "views/style.hpp" + +namespace bank::client::views { + +namespace ui = morph::ui; + +ui::Node accountsView(AccountsController& accounts) { + AccountsController* const ctl = &accounts; + opening::State const* const form = &accounts.form(); + + ui::Node summary = ui::panel({ + .padding = 1, + .child = ui::column({.children = { + ui::text({.text = [ctl] { return ctl->totalText(); }, .role = ui::TextRole::Heading}), + ui::text({.text = [ctl] { return ctl->openCountText(); }, .role = ui::TextRole::Muted}), + }}), + }); + + ui::Node opener = ui::panel({ + .title = "New account", + .padding = 1, + .child = ui::row({ + .children = + { + ui::select({.options = optionsOf(AccountsController::kindChoices()), + .selected = [form] { return keyOf(form->kind.get()); }, + .onSelect = [ctl](ui::Key key) { ctl->chooseKind(idOf(key)); }}), + ui::select({.options = optionsOf(AccountsController::currencyChoices()), + .selected = [form] { return keyOf(form->currency.get()); }, + .onSelect = [ctl](ui::Key key) { ctl->chooseCurrency(idOf(key)); }}), + ui::textInput({.value = [form] { return form->overdraft.get(); }, + .onChange = [ctl](std::string text) { ctl->editOverdraft(std::move(text)); }, + .placeholder = "Overdraft (opt.)"}), + ui::button({.label = "Open account", .onClick = [ctl] { ctl->openAccount(); }}), + }, + .gap = 1, + }), + }); + + ui::Node list = ui::forEach( + [ctl] { return ctl->cards(); }, [](AccountCard const& card) { return ui::Key{card.id}; }, + [](morph::reactive::Signal const& card) { + auto const* const row = &card; + return ui::panel({ + .padding = 1, + .child = ui::column({.children = { + ui::row({.children = { + ui::text({.text = [row] { return row->get().kind; }, + .role = ui::TextRole::Heading}), + ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::text({.text = [row] { return row->get().statusText; }, + .role = [row] { return roleOf(row->get().statusTone); }}), + }}), + ui::text({.text = [row] { return row->get().number; }, + .role = ui::TextRole::Muted}), + ui::text({.text = [row] { return row->get().balanceText; }, + .role = ui::TextRole::Heading}), + ui::text({.text = [row] { return row->get().overdraftText; }, + .role = ui::TextRole::Muted, + .common = {.visible = [row] { return row->get().hasOverdraft; }}}), + }}), + }); + }, + ui::Axis::Vertical, 1); + + return ui::column({ + .children = {summary, opener, + ui::scroll({.child = list, .common = {.layout = {.height = ui::Sizing::stretch()}}})}, + .gap = 1, + }); +} + +} // namespace bank::client::views +``` + +- [ ] **Step 4: Generate the golden, then run the tests to verify they pass** + +```bash +cmake --build build/bank --target bank_app_tests +BANK_UPDATE_GOLDEN=1 ./build/bank/examples/bank/bank_app_tests "Accounts view: the empty screen, then an account opened through it" +cat examples/bank/tests/app/golden/accounts.txt +``` + +Review it: a Panel with a Heading Text `0` and a Muted Text `0 open account(s)`; a Panel titled `New account` +holding a Select with options `Checking`, `Savings`, `Credit` and `Checking` selected (`selected=0`), a Select +with options `USD` … `JPY` and `USD` selected, a TextInput `Overdraft (opt.)`, a Button `Open account`; a `Scroll` +holding the list, an empty `Column`. Nothing names an account. + +Run: `./build/bank/examples/bank/bank_app_tests "[bank][app]"` +Expected: PASS — 24 test cases. + +Mutation checks, each restored afterwards: +1. In `summarize`, delete the `continue;` that skips closed accounts. Expected FAIL in "a closed account is listed + but not counted" (`"2 open account(s)"`). +2. Make `_list`'s key return `dto::ListAccounts{}` unconditionally. Expected FAIL in "signing out empties the list + and the total returns to —" (`cards()` keeps one entry). + +- [ ] **Step 5: Commit** + +```bash +git add examples/bank/app examples/bank/tests/app examples/bank/CMakeLists.txt +git commit -m "wip(bank): AccountsController and the accounts screen + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 4: `TransactionsController` and the move-money screen + +The old `TransactionController` + `MoveMoneyPage.qml`, and the controller spec 1 §4b uses as its counter-example. +The old controller fetched history in whichever `.then` remembered to call `reloadHistory()`, so selecting A and +then B let A's late reply overwrite B's history. Here the history is a `Query` keyed on the selection (latest +wins); deposits, withdrawals and transfers are `Mutation`s that invalidate the account list and the history in one +batch; the selection is derived (`resolveChoice`), so the refresh a deposit causes keeps it. This task carries the +two tests that pin both properties: the A/B ordering (Review Focus 1) and the old live-engine QML test's +pick → deposit → still picked → deposit again sequence (Review Focus 2), now without an engine — a view binds the +picker to `selectedAccount()`, so there is no widget state that could drift from the controller's. + +**Files:** +- Create: `examples/bank/app/controllers/transactions_controller.hpp`, + `examples/bank/app/controllers/transactions_controller.cpp` +- Create: `examples/bank/app/views/move_money_view.hpp`, `examples/bank/app/views/move_money_view.cpp` +- Create: `examples/bank/tests/app/golden/move_money.txt` (generated in Step 4) +- Modify: `examples/bank/CMakeLists.txt` — append `app/controllers/transactions_controller.cpp` and + `app/views/move_money_view.cpp` to `BANK_APP_SOURCES`; append `tests/app/test_transactions.cpp` to `bank_app_tests` +- Test: `examples/bank/tests/app/test_transactions.cpp` + +**Interfaces:** +- Consumes: Tasks 1–3; `reactive::Query`'s fetcher constructor `Query(Runtime&, Fetch, Key, QueryOptions = {})` + (Part 1, the test seam); `async::Completion::makeSettleable(exec::IExecutor*) -> {Completion, + Completion::Promise}` and `Promise::resolve(T)` (`include/morph/core/completion.hpp:699`); `ui::table`, + `ui::TableColumn` (Part 2); `dto::{History, HistoryPage, TxnInfo, Deposit, Withdraw, Transfer, TransferResult}`, + `bank::{TxnDirection, TxnKind, currencyDecimals}`. +- Produces: `namespace bank::client::movemoney { State{chosenAccount, amount, chosenTarget, transferAmount}; + ChooseAccount, EditAmount, ChooseTarget, EditTransferAmount, ClearAmount, ClearTransferAmount; Msg; init; Update; }`; + `struct MoveAccount`; `struct HistoryRow`; `using HistoryFetch`; `class TransactionsController { + TransactionsController(Runtime&, BankHandlers&, AuthController const&, Notices&, HistoryFetch = {}); + chooseAccount, editAmount, chooseTarget, editTransferAmount, deposit(), withdraw(), transfer(), refresh(); form(), + accountChoices(), selectedAccount(), selectedTarget(), history(), canMove(), canTransfer(), accountsPending(), + historyPending(); }`; `views::moveMoneyView(TransactionsController&)`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/bank/tests/app/test_transactions.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/core/types.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank_app_test_support.hpp" +#include "controllers/transactions_controller.hpp" +#include "recorded.hpp" +#include "views/move_money_view.hpp" + +using bank::client::Choice; +using bank::client::HistoryFetch; +using bank::client::Tone; +using bank::client::TransactionsController; +using bank::testing::BankWiring; +using bank::testing::RecordingBackend; +using bank::testing::settle; + +namespace { + +// Holds every history request until the test settles it, in whatever order the test chooses. +class HeldHistory { +public: + explicit HeldHistory(morph::exec::IExecutor& owner) : _owner{&owner} {} + + [[nodiscard]] HistoryFetch fetch() { + return [this](bank::dto::History const& request) { + auto [completion, promise] = morph::async::Completion::makeSettleable(_owner); + _calls.push_back(Call{.accountId = request.accountId, .promise = std::move(promise)}); + return std::move(completion); + }; + } + + [[nodiscard]] std::size_t calls() const noexcept { return _calls.size(); } + [[nodiscard]] std::int64_t accountOf(std::size_t index) const { return _calls.at(index).accountId; } + void resolve(std::size_t index, bank::dto::HistoryPage page) { _calls.at(index).promise.resolve(std::move(page)); } + +private: + struct Call { + std::int64_t accountId = 0; + morph::async::Completion::Promise promise; + }; + morph::exec::IExecutor* _owner; + std::vector _calls; +}; + +bank::dto::HistoryPage onePage(std::int64_t accountId, bank::TxnKind kind, bank::TxnDirection direction, + std::int64_t amountMinor, std::int64_t balanceMinor) { + return bank::dto::HistoryPage{ + .accountId = accountId, + .entries = {bank::dto::TxnInfo{.id = accountId * 100, + .accountId = accountId, + .direction = static_cast(direction), + .kind = static_cast(kind), + .amountMinor = amountMinor, + .currency = 0, + .balanceAfterMinor = balanceMinor}}, + }; +} + +std::string labelOf(TransactionsController const& txns, std::int64_t accountId) { + for (Choice const& choice : txns.accountChoices()) { + if (choice.id == accountId) { + return choice.label; + } + } + return {}; +} + +} // namespace + +// Review Focus 1. +TEST_CASE("TransactionsController: a late reply for the previously selected account does not replace the " + "shown history", + "[bank][app][move-money]") { + BankWiring wiring; + wiring.signUp("txn-ab"); + std::int64_t const first = wiring.openAccount(0, 0); + std::int64_t const second = wiring.openAccount(1, 0); + HeldHistory held{wiring.ctx.executor()}; + TransactionsController txns{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices, held.fetch()}; + + REQUIRE(settle(wiring.ctx, [&held] { return held.calls() == 1; })); + REQUIRE(held.accountOf(0) == first); // the first account, selected until the user picks + txns.chooseAccount(second); + REQUIRE(settle(wiring.ctx, [&held] { return held.calls() == 2; })); + REQUIRE(held.accountOf(1) == second); + + // B answers first; A's reply arrives after it. + held.resolve(1, onePage(second, bank::TxnKind::Deposit, bank::TxnDirection::Credit, 100, 100)); + held.resolve(0, onePage(first, bank::TxnKind::Withdrawal, bank::TxnDirection::Debit, 999, -999)); + bank::testing::drain(wiring.ctx); + + REQUIRE(txns.history().size() == 1); + CHECK(txns.history().front().kind == "Deposit"); + CHECK(txns.history().front().amountText == "+1.00 USD"); + CHECK(txns.selectedAccount() == second); +} + +// Review Focus 2. +TEST_CASE("TransactionsController: the selection survives the refresh a deposit causes, and the next deposit " + "lands in the shown account", + "[bank][app][move-money]") { + BankWiring wiring; + wiring.signUp("txn-move"); + std::int64_t const checking = wiring.openAccount(0, 0); + std::int64_t const savings = wiring.openAccount(1, 0); + TransactionsController txns{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&txns] { return txns.accountChoices().size() == 2; })); + CHECK(txns.selectedAccount() == checking); + + txns.chooseAccount(savings); + REQUIRE(txns.selectedAccount() == savings); + txns.editAmount("50.00"); + txns.deposit(); + CHECK(txns.form().amount.peek().empty()); + REQUIRE(settle(wiring.ctx, [&txns, savings] { return labelOf(txns, savings).ends_with("50.00 USD"); })); + CHECK(wiring.balanceOf(savings) == 5000); + CHECK(txns.selectedAccount() == savings); // the refresh did not move the selection + + std::int64_t const shown = txns.selectedAccount(); + txns.editAmount("10.00"); + txns.deposit(); + REQUIRE(settle(wiring.ctx, [&txns, savings] { return labelOf(txns, savings).ends_with("60.00 USD"); })); + CHECK(wiring.balanceOf(shown) == 6000); + CHECK(wiring.balanceOf(savings) == 6000); + CHECK(wiring.balanceOf(checking) == 0); +} + +TEST_CASE("TransactionsController: a deposit confirms itself in the notice strip", "[bank][app][move-money]") { + BankWiring wiring; + wiring.signUp("txn-toast"); + static_cast(wiring.openAccount(0, 0)); + TransactionsController txns{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&txns] { return txns.accountChoices().size() == 1; })); + txns.editAmount("200.00"); + txns.deposit(); + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.notices.visible(); })); + CHECK(wiring.notices.text() == "Transaction posted"); + CHECK(wiring.notices.tone() == Tone::Good); +} + +TEST_CASE("TransactionsController: history lists the selected account's entries, newest first", + "[bank][app][move-money]") { + BankWiring wiring; + wiring.signUp("txn-history"); + std::int64_t const account = wiring.openAccount(0, 0); + TransactionsController txns{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&txns] { return txns.accountChoices().size() == 1; })); + txns.editAmount("100.00"); + txns.deposit(); + REQUIRE(settle(wiring.ctx, [&txns, account] { return labelOf(txns, account).ends_with("100.00 USD"); })); + txns.editAmount("30.00"); + txns.withdraw(); + REQUIRE(settle(wiring.ctx, [&txns] { return txns.history().size() == 2 && !txns.historyPending(); })); + + CHECK(txns.history().front().kind == "Withdrawal"); + CHECK(txns.history().front().amountText == "−30.00 USD"); + CHECK(txns.history().front().amountTone == Tone::Bad); + CHECK(txns.history().front().balanceText == "70.00 USD"); + CHECK(txns.history().back().kind == "Deposit"); + CHECK(txns.history().back().amountText == "+100.00 USD"); + CHECK(txns.history().back().amountTone == Tone::Good); +} + +TEST_CASE("TransactionsController: a transfer moves money to the chosen target", "[bank][app][move-money]") { + BankWiring wiring; + wiring.signUp("txn-transfer"); + std::int64_t const checking = wiring.openAccount(0, 0); + std::int64_t const savings = wiring.openAccount(1, 0); + wiring.depositInto(checking, 10000); + TransactionsController txns{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&txns] { return txns.accountChoices().size() == 2; })); + CHECK(txns.selectedTarget() == checking); // the first account, until the user picks + txns.chooseTarget(savings); + txns.editTransferAmount("40.00"); + REQUIRE(txns.canTransfer()); + txns.transfer(); + REQUIRE(settle(wiring.ctx, [&txns, savings] { return labelOf(txns, savings).ends_with("40.00 USD"); })); + CHECK(wiring.balanceOf(checking) == 6000); + CHECK(wiring.balanceOf(savings) == 4000); +} + +TEST_CASE("TransactionsController: a refused withdrawal shows the model's error", "[bank][app][move-money]") { + BankWiring wiring; + wiring.signUp("txn-refused"); + static_cast(wiring.openAccount(0, 0)); + TransactionsController txns{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&txns] { return txns.accountChoices().size() == 1; })); + txns.editAmount("5.00"); + txns.withdraw(); + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.notices.visible(); })); + CHECK(wiring.notices.tone() == Tone::Bad); + CHECK_FALSE(wiring.notices.text().empty()); + CHECK(wiring.notices.text() != "Transaction posted"); +} + +TEST_CASE("TransactionsController: amounts use the selected account's currency decimals", "[bank][app][move-money]") { + BankWiring wiring; + wiring.signUp("txn-jpy"); + std::int64_t const yen = wiring.openAccount(0, 4); + TransactionsController txns{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&txns] { return txns.accountChoices().size() == 1; })); + txns.editAmount("1200"); + txns.deposit(); + REQUIRE(settle(wiring.ctx, [&txns, yen] { return labelOf(txns, yen).ends_with("1200 JPY"); })); + CHECK(wiring.balanceOf(yen) == 1200); +} + +TEST_CASE("TransactionsController: moving money needs a valid amount and an account", "[bank][app][move-money]") { + BankWiring wiring; + wiring.signUp("txn-valid"); + TransactionsController txns{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&txns] { return !txns.accountsPending(); })); + txns.editAmount("5"); + CHECK_FALSE(txns.canMove()); // no account + static_cast(wiring.openAccount(0, 0)); + txns.refresh(); + REQUIRE(settle(wiring.ctx, [&txns] { return txns.accountChoices().size() == 1; })); + CHECK(txns.canMove()); + txns.editAmount("abc"); + CHECK_FALSE(txns.canMove()); + CHECK_FALSE(txns.canTransfer()); +} + +TEST_CASE("Move money view: the picker and the buttons drive the controller", "[bank][app][view][move-money]") { + BankWiring wiring; + wiring.signUp("view-move"); + TransactionsController txns{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&txns] { return !txns.accountsPending(); })); + RecordingBackend rec; + auto const mounted = bank::testing::mount(wiring.ctx, rec, bank::client::views::moveMoneyView(txns)); + bank::testing::checkGolden("move_money", rec.dump()); + + static_cast(wiring.openAccount(0, 0)); + std::int64_t const savings = wiring.openAccount(1, 0); + txns.refresh(); + REQUIRE(settle(wiring.ctx, [&txns] { return txns.accountChoices().size() == 2; })); + bank::testing::drain(wiring.ctx); + std::vector const selects = rec.all(bank::testing::kSelect); + REQUIRE(selects.size() == 2); // the account, then the transfer target + rec.choose(selects.at(0), morph::ui::Key{savings}); + CHECK(txns.selectedAccount() == savings); + + int const deposit = bank::testing::button(rec, "Deposit"); + bank::testing::drain(wiring.ctx); + CHECK_FALSE(bank::testing::enabled(rec, deposit)); + rec.edit(bank::testing::input(rec, "Amount"), "50.00"); + bank::testing::drain(wiring.ctx); + CHECK(bank::testing::enabled(rec, deposit)); + rec.click(deposit); + REQUIRE(settle(wiring.ctx, [&txns] { return txns.history().size() == 1; })); + bank::testing::drain(wiring.ctx); + CHECK(wiring.balanceOf(savings) == 5000); + CHECK(bank::testing::shows(rec, "+50.00 USD")); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/bank --target bank_app_tests` +Expected: FAIL — `fatal error: 'controllers/transactions_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bank/app/controllers/transactions_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/dto/account_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "bank_handlers.hpp" +#include "choice.hpp" +#include "notices.hpp" +#include "tone.hpp" + +/// @file +/// The move-money screen: deposit, withdraw and transfer for a selected account, and that account's +/// recent activity. +/// +/// The history is a `Query` keyed on the selection, so selecting A and then B supersedes A's +/// request: a late reply for A is dropped instead of overwriting B's history. The selection is the +/// user's pick while it is still listed, else the first open account, so the refresh every +/// successful move causes keeps naming the account the next move lands in. + +namespace bank::client { + +class AuthController; + +/// @brief The move-money form's user intent. +namespace movemoney { + +struct State { + morph::reactive::Signal chosenAccount; ///< the user's pick; 0 for none + morph::reactive::Signal amount; ///< deposit/withdraw amount, as typed + morph::reactive::Signal chosenTarget; ///< the user's transfer target; 0 for none + morph::reactive::Signal transferAmount; ///< transfer amount, as typed +}; + +struct ChooseAccount { + std::int64_t id = 0; +}; +struct EditAmount { + std::string text; +}; +struct ChooseTarget { + std::int64_t id = 0; +}; +struct EditTransferAmount { + std::string text; +}; +struct ClearAmount {}; +struct ClearTransferAmount {}; + +using Msg = std::variant; + +/// @brief Nothing picked, nothing typed. +[[nodiscard]] State init(morph::reactive::Runtime& runtime); + +struct Update { + void operator()(State& state, ChooseAccount const& msg) const; + void operator()(State& state, EditAmount const& msg) const; + void operator()(State& state, ChooseTarget const& msg) const; + void operator()(State& state, EditTransferAmount const& msg) const; + void operator()(State& state, ClearAmount const& msg) const; + void operator()(State& state, ClearTransferAmount const& msg) const; +}; + +} // namespace movemoney + +/// @brief An open account as the pickers list it. +struct MoveAccount { + std::int64_t id = 0; + std::string label; ///< masked number and balance + int currency = 0; ///< what its amounts are parsed in + bool operator==(MoveAccount const&) const = default; +}; + +/// @brief One ledger entry as the activity table shows it. +struct HistoryRow { + std::int64_t id = 0; + std::string kind; ///< `Deposit`, `Transfer in`, … + std::string amountText; ///< `+` for money in, `−` for money out + Tone amountTone = Tone::Neutral; + std::string balanceText; ///< the balance after the entry + bool operator==(HistoryRow const&) const = default; +}; + +/// @brief Fetches one page of an account's history. Production fetches through the bridge; a test +/// passes one whose replies it settles in any order. +using HistoryFetch = std::function(dto::History const&)>; + +/// @brief The move-money screen's state and commands. +class TransactionsController { +public: + /// @param history Empty: fetch through `handlers.transactions`. + TransactionsController(morph::reactive::Runtime& runtime, BankHandlers& handlers, AuthController const& auth, + Notices& notices, HistoryFetch history = {}); + + void chooseAccount(std::int64_t chosenId); + void editAmount(std::string text); + void chooseTarget(std::int64_t chosenId); + void editTransferAmount(std::string text); + /// @brief Deposits the amount into the selected account; does nothing while `canMove()` is false. + void deposit(); + /// @brief Withdraws the amount from the selected account; does nothing while `canMove()` is false. + void withdraw(); + /// @brief Moves the transfer amount to the target; does nothing while `canTransfer()` is false. + void transfer(); + /// @brief Fetches the accounts and the history again. + void refresh(); + + [[nodiscard]] movemoney::State const& form() const noexcept { return _form.state(); } + [[nodiscard]] std::vector const& accountChoices() const { return _choices.get(); } + /// @brief The account the moves act on: the user's pick while listed, else the first. Tracked. + [[nodiscard]] std::int64_t selectedAccount() const { return _selected.get(); } + [[nodiscard]] std::int64_t selectedTarget() const { return _target.get(); } + [[nodiscard]] std::vector const& history() const { return _historyRows.get(); } + [[nodiscard]] bool canMove() const { return _canMove.get(); } + [[nodiscard]] bool canTransfer() const { return _canTransfer.get(); } + [[nodiscard]] bool accountsPending() const { return _accounts.pending(); } + [[nodiscard]] bool historyPending() const { return _history.pending(); } + +private: + morph::reactive::Runtime* _rt; + morph::reactive::Store _form; + morph::reactive::Query _accounts; + // The history query's key reads these two, and its effect runs in its constructor: they precede it. + morph::reactive::Computed> _openAccounts; + morph::reactive::Computed _selected; + morph::reactive::Query _history; + morph::reactive::Mutation _deposit; + morph::reactive::Mutation _withdraw; + morph::reactive::Mutation _transfer; + morph::reactive::Computed> _choices; + morph::reactive::Computed _target; + morph::reactive::Computed _currency; + morph::reactive::Computed> _historyRows; + morph::reactive::Computed> _amountMinor; + morph::reactive::Computed> _transferMinor; + morph::reactive::Computed _canMove; + morph::reactive::Computed _canTransfer; + FailureReporter _failures; + // Last member, so it is destroyed first: a confirmation still in flight is dropped. + morph::async::CallbackScope _confirmations; +}; + +} // namespace bank::client +``` + +Create `examples/bank/app/controllers/transactions_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "controllers/transactions_controller.hpp" + +#include + +#include "bank/core/types.hpp" +#include "controllers/auth_controller.hpp" +#include "format.hpp" + +namespace bank::client { + +namespace movemoney { + +State init(morph::reactive::Runtime& runtime) { + return State{.chosenAccount{runtime, 0}, + .amount{runtime, std::string{}}, + .chosenTarget{runtime, 0}, + .transferAmount{runtime, std::string{}}}; +} + +void Update::operator()(State& state, ChooseAccount const& msg) const { state.chosenAccount.set(msg.id); } +void Update::operator()(State& state, EditAmount const& msg) const { state.amount.set(msg.text); } +void Update::operator()(State& state, ChooseTarget const& msg) const { state.chosenTarget.set(msg.id); } +void Update::operator()(State& state, EditTransferAmount const& msg) const { state.transferAmount.set(msg.text); } +void Update::operator()(State& state, ClearAmount const& /*msg*/) const { state.amount.set(std::string{}); } + +void Update::operator()(State& state, ClearTransferAmount const& /*msg*/) const { + state.transferAmount.set(std::string{}); +} + +} // namespace movemoney + +namespace { + +std::vector openAccountsOf(std::optional const& list) { + std::vector accounts; + if (!list.has_value()) { + return accounts; + } + for (dto::AccountInfo const& account : list->accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + accounts.push_back(MoveAccount{ + .id = account.id, + .label = fmt::last4(account.number) + " · " + fmt::money(account.balanceMinor, account.currency), + .currency = account.currency, + }); + } + return accounts; +} + +HistoryRow rowOf(dto::TxnInfo const& entry) { + bool const credit = entry.direction == static_cast(bank::TxnDirection::Credit); + return HistoryRow{ + .id = entry.id, + .kind = fmt::txnKind(entry.kind), + .amountText = (credit ? "+" : "−") + fmt::money(entry.amountMinor, entry.currency), + .amountTone = credit ? Tone::Good : Tone::Bad, + .balanceText = fmt::money(entry.balanceAfterMinor, entry.currency), + }; +} + +std::optional amountIn(std::string const& text, int currency) { + return fmt::parseMinor(text, bank::currencyDecimals(static_cast(currency))); +} + +} // namespace + +TransactionsController::TransactionsController(morph::reactive::Runtime& runtime, BankHandlers& handlers, + AuthController const& auth, Notices& notices, HistoryFetch history) + : _rt{&runtime}, + _form{runtime, &movemoney::init, movemoney::Update{}}, + _accounts{runtime, handlers.customers, + [session = &auth]() -> std::optional { + if (!session->signedIn()) { + return std::nullopt; + } + return dto::ListAccounts{}; + }}, + _openAccounts{runtime, [this] { return openAccountsOf(_accounts.value()); }}, + _selected{runtime, [this] { return resolveChoice(_openAccounts.get(), _form.state().chosenAccount.get()); }}, + _history{runtime, + history ? std::move(history) + : HistoryFetch{[&handlers](dto::History const& request) { + return handlers.transactions.execute(request); + }}, + [this]() -> std::optional { + std::int64_t const account = _selected.get(); + if (account == 0) { + return std::nullopt; + } + return dto::History{.accountId = account, .limit = 50}; + }}, + _deposit{runtime, confirmed(handlers.transactions, notices, _confirmations, "Transaction posted"), + morph::reactive::MutationOptions{.invalidates = {&_accounts, &_history}}}, + _withdraw{runtime, confirmed(handlers.transactions, notices, _confirmations, "Transaction posted"), + morph::reactive::MutationOptions{.invalidates = {&_accounts, &_history}}}, + _transfer{runtime, confirmed(handlers.transactions, notices, _confirmations, "Transaction posted"), + morph::reactive::MutationOptions{.invalidates = {&_accounts, &_history}}}, + _choices{runtime, + [this] { + std::vector choices; + for (MoveAccount const& account : _openAccounts.get()) { + choices.push_back(Choice{.id = account.id, .label = account.label}); + } + return choices; + }}, + _target{runtime, [this] { return resolveChoice(_openAccounts.get(), _form.state().chosenTarget.get()); }}, + _currency{runtime, + [this] { + std::int64_t const selected = _selected.get(); + for (MoveAccount const& account : _openAccounts.get()) { + if (account.id == selected) { + return account.currency; + } + } + return 0; + }}, + _historyRows{runtime, + [this] { + std::vector rows; + if (std::optional const& page = _history.value(); page.has_value()) { + rows.reserve(page->entries.size()); + for (dto::TxnInfo const& entry : page->entries) { + rows.push_back(rowOf(entry)); + } + } + return rows; + }}, + _amountMinor{runtime, [this] { return amountIn(_form.state().amount.get(), _currency.get()); }}, + _transferMinor{runtime, [this] { return amountIn(_form.state().transferAmount.get(), _currency.get()); }}, + _canMove{runtime, [this] { return _amountMinor.get().has_value() && _selected.get() != 0; }}, + _canTransfer{runtime, + [this] { + return _transferMinor.get().has_value() && _selected.get() != 0 && _target.get() != 0; + }}, + _failures{runtime, notices, + {[this] { return _accounts.error(); }, [this] { return _history.error(); }, + [this] { return _deposit.error(); }, [this] { return _withdraw.error(); }, + [this] { return _transfer.error(); }}} {} + +void TransactionsController::chooseAccount(std::int64_t chosenId) { _form.send(movemoney::ChooseAccount{chosenId}); } +void TransactionsController::editAmount(std::string text) { _form.send(movemoney::EditAmount{std::move(text)}); } +void TransactionsController::chooseTarget(std::int64_t chosenId) { _form.send(movemoney::ChooseTarget{chosenId}); } + +void TransactionsController::editTransferAmount(std::string text) { + _form.send(movemoney::EditTransferAmount{std::move(text)}); +} + +void TransactionsController::deposit() { + if (!_canMove.peek()) { + return; + } + _deposit.run(dto::Deposit{.accountId = _selected.peek(), .amountMinor = _amountMinor.peek().value_or(0)}); + _form.send(movemoney::ClearAmount{}); +} + +void TransactionsController::withdraw() { + if (!_canMove.peek()) { + return; + } + _withdraw.run(dto::Withdraw{.accountId = _selected.peek(), .amountMinor = _amountMinor.peek().value_or(0)}); + _form.send(movemoney::ClearAmount{}); +} + +void TransactionsController::transfer() { + if (!_canTransfer.peek()) { + return; + } + _transfer.run(dto::Transfer{.fromAccountId = _selected.peek(), + .toAccountId = _target.peek(), + .amountMinor = _transferMinor.peek().value_or(0)}); + _form.send(movemoney::ClearTransferAmount{}); +} + +void TransactionsController::refresh() { + _rt->batch([this] { + _accounts.refetch(); + _history.refetch(); + }); +} + +} // namespace bank::client +``` + +Create `examples/bank/app/views/move_money_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "controllers/transactions_controller.hpp" + +namespace bank::client::views { + +/// @brief The move-money screen: the account picker, deposit/withdraw, transfer, recent activity. +/// @param txns Must outlive the mounted view. +/// @return The view. +[[nodiscard]] morph::ui::Node moveMoneyView(TransactionsController& txns); + +} // namespace bank::client::views +``` + +Create `examples/bank/app/views/move_money_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "views/move_money_view.hpp" + +#include +#include +#include +#include + +#include "views/style.hpp" + +namespace bank::client::views { + +namespace ui = morph::ui; + +ui::Node moveMoneyView(TransactionsController& txns) { + TransactionsController* const ctl = &txns; + movemoney::State const* const form = &txns.form(); + auto const accountOptions = [ctl] { return optionsOf(ctl->accountChoices()); }; + ui::Common const stretch{.layout = {.width = ui::Sizing::stretch()}}; + + ui::Node pickRow = ui::row({ + .children = {ui::text({.text = "Account", .role = ui::TextRole::Muted}), + ui::select({.options = accountOptions, + .selected = [ctl] { return accountKeyOf(ctl->selectedAccount()); }, + .onSelect = [ctl](ui::Key key) { ctl->chooseAccount(idOf(key)); }, + .common = stretch})}, + .gap = 1, + }); + + ui::Node amountRow = ui::row({ + .children = {ui::textInput({.value = [form] { return form->amount.get(); }, + .onChange = [ctl](std::string text) { ctl->editAmount(std::move(text)); }, + .placeholder = "Amount", + .common = stretch}), + ui::button({.label = "Deposit", + .onClick = [ctl] { ctl->deposit(); }, + .common = {.enabled = [ctl] { return ctl->canMove(); }}}), + ui::button({.label = "Withdraw", + .onClick = [ctl] { ctl->withdraw(); }, + .common = {.enabled = [ctl] { return ctl->canMove(); }}})}, + .gap = 1, + }); + + ui::Node transferRow = ui::row({ + .children = {ui::button({.label = "Transfer to", + .onClick = [ctl] { ctl->transfer(); }, + .common = {.enabled = [ctl] { return ctl->canTransfer(); }}}), + ui::select({.options = accountOptions, + .selected = [ctl] { return accountKeyOf(ctl->selectedTarget()); }, + .onSelect = [ctl](ui::Key key) { ctl->chooseTarget(idOf(key)); }, + .common = stretch}), + ui::textInput({.value = [form] { return form->transferAmount.get(); }, + .onChange = [ctl](std::string text) { ctl->editTransferAmount(std::move(text)); }, + .placeholder = "Transfer amount", + .common = stretch})}, + .gap = 1, + }); + + ui::Node activity = ui::table( + {ui::TableColumn{.label = "TYPE", .width = ui::Sizing::stretch()}, + ui::TableColumn{.label = "AMOUNT", .width = ui::Sizing::fixed(16)}, + ui::TableColumn{.label = "BALANCE", .width = ui::Sizing::fixed(16)}}, + [ctl] { return ctl->history(); }, [](HistoryRow const& row) { return ui::Key{row.id}; }, + [](morph::reactive::Signal const& entry) { + auto const* const row = &entry; + return std::vector{ + ui::text({.text = [row] { return row->get().kind; }}), + ui::text({.text = [row] { return row->get().amountText; }, + .role = [row] { return roleOf(row->get().amountTone); }}), + ui::text({.text = [row] { return row->get().balanceText; }}), + }; + }); + + return ui::column({ + .children = {ui::panel({.title = "Move money", + .padding = 1, + .child = ui::column({.children = {pickRow, amountRow, transferRow}, .gap = 1})}), + ui::panel({.title = "Recent activity", + .padding = 1, + .child = activity, + .common = {.layout = {.height = ui::Sizing::stretch()}}})}, + .gap = 1, + }); +} + +} // namespace bank::client::views +``` + +- [ ] **Step 4: Generate the golden, then run the tests to verify they pass** + +```bash +cmake --build build/bank --target bank_app_tests +BANK_UPDATE_GOLDEN=1 ./build/bank/examples/bank/bank_app_tests "Move money view: the picker and the buttons drive the controller" +cat examples/bank/tests/app/golden/move_money.txt +``` + +Review it: a Panel `Move money` with three `Row`s — a Muted Text `Account` and a Select with `options=[] +selected=none`; a TextInput `Amount` and Buttons `Deposit`, `Withdraw` with `enabled=false`; a Button `Transfer to` +(`enabled=false`), a Select with `options=[] selected=none`, a TextInput `Transfer amount` — and a Panel `Recent +activity` with a `Table` of `columns=[TYPE:stretch(1),AMOUNT:fixed(16),BALANCE:fixed(16)]` and no `Row`s. + +Run: `./build/bank/examples/bank/bank_app_tests "[bank][app]"` +Expected: PASS — 33 test cases. + +Mutation checks, each restored afterwards: +1. In `include/morph/reactive/control.hpp`, `Query::issue`, delete `_inflight.reset();` — the supersede this + controller's history relies on. Expected FAIL in "a late reply for the previously selected account does not + replace the shown history" (`kind == "Withdrawal"`). +2. In `_selected`, pass `0` instead of `_form.state().chosenAccount.get()` to `resolveChoice`. Expected FAIL in + "the selection survives the refresh a deposit causes, …" at `REQUIRE(txns.selectedAccount() == savings)`. +3. Remove `&_history` from `_withdraw`'s `invalidates`. Expected FAIL in "history lists the selected account's + entries, newest first" (times out with one entry). + +- [ ] **Step 5: Commit** + +```bash +git add examples/bank/app examples/bank/tests/app examples/bank/CMakeLists.txt +git commit -m "wip(bank): TransactionsController, a history keyed on the selection, and the move-money screen + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 5: `CardsController` and the cards screen + +The old `CardController` + `CardsPage.qml`: issue a card on an account, then freeze, unfreeze or cancel it. The +freeze/unfreeze choice — a conditional the QML made on `modelData.active` — moves into `toggleFreeze`. + +**Files:** +- Create: `examples/bank/app/account_choices.hpp` +- Create: `examples/bank/app/controllers/cards_controller.hpp`, `examples/bank/app/controllers/cards_controller.cpp` +- Create: `examples/bank/app/views/cards_view.hpp`, `examples/bank/app/views/cards_view.cpp` +- Create: `examples/bank/tests/app/golden/cards.txt` (generated in Step 4) +- Modify: `examples/bank/CMakeLists.txt` — append `app/controllers/cards_controller.cpp` and `app/views/cards_view.cpp` + to `BANK_APP_SOURCES`; append `tests/app/test_cards.cpp` to `bank_app_tests` +- Test: `examples/bank/tests/app/test_cards.cpp` + +**Interfaces:** +- Consumes: Tasks 1–4; `dto::{ListCards, CardList, CardInfo, IssueCard, FreezeCard, UnfreezeCard, CancelCard, + CommandResult}` (`include/bank/dto/card_dto.hpp`), `bank::{CardKind, CardStatus}`. +- Produces: `openAccountChoices(std::optional const&) -> std::vector` + (`account_choices.hpp`, used again by Tasks 6–7); `namespace bank::client::issuing { State{account, kind, limit}; + ChooseAccount, ChooseKind, EditLimit, ClearLimit; Msg; init; Update; }`; `struct CardRow`; `class CardsController { + CardsController(Runtime&, BankHandlers&, AuthController const&, Notices&); chooseAccount, chooseKind, editLimit, + issue(), toggleFreeze(id), cancel(id), refresh(); static kindChoices(); form(), accountChoices(), + selectedAccount(), cards(), canIssue(), loaded(); }`; `views::cardsView(CardsController&)`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/bank/tests/app/test_cards.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include "bank_app_test_support.hpp" +#include "controllers/cards_controller.hpp" +#include "recorded.hpp" +#include "views/cards_view.hpp" + +using bank::client::CardRow; +using bank::client::CardsController; +using bank::client::Tone; +using bank::testing::BankWiring; +using bank::testing::RecordingBackend; +using bank::testing::settle; + +TEST_CASE("CardsController: an issued card is listed with its limit and can be frozen, unfrozen and cancelled", + "[bank][app][cards]") { + BankWiring wiring; + wiring.signUp("cards-life"); + std::int64_t const account = wiring.openAccount(0, 0); + CardsController cards{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&cards] { return cards.accountChoices().size() == 1 && cards.loaded(); })); + CHECK(cards.selectedAccount() == account); + + cards.chooseKind(0); + cards.editLimit("1000"); + cards.issue(); + CHECK(cards.form().limit.peek().empty()); + REQUIRE(settle(wiring.ctx, [&cards] { return cards.cards().size() == 1; })); + CardRow const issued = cards.cards().front(); + CHECK(issued.title.starts_with("Debit card ••••")); + CHECK(issued.limitText == "Daily limit 1000.00 USD"); + CHECK(issued.statusText == "Active"); + CHECK(issued.statusTone == Tone::Good); + CHECK(issued.toggleLabel == "Freeze"); + CHECK(issued.active); + CHECK_FALSE(issued.cancelled); + + cards.toggleFreeze(issued.id); + REQUIRE(settle(wiring.ctx, [&cards] { return cards.cards().front().statusText == "Frozen"; })); + CHECK(cards.cards().front().statusTone == Tone::Warn); + CHECK(cards.cards().front().toggleLabel == "Unfreeze"); + + cards.toggleFreeze(issued.id); + REQUIRE(settle(wiring.ctx, [&cards] { return cards.cards().front().statusText == "Active"; })); + + cards.cancel(issued.id); + REQUIRE(settle(wiring.ctx, [&cards] { return cards.cards().front().cancelled; })); + CHECK(cards.cards().front().statusText == "Cancelled"); + CHECK(cards.cards().front().statusTone == Tone::Bad); +} + +TEST_CASE("CardsController: issuing needs an account", "[bank][app][cards]") { + BankWiring wiring; + wiring.signUp("cards-none"); + CardsController cards{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&cards] { return cards.loaded(); })); + CHECK_FALSE(cards.canIssue()); + CHECK(cards.selectedAccount() == 0); +} + +TEST_CASE("CardsController: a blank limit issues a card with none", "[bank][app][cards]") { + BankWiring wiring; + wiring.signUp("cards-blank"); + static_cast(wiring.openAccount(0, 0)); + CardsController cards{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&cards] { return cards.canIssue(); })); + cards.chooseKind(1); + cards.issue(); + REQUIRE(settle(wiring.ctx, [&cards] { return cards.cards().size() == 1; })); + CHECK(cards.cards().front().title.starts_with("Credit card ••••")); + CHECK(cards.cards().front().limitText == "Daily limit 0.00 USD"); +} + +TEST_CASE("Cards view: issuing, freezing and cancelling through the screen", "[bank][app][view][cards]") { + BankWiring wiring; + wiring.signUp("view-cards"); + CardsController cards{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&cards] { return cards.loaded(); })); + RecordingBackend rec; + auto const mounted = bank::testing::mount(wiring.ctx, rec, bank::client::views::cardsView(cards)); + bank::testing::checkGolden("cards", rec.dump()); + + static_cast(wiring.openAccount(0, 0)); + cards.refresh(); + REQUIRE(settle(wiring.ctx, [&cards] { return cards.canIssue(); })); + rec.edit(bank::testing::input(rec, "Daily limit (opt.)"), "250"); + rec.click(bank::testing::button(rec, "Issue")); + REQUIRE(settle(wiring.ctx, [&cards] { return cards.cards().size() == 1; })); + bank::testing::drain(wiring.ctx); + CHECK(bank::testing::shows(rec, "Daily limit 250.00 USD")); + + rec.click(bank::testing::button(rec, "Freeze")); + REQUIRE(settle(wiring.ctx, [&cards] { return cards.cards().front().statusText == "Frozen"; })); + bank::testing::drain(wiring.ctx); + CHECK(bank::testing::shows(rec, "Frozen")); + + rec.click(bank::testing::button(rec, "Cancel")); + REQUIRE(settle(wiring.ctx, [&cards] { return cards.cards().front().cancelled; })); + bank::testing::drain(wiring.ctx); + CHECK_FALSE(bank::testing::visible(rec, bank::testing::button(rec, "Unfreeze"))); + CHECK_FALSE(bank::testing::visible(rec, bank::testing::button(rec, "Cancel"))); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/bank --target bank_app_tests` +Expected: FAIL — `fatal error: 'controllers/cards_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bank/app/account_choices.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "choice.hpp" +#include "format.hpp" + +/// @file +/// The signed-in user's open accounts as picker choices, labelled by masked number: what the cards, +/// payees and loans screens pick an account from. + +namespace bank::client { + +/// @brief The open accounts in @p list, in list order; empty before a list has arrived. +[[nodiscard]] inline std::vector openAccountChoices(std::optional const& list) { + std::vector choices; + if (!list.has_value()) { + return choices; + } + for (dto::AccountInfo const& account : list->accounts) { + if (account.status == static_cast(bank::AccountStatus::Closed)) { + continue; + } + choices.push_back(Choice{.id = account.id, .label = fmt::last4(account.number)}); + } + return choices; +} + +} // namespace bank::client +``` + +Create `examples/bank/app/controllers/cards_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/dto/account_dto.hpp" +#include "bank/dto/card_dto.hpp" +#include "bank_handlers.hpp" +#include "choice.hpp" +#include "notices.hpp" +#include "tone.hpp" + +/// @file +/// The cards screen: issue a card on an account, and freeze, unfreeze or cancel one. + +namespace bank::client { + +class AuthController; + +/// @brief The issue-card form's user intent. +namespace issuing { + +struct State { + morph::reactive::Signal account; ///< the user's pick; 0 for none + morph::reactive::Signal kind; ///< a `bank::CardKind` value + morph::reactive::Signal limit; ///< the daily limit as typed; blank for none +}; + +struct ChooseAccount { + std::int64_t id = 0; +}; +struct ChooseKind { + std::int64_t kind = 0; +}; +struct EditLimit { + std::string text; +}; +struct ClearLimit {}; + +using Msg = std::variant; + +[[nodiscard]] State init(morph::reactive::Runtime& runtime); + +struct Update { + void operator()(State& state, ChooseAccount const& msg) const; + void operator()(State& state, ChooseKind const& msg) const; + void operator()(State& state, EditLimit const& msg) const; + void operator()(State& state, ClearLimit const& msg) const; +}; + +} // namespace issuing + +/// @brief One card as the screen shows it. +struct CardRow { + std::int64_t id = 0; + std::string title; ///< `Debit card ••••1234` + std::string limitText; ///< `Daily limit …` + std::string statusText; ///< `Active`, `Frozen`, `Cancelled` + Tone statusTone = Tone::Neutral; + bool active = false; + bool cancelled = false; ///< a cancelled card offers no actions + std::string toggleLabel; ///< `Freeze` for an active card, else `Unfreeze` + bool operator==(CardRow const&) const = default; +}; + +/// @brief The cards screen's state and commands. +class CardsController { +public: + CardsController(morph::reactive::Runtime& runtime, BankHandlers& handlers, AuthController const& auth, + Notices& notices); + + void chooseAccount(std::int64_t chosenId); + void chooseKind(std::int64_t kind); + void editLimit(std::string text); + /// @brief Issues a card on the selected account; a blank or unparseable limit issues it with none. + void issue(); + /// @brief Freezes an active card and unfreezes a frozen one; a cancelled card is left alone. + void toggleFreeze(std::int64_t cardId); + void cancel(std::int64_t cardId); + void refresh(); + + [[nodiscard]] static std::vector const& kindChoices(); + + [[nodiscard]] issuing::State const& form() const noexcept { return _form.state(); } + [[nodiscard]] std::vector const& accountChoices() const { return _accountChoices.get(); } + [[nodiscard]] std::int64_t selectedAccount() const { return _account.get(); } + [[nodiscard]] std::vector const& cards() const { return _rows.get(); } + [[nodiscard]] bool canIssue() const { return _canIssue.get(); } + /// @brief Whether a card list has arrived for the current session. Tracked. + [[nodiscard]] bool loaded() const { return _cardList.value().has_value(); } + +private: + morph::reactive::Runtime* _rt; + morph::reactive::Store _form; + morph::reactive::Query _accounts; + morph::reactive::Query _cardList; + morph::reactive::Mutation _issue; + morph::reactive::Mutation _freeze; + morph::reactive::Mutation _unfreeze; + morph::reactive::Mutation _cancel; + morph::reactive::Computed> _accountChoices; + morph::reactive::Computed _account; + morph::reactive::Computed> _rows; + morph::reactive::Computed _canIssue; + FailureReporter _failures; +}; + +} // namespace bank::client +``` + +Create `examples/bank/app/controllers/cards_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "controllers/cards_controller.hpp" + +#include +#include +#include + +#include "account_choices.hpp" +#include "bank/core/types.hpp" +#include "controllers/auth_controller.hpp" +#include "format.hpp" + +namespace bank::client { + +namespace issuing { + +State init(morph::reactive::Runtime& runtime) { + return State{.account{runtime, 0}, .kind{runtime, 0}, .limit{runtime, std::string{}}}; +} + +void Update::operator()(State& state, ChooseAccount const& msg) const { state.account.set(msg.id); } +void Update::operator()(State& state, ChooseKind const& msg) const { state.kind.set(msg.kind); } +void Update::operator()(State& state, EditLimit const& msg) const { state.limit.set(msg.text); } +void Update::operator()(State& state, ClearLimit const& /*msg*/) const { state.limit.set(std::string{}); } + +} // namespace issuing + +namespace { + +// The model keeps a limit without a currency; it is shown in USD. +CardRow rowOf(dto::CardInfo const& card) { + auto const status = static_cast(card.status); + std::string const kind = card.kind == static_cast(bank::CardKind::Credit) ? "Credit" : "Debit"; + CardRow row{ + .id = card.id, + .title = kind + " card ••••" + card.panLast4, + .limitText = "Daily limit " + fmt::money(card.dailyLimitMinor, 0), + .statusText = "Cancelled", + .statusTone = Tone::Bad, + .active = status == bank::CardStatus::Active, + .cancelled = status == bank::CardStatus::Cancelled, + .toggleLabel = status == bank::CardStatus::Active ? "Freeze" : "Unfreeze", + }; + if (status == bank::CardStatus::Active) { + row.statusText = "Active"; + row.statusTone = Tone::Good; + } else if (status == bank::CardStatus::Frozen) { + row.statusText = "Frozen"; + row.statusTone = Tone::Warn; + } + return row; +} + +} // namespace + +CardsController::CardsController(morph::reactive::Runtime& runtime, BankHandlers& handlers, AuthController const& auth, + Notices& notices) + : _rt{&runtime}, + _form{runtime, &issuing::init, issuing::Update{}}, + _accounts{runtime, handlers.customers, + [session = &auth]() -> std::optional { + if (!session->signedIn()) { + return std::nullopt; + } + return dto::ListAccounts{}; + }}, + _cardList{runtime, handlers.cards, + [session = &auth]() -> std::optional { + if (!session->signedIn()) { + return std::nullopt; + } + return dto::ListCards{}; + }}, + _issue{runtime, handlers.cards, morph::reactive::MutationOptions{.invalidates = {&_cardList}}}, + _freeze{runtime, handlers.cards, morph::reactive::MutationOptions{.invalidates = {&_cardList}}}, + _unfreeze{runtime, handlers.cards, morph::reactive::MutationOptions{.invalidates = {&_cardList}}}, + _cancel{runtime, handlers.cards, morph::reactive::MutationOptions{.invalidates = {&_cardList}}}, + _accountChoices{runtime, [this] { return openAccountChoices(_accounts.value()); }}, + _account{runtime, [this] { return resolveChoice(_accountChoices.get(), _form.state().account.get()); }}, + _rows{runtime, + [this] { + std::vector rows; + if (std::optional const& list = _cardList.value(); list.has_value()) { + rows.reserve(list->cards.size()); + for (dto::CardInfo const& card : list->cards) { + rows.push_back(rowOf(card)); + } + } + return rows; + }}, + _canIssue{runtime, [this] { return _account.get() != 0; }}, + _failures{runtime, notices, + {[this] { return _accounts.error(); }, [this] { return _cardList.error(); }, + [this] { return _issue.error(); }, [this] { return _freeze.error(); }, + [this] { return _unfreeze.error(); }, [this] { return _cancel.error(); }}} {} + +void CardsController::chooseAccount(std::int64_t chosenId) { _form.send(issuing::ChooseAccount{chosenId}); } +void CardsController::chooseKind(std::int64_t kind) { _form.send(issuing::ChooseKind{kind}); } +void CardsController::editLimit(std::string text) { _form.send(issuing::EditLimit{std::move(text)}); } + +void CardsController::issue() { + if (!_canIssue.peek()) { + return; + } + issuing::State const& form = _form.state(); + std::string_view const limit = fmt::trimmed(form.limit.peek()); + std::int64_t const minor = limit.empty() ? 0 : fmt::parseMinor(limit).value_or(0); + _issue.run(dto::IssueCard{ + .accountId = _account.peek(), .kind = static_cast(form.kind.peek()), .dailyLimitMinor = minor}); + _form.send(issuing::ClearLimit{}); +} + +void CardsController::toggleFreeze(std::int64_t cardId) { + for (CardRow const& row : _rows.peek()) { + if (row.id != cardId || row.cancelled) { + continue; + } + if (row.active) { + _freeze.run(dto::FreezeCard{.id = cardId}); + } else { + _unfreeze.run(dto::UnfreezeCard{.id = cardId}); + } + return; + } +} + +void CardsController::cancel(std::int64_t cardId) { _cancel.run(dto::CancelCard{.id = cardId}); } + +void CardsController::refresh() { + _rt->batch([this] { + _accounts.refetch(); + _cardList.refetch(); + }); +} + +std::vector const& CardsController::kindChoices() { + static std::vector const choices{{.id = 0, .label = "Debit"}, {.id = 1, .label = "Credit"}}; + return choices; +} + +} // namespace bank::client +``` + +Create `examples/bank/app/views/cards_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "controllers/cards_controller.hpp" + +namespace bank::client::views { + +/// @brief The cards screen: the issue form and one panel per card. +/// @param cards Must outlive the mounted view. +/// @return The view. +[[nodiscard]] morph::ui::Node cardsView(CardsController& cards); + +} // namespace bank::client::views +``` + +Create `examples/bank/app/views/cards_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "views/cards_view.hpp" + +#include +#include +#include + +#include "views/style.hpp" + +namespace bank::client::views { + +namespace ui = morph::ui; + +ui::Node cardsView(CardsController& cards) { + CardsController* const ctl = &cards; + issuing::State const* const form = &cards.form(); + + ui::Node issuer = ui::panel({ + .title = "Issue card", + .padding = 1, + .child = ui::row({ + .children = {ui::select({.options = [ctl] { return optionsOf(ctl->accountChoices()); }, + .selected = [ctl] { return accountKeyOf(ctl->selectedAccount()); }, + .onSelect = [ctl](ui::Key key) { ctl->chooseAccount(idOf(key)); }}), + ui::select({.options = optionsOf(CardsController::kindChoices()), + .selected = [form] { return keyOf(form->kind.get()); }, + .onSelect = [ctl](ui::Key key) { ctl->chooseKind(idOf(key)); }}), + ui::textInput({.value = [form] { return form->limit.get(); }, + .onChange = [ctl](std::string text) { ctl->editLimit(std::move(text)); }, + .placeholder = "Daily limit (opt.)"}), + ui::button({.label = "Issue", + .onClick = [ctl] { ctl->issue(); }, + .common = {.enabled = [ctl] { return ctl->canIssue(); }}})}, + .gap = 1, + }), + }); + + ui::Node list = ui::forEach( + [ctl] { return ctl->cards(); }, [](CardRow const& row) { return ui::Key{row.id}; }, + [ctl](morph::reactive::Signal const& card) { + auto const* const row = &card; + auto const live = [row] { return !row->get().cancelled; }; + return ui::panel({ + .padding = 1, + .child = ui::row({ + .children = {ui::column({.children = {ui::text({.text = [row] { return row->get().title; }, + .role = ui::TextRole::Heading}), + ui::text({.text = [row] { return row->get().limitText; }, + .role = ui::TextRole::Muted})}}), + ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::text({.text = [row] { return row->get().statusText; }, + .role = [row] { return roleOf(row->get().statusTone); }}), + ui::button({.label = [row] { return row->get().toggleLabel; }, + .onClick = [ctl, row] { ctl->toggleFreeze(row->peek().id); }, + .common = {.visible = live}}), + ui::button({.label = "Cancel", + .onClick = [ctl, row] { ctl->cancel(row->peek().id); }, + .common = {.visible = live}})}, + .gap = 1, + }), + }); + }, + ui::Axis::Vertical, 1); + + return ui::column({ + .children = {issuer, ui::scroll({.child = list, .common = {.layout = {.height = ui::Sizing::stretch()}}})}, + .gap = 1, + }); +} + +} // namespace bank::client::views +``` + +- [ ] **Step 4: Generate the golden, then run the tests to verify they pass** + +```bash +cmake --build build/bank --target bank_app_tests +BANK_UPDATE_GOLDEN=1 ./build/bank/examples/bank/bank_app_tests "Cards view: issuing, freezing and cancelling through the screen" +cat examples/bank/tests/app/golden/cards.txt +``` + +Review it: a Panel `Issue card` holding a Select with `options=[] selected=none`, a Select `Debit`/`Credit` with +`Debit` selected, a TextInput `Daily limit (opt.)` and a Button `Issue` with `enabled=false`; a `Scroll` holding the +list, an empty `Column`. + +Run: `./build/bank/examples/bank/bank_app_tests "[bank][app]"` +Expected: PASS — 37 test cases. + +Mutation checks, each restored afterwards: +1. In `toggleFreeze`, run `_freeze` whatever `row.active` says. Expected FAIL in "an issued card is listed with its + limit and can be frozen, unfrozen and cancelled" (times out waiting for `Active` again). +2. Remove `&_cardList` from `_issue`'s `invalidates`. Expected FAIL in the same test (times out waiting for one card). + +- [ ] **Step 5: Commit** + +```bash +git add examples/bank/app examples/bank/tests/app examples/bank/CMakeLists.txt +git commit -m "wip(bank): CardsController and the cards screen + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 6: `PayeesController` and the payees & bills screen + +The old `PayeeController` + `PayeesPage.qml`: add and remove payees, and pay a bill. A payment reloads nothing, +so `Bill paid` in the notice strip is its only confirmation — the half of the old toast test that is about payees. + +**Files:** +- Create: `examples/bank/app/controllers/payees_controller.hpp`, `examples/bank/app/controllers/payees_controller.cpp` +- Create: `examples/bank/app/views/payees_view.hpp`, `examples/bank/app/views/payees_view.cpp` +- Create: `examples/bank/tests/app/golden/payees.txt` (generated in Step 4) +- Modify: `examples/bank/CMakeLists.txt` — append `app/controllers/payees_controller.cpp` and + `app/views/payees_view.cpp` to `BANK_APP_SOURCES`; append `tests/app/test_payees.cpp` to `bank_app_tests` +- Test: `examples/bank/tests/app/test_payees.cpp` + +**Interfaces:** +- Consumes: Tasks 1–5 (`openAccountChoices`, `confirmed`); `dto::{AddPayee, RemovePayee, ListPayees, PayeeList, + PayeeInfo}` (`include/bank/dto/payee_dto.hpp`), `dto::{PayBill, PaymentInfo}` (`payment_dto.hpp`). +- Produces: `namespace bank::client::payee { State{name, iban, bankName, account, payee, amount}; EditName, EditIban, + EditBankName, ClearPayee, ChooseAccount, ChoosePayee, EditAmount, ClearAmount; Msg; init; Update; }`; + `struct PayeeRow`; `class PayeesController { PayeesController(Runtime&, BankHandlers&, AuthController const&, + Notices&); editName, editIban, editBankName, addPayee(), removePayee(id), chooseAccount, choosePayee, editAmount, + payBill(), refresh(); form(), accountChoices(), payeeChoices(), selectedAccount(), selectedPayee(), payees(), + canAdd(), canPay(), loaded(); }`; `views::payeesView(PayeesController&)`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/bank/tests/app/test_payees.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include "bank_app_test_support.hpp" +#include "controllers/payees_controller.hpp" +#include "recorded.hpp" +#include "views/payees_view.hpp" + +using bank::client::PayeesController; +using bank::client::Tone; +using bank::testing::BankWiring; +using bank::testing::RecordingBackend; +using bank::testing::settle; + +namespace { + +constexpr char const* kIban = "DE89370400440532013000"; + +} // namespace + +TEST_CASE("PayeesController: an added payee is listed and can be removed", "[bank][app][payees]") { + BankWiring wiring; + wiring.signUp("payees-add"); + PayeesController payees{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&payees] { return payees.loaded(); })); + payees.editName("City Power"); + payees.editIban(std::string{" "} + kIban + " "); + payees.editBankName("Stadtbank"); + REQUIRE(payees.canAdd()); + payees.addPayee(); + CHECK(payees.form().name.peek().empty()); + CHECK(payees.form().iban.peek().empty()); + REQUIRE(settle(wiring.ctx, [&payees] { return payees.payees().size() == 1; })); + CHECK(payees.payees().front().name == "City Power"); + CHECK(payees.payees().front().iban == kIban); + CHECK(payees.selectedPayee() == payees.payees().front().id); + + payees.removePayee(payees.payees().front().id); + REQUIRE(settle(wiring.ctx, [&payees] { return payees.payees().empty(); })); + CHECK(payees.selectedPayee() == 0); +} + +TEST_CASE("PayeesController: adding needs a name and a plausible IBAN", "[bank][app][payees]") { + BankWiring wiring; + wiring.signUp("payees-valid"); + PayeesController payees{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + CHECK_FALSE(payees.canAdd()); + payees.editName("Gas"); + payees.editIban("not-an-iban"); + CHECK_FALSE(payees.canAdd()); + payees.editIban(kIban); + CHECK(payees.canAdd()); + payees.editName(""); + CHECK_FALSE(payees.canAdd()); +} + +TEST_CASE("PayeesController: paying a bill confirms it and debits the account", "[bank][app][payees]") { + BankWiring wiring; + wiring.signUp("payees-pay"); + std::int64_t const account = wiring.openAccount(0, 0); + wiring.depositInto(account, 5000); + PayeesController payees{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + payees.editName("City Power"); + payees.editIban(kIban); + payees.addPayee(); + REQUIRE(settle(wiring.ctx, + [&payees] { return payees.payees().size() == 1 && payees.accountChoices().size() == 1; })); + CHECK_FALSE(payees.canPay()); // no amount yet + payees.editAmount("12.50"); + REQUIRE(payees.canPay()); + payees.payBill(); + CHECK(payees.form().amount.peek().empty()); + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.notices.visible(); })); + CHECK(wiring.notices.text() == "Bill paid"); + CHECK(wiring.notices.tone() == Tone::Good); + CHECK(wiring.balanceOf(account) == 3750); +} + +TEST_CASE("Payees view: adding a payee and paying it through the screen", "[bank][app][view][payees]") { + BankWiring wiring; + wiring.signUp("view-payees"); + PayeesController payees{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&payees] { return payees.loaded(); })); + RecordingBackend rec; + auto const mounted = bank::testing::mount(wiring.ctx, rec, bank::client::views::payeesView(payees)); + bank::testing::checkGolden("payees", rec.dump()); + + std::int64_t const account = wiring.openAccount(0, 0); + wiring.depositInto(account, 2000); + payees.refresh(); + rec.edit(bank::testing::input(rec, "Payee name"), "Water"); + rec.edit(bank::testing::input(rec, "IBAN"), kIban); + bank::testing::drain(wiring.ctx); + rec.click(bank::testing::button(rec, "Add payee")); + REQUIRE(settle(wiring.ctx, + [&payees] { return payees.payees().size() == 1 && payees.accountChoices().size() == 1; })); + bank::testing::drain(wiring.ctx); + CHECK(bank::testing::shows(rec, "Water")); + + rec.edit(bank::testing::input(rec, "Amount"), "5.00"); + bank::testing::drain(wiring.ctx); + rec.click(bank::testing::button(rec, "Pay")); + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.notices.visible(); })); + CHECK(wiring.notices.text() == "Bill paid"); + CHECK(wiring.balanceOf(account) == 1500); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/bank --target bank_app_tests` +Expected: FAIL — `fatal error: 'controllers/payees_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bank/app/controllers/payees_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/dto/account_dto.hpp" +#include "bank/dto/payee_dto.hpp" +#include "bank/dto/payment_dto.hpp" +#include "bank_handlers.hpp" +#include "choice.hpp" +#include "notices.hpp" + +/// @file +/// The payees & bills screen: saved payees, adding and removing one, and paying a bill to one. + +namespace bank::client { + +class AuthController; + +/// @brief The add-payee and pay-bill forms' user intent. +namespace payee { + +struct State { + morph::reactive::Signal name; ///< the new payee's name + morph::reactive::Signal iban; ///< the new payee's IBAN, as typed + morph::reactive::Signal bankName; ///< optional + morph::reactive::Signal account; ///< the paying account the user picked; 0 for none + morph::reactive::Signal payee; ///< the payee the user picked; 0 for none + morph::reactive::Signal amount; ///< the bill amount, as typed +}; + +struct EditName { + std::string text; +}; +struct EditIban { + std::string text; +}; +struct EditBankName { + std::string text; +}; +struct ClearPayee {}; +struct ChooseAccount { + std::int64_t id = 0; +}; +struct ChoosePayee { + std::int64_t id = 0; +}; +struct EditAmount { + std::string text; +}; +struct ClearAmount {}; + +using Msg = std::variant; + +[[nodiscard]] State init(morph::reactive::Runtime& runtime); + +struct Update { + void operator()(State& state, EditName const& msg) const; + void operator()(State& state, EditIban const& msg) const; + void operator()(State& state, EditBankName const& msg) const; + void operator()(State& state, ClearPayee const& msg) const; + void operator()(State& state, ChooseAccount const& msg) const; + void operator()(State& state, ChoosePayee const& msg) const; + void operator()(State& state, EditAmount const& msg) const; + void operator()(State& state, ClearAmount const& msg) const; +}; + +} // namespace payee + +/// @brief One saved payee as the screen shows it. +struct PayeeRow { + std::int64_t id = 0; + std::string name; + std::string iban; + bool operator==(PayeeRow const&) const = default; +}; + +/// @brief The payees screen's state and commands. +class PayeesController { +public: + PayeesController(morph::reactive::Runtime& runtime, BankHandlers& handlers, AuthController const& auth, + Notices& notices); + + void editName(std::string text); + void editIban(std::string text); + void editBankName(std::string text); + /// @brief Saves the payee, the IBAN trimmed; does nothing while `canAdd()` is false. + void addPayee(); + void removePayee(std::int64_t payeeId); + void chooseAccount(std::int64_t chosenId); + void choosePayee(std::int64_t chosenId); + void editAmount(std::string text); + /// @brief Pays the amount to the selected payee; does nothing while `canPay()` is false. + void payBill(); + void refresh(); + + [[nodiscard]] payee::State const& form() const noexcept { return _form.state(); } + [[nodiscard]] std::vector const& accountChoices() const { return _accountChoices.get(); } + [[nodiscard]] std::vector const& payeeChoices() const { return _payeeChoices.get(); } + [[nodiscard]] std::int64_t selectedAccount() const { return _account.get(); } + [[nodiscard]] std::int64_t selectedPayee() const { return _payee.get(); } + [[nodiscard]] std::vector const& payees() const { return _rows.get(); } + [[nodiscard]] bool canAdd() const { return _canAdd.get(); } + [[nodiscard]] bool canPay() const { return _canPay.get(); } + [[nodiscard]] bool loaded() const { return _payeeList.value().has_value(); } + +private: + morph::reactive::Runtime* _rt; + morph::reactive::Store _form; + morph::reactive::Query _accounts; + morph::reactive::Query _payeeList; + morph::reactive::Mutation _add; + morph::reactive::Mutation _remove; + morph::reactive::Mutation _pay; + morph::reactive::Computed> _accountChoices; + morph::reactive::Computed> _rows; + morph::reactive::Computed> _payeeChoices; + morph::reactive::Computed _account; + morph::reactive::Computed _payee; + morph::reactive::Computed> _amountMinor; + morph::reactive::Computed _canAdd; + morph::reactive::Computed _canPay; + FailureReporter _failures; + // Last member, so it is destroyed first: a confirmation still in flight is dropped. + morph::async::CallbackScope _confirmations; +}; + +} // namespace bank::client +``` + +Create `examples/bank/app/controllers/payees_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "controllers/payees_controller.hpp" + +#include +#include + +#include "account_choices.hpp" +#include "controllers/auth_controller.hpp" +#include "format.hpp" + +namespace bank::client { + +namespace payee { + +State init(morph::reactive::Runtime& runtime) { + return State{.name{runtime, std::string{}}, + .iban{runtime, std::string{}}, + .bankName{runtime, std::string{}}, + .account{runtime, 0}, + .payee{runtime, 0}, + .amount{runtime, std::string{}}}; +} + +void Update::operator()(State& state, EditName const& msg) const { state.name.set(msg.text); } +void Update::operator()(State& state, EditIban const& msg) const { state.iban.set(msg.text); } +void Update::operator()(State& state, EditBankName const& msg) const { state.bankName.set(msg.text); } + +void Update::operator()(State& state, ClearPayee const& /*msg*/) const { + state.name.set(std::string{}); + state.iban.set(std::string{}); + state.bankName.set(std::string{}); +} + +void Update::operator()(State& state, ChooseAccount const& msg) const { state.account.set(msg.id); } +void Update::operator()(State& state, ChoosePayee const& msg) const { state.payee.set(msg.id); } +void Update::operator()(State& state, EditAmount const& msg) const { state.amount.set(msg.text); } +void Update::operator()(State& state, ClearAmount const& /*msg*/) const { state.amount.set(std::string{}); } + +} // namespace payee + +namespace { + +dto::AddPayee draftOf(std::string const& name, std::string_view iban, std::string const& bankName) { + return dto::AddPayee{.name = name, .iban = std::string{fmt::trimmed(iban)}, .bankName = bankName}; +} + +} // namespace + +PayeesController::PayeesController(morph::reactive::Runtime& runtime, BankHandlers& handlers, + AuthController const& auth, Notices& notices) + : _rt{&runtime}, + _form{runtime, &payee::init, payee::Update{}}, + _accounts{runtime, handlers.customers, + [session = &auth]() -> std::optional { + if (!session->signedIn()) { + return std::nullopt; + } + return dto::ListAccounts{}; + }}, + _payeeList{runtime, handlers.payees, + [session = &auth]() -> std::optional { + if (!session->signedIn()) { + return std::nullopt; + } + return dto::ListPayees{}; + }}, + _add{runtime, handlers.payees, morph::reactive::MutationOptions{.invalidates = {&_payeeList}}}, + _remove{runtime, handlers.payees, morph::reactive::MutationOptions{.invalidates = {&_payeeList}}}, + _pay{runtime, confirmed(handlers.payments, notices, _confirmations, "Bill paid")}, + _accountChoices{runtime, [this] { return openAccountChoices(_accounts.value()); }}, + _rows{runtime, + [this] { + std::vector rows; + if (std::optional const& list = _payeeList.value(); list.has_value()) { + rows.reserve(list->payees.size()); + for (dto::PayeeInfo const& info : list->payees) { + rows.push_back(PayeeRow{.id = info.id, .name = info.name, .iban = info.iban}); + } + } + return rows; + }}, + _payeeChoices{runtime, + [this] { + std::vector choices; + for (PayeeRow const& row : _rows.get()) { + choices.push_back(Choice{.id = row.id, .label = row.name}); + } + return choices; + }}, + _account{runtime, [this] { return resolveChoice(_accountChoices.get(), _form.state().account.get()); }}, + _payee{runtime, [this] { return resolveChoice(_payeeChoices.get(), _form.state().payee.get()); }}, + _amountMinor{runtime, [this] { return fmt::parseMinor(_form.state().amount.get()); }}, + _canAdd{runtime, + [this] { + payee::State const& form = _form.state(); + return draftOf(form.name.get(), form.iban.get(), form.bankName.get()).validate(); + }}, + _canPay{runtime, [this] { return _amountMinor.get().has_value() && _account.get() != 0 && _payee.get() != 0; }}, + _failures{runtime, notices, + {[this] { return _accounts.error(); }, [this] { return _payeeList.error(); }, + [this] { return _add.error(); }, [this] { return _remove.error(); }, + [this] { return _pay.error(); }}} {} + +void PayeesController::editName(std::string text) { _form.send(payee::EditName{std::move(text)}); } +void PayeesController::editIban(std::string text) { _form.send(payee::EditIban{std::move(text)}); } +void PayeesController::editBankName(std::string text) { _form.send(payee::EditBankName{std::move(text)}); } + +void PayeesController::addPayee() { + if (!_canAdd.peek()) { + return; + } + payee::State const& form = _form.state(); + _add.run(draftOf(form.name.peek(), form.iban.peek(), form.bankName.peek())); + _form.send(payee::ClearPayee{}); +} + +void PayeesController::removePayee(std::int64_t payeeId) { _remove.run(dto::RemovePayee{.id = payeeId}); } +void PayeesController::chooseAccount(std::int64_t chosenId) { _form.send(payee::ChooseAccount{chosenId}); } +void PayeesController::choosePayee(std::int64_t chosenId) { _form.send(payee::ChoosePayee{chosenId}); } +void PayeesController::editAmount(std::string text) { _form.send(payee::EditAmount{std::move(text)}); } + +void PayeesController::payBill() { + if (!_canPay.peek()) { + return; + } + _pay.run(dto::PayBill{ + .fromAccountId = _account.peek(), .payeeId = _payee.peek(), .amountMinor = _amountMinor.peek().value_or(0)}); + _form.send(payee::ClearAmount{}); +} + +void PayeesController::refresh() { + _rt->batch([this] { + _accounts.refetch(); + _payeeList.refetch(); + }); +} + +} // namespace bank::client +``` + +Create `examples/bank/app/views/payees_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "controllers/payees_controller.hpp" + +namespace bank::client::views { + +/// @brief The payees & bills screen: the add form, the pay form, one panel per payee. +/// @param payees Must outlive the mounted view. +/// @return The view. +[[nodiscard]] morph::ui::Node payeesView(PayeesController& payees); + +} // namespace bank::client::views +``` + +Create `examples/bank/app/views/payees_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "views/payees_view.hpp" + +#include +#include +#include + +#include "views/style.hpp" + +namespace bank::client::views { + +namespace ui = morph::ui; + +ui::Node payeesView(PayeesController& payees) { + PayeesController* const ctl = &payees; + payee::State const* const form = &payees.form(); + ui::Common const stretch{.layout = {.width = ui::Sizing::stretch()}}; + + ui::Node adder = ui::panel({ + .padding = 1, + .child = ui::row({ + .children = {ui::textInput({.value = [form] { return form->name.get(); }, + .onChange = [ctl](std::string text) { ctl->editName(std::move(text)); }, + .placeholder = "Payee name", + .common = stretch}), + ui::textInput({.value = [form] { return form->iban.get(); }, + .onChange = [ctl](std::string text) { ctl->editIban(std::move(text)); }, + .placeholder = "IBAN", + .common = stretch}), + ui::textInput({.value = [form] { return form->bankName.get(); }, + .onChange = [ctl](std::string text) { ctl->editBankName(std::move(text)); }, + .placeholder = "Bank (optional)", + .common = stretch}), + ui::button({.label = "Add payee", + .onClick = [ctl] { ctl->addPayee(); }, + .common = {.enabled = [ctl] { return ctl->canAdd(); }}})}, + .gap = 1, + }), + }); + + ui::Node payer = ui::panel({ + .title = "Pay bill", + .padding = 1, + .child = ui::row({ + .children = {ui::select({.options = [ctl] { return optionsOf(ctl->accountChoices()); }, + .selected = [ctl] { return accountKeyOf(ctl->selectedAccount()); }, + .onSelect = [ctl](ui::Key key) { ctl->chooseAccount(idOf(key)); }}), + ui::select({.options = [ctl] { return optionsOf(ctl->payeeChoices()); }, + .selected = [ctl] { return accountKeyOf(ctl->selectedPayee()); }, + .onSelect = [ctl](ui::Key key) { ctl->choosePayee(idOf(key)); }}), + ui::textInput({.value = [form] { return form->amount.get(); }, + .onChange = [ctl](std::string text) { ctl->editAmount(std::move(text)); }, + .placeholder = "Amount"}), + ui::button({.label = "Pay", + .onClick = [ctl] { ctl->payBill(); }, + .common = {.enabled = [ctl] { return ctl->canPay(); }}})}, + .gap = 1, + }), + }); + + ui::Node list = ui::forEach( + [ctl] { return ctl->payees(); }, [](PayeeRow const& row) { return ui::Key{row.id}; }, + [ctl](morph::reactive::Signal const& entry) { + auto const* const row = &entry; + return ui::panel({ + .padding = 1, + .child = ui::row({ + .children = {ui::column({.children = {ui::text({.text = [row] { return row->get().name; }, + .role = ui::TextRole::Heading}), + ui::text({.text = [row] { return row->get().iban; }, + .role = ui::TextRole::Muted})}}), + ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::button({.label = "Remove", + .onClick = [ctl, row] { ctl->removePayee(row->peek().id); }})}, + .gap = 1, + }), + }); + }, + ui::Axis::Vertical, 1); + + return ui::column({ + .children = {adder, payer, + ui::scroll({.child = list, .common = {.layout = {.height = ui::Sizing::stretch()}}})}, + .gap = 1, + }); +} + +} // namespace bank::client::views +``` + +- [ ] **Step 4: Generate the golden, then run the tests to verify they pass** + +```bash +cmake --build build/bank --target bank_app_tests +BANK_UPDATE_GOLDEN=1 ./build/bank/examples/bank/bank_app_tests "Payees view: adding a payee and paying it through the screen" +cat examples/bank/tests/app/golden/payees.txt +``` + +Review it: a Panel with TextInputs `Payee name`, `IBAN`, `Bank (optional)` and a Button `Add payee` +(`enabled=false`); a Panel `Pay bill` with two Selects showing `options=[] selected=none`, a TextInput `Amount` and +a Button `Pay` (`enabled=false`); a `Scroll` holding the list, an empty `Column`. + +Run: `./build/bank/examples/bank/bank_app_tests "[bank][app]"` +Expected: PASS — 41 test cases. + +Mutation checks, each restored afterwards: +1. Construct `_pay` as `_pay{runtime, handlers.payments}` (no confirmation). Expected FAIL in "paying a bill confirms it + and debits the account" (times out waiting for the notice). +2. In `draftOf`, pass `std::string{iban}` instead of the trimmed IBAN. Expected FAIL in "an added payee is listed and + can be removed" at `REQUIRE(payees.canAdd())`. + +- [ ] **Step 5: Commit** + +```bash +git add examples/bank/app examples/bank/tests/app examples/bank/CMakeLists.txt +git commit -m "wip(bank): PayeesController and the payees & bills screen + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 7: `LoansController` and the loans screen + +The old `LoanController` + `LoansPage.qml`: apply for a loan, repay one in full, and show a loan's amortization +schedule. The schedule is a `Query` keyed on the loan being shown, so it gets latest-wins for free — the same fix +as the history's. The QML's rate/term parsing (`parseInt`, `-1` for a blank rate) moves into `Computed`s over +`fmt::parseWhole`; the repay amount is the outstanding balance in minor units, with no round trip through text. + +**Files:** +- Create: `examples/bank/app/controllers/loans_controller.hpp`, `examples/bank/app/controllers/loans_controller.cpp` +- Create: `examples/bank/app/views/loans_view.hpp`, `examples/bank/app/views/loans_view.cpp` +- Create: `examples/bank/tests/app/golden/loans.txt` (generated in Step 4) +- Modify: `examples/bank/CMakeLists.txt` — append `app/controllers/loans_controller.cpp` and `app/views/loans_view.cpp` + to `BANK_APP_SOURCES`; append `tests/app/test_loans.cpp` to `bank_app_tests` +- Test: `examples/bank/tests/app/test_loans.cpp` + +**Interfaces:** +- Consumes: Tasks 1–6; `dto::{ApplyLoan, RepayLoan, ListLoans, LoanList, LoanInfo, LoanScheduleRequest, + LoanScheduleResult, Installment}` (`include/bank/dto/loan_dto.hpp`), `bank::LoanStatus`. +- Produces: `namespace bank::client::lending { State{account, principal, rate, term, scheduleLoan}; ChooseAccount, + EditPrincipal, EditRate, EditTerm, ClearApplication, ShowSchedule; Msg; init; Update; }`; `struct LoanRow`; + `struct InstallmentRow`; `class LoansController { LoansController(Runtime&, BankHandlers&, AuthController const&, + Notices&); chooseAccount, editPrincipal, editRate, editTerm, apply(), repay(loanId), showSchedule(loanId), + refresh(); form(), accountChoices(), selectedAccount(), loans(), schedule(), canApply(), loaded(); }`; + `views::loansView(LoansController&)`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/bank/tests/app/test_loans.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "bank_app_test_support.hpp" +#include "controllers/loans_controller.hpp" +#include "recorded.hpp" +#include "views/loans_view.hpp" + +using bank::client::LoanRow; +using bank::client::LoansController; +using bank::client::Tone; +using bank::testing::BankWiring; +using bank::testing::RecordingBackend; +using bank::testing::settle; + +namespace { + +void applyFor(LoansController& loans, std::string principal, std::string rate, std::string term) { + loans.editPrincipal(std::move(principal)); + loans.editRate(std::move(rate)); + loans.editTerm(std::move(term)); + REQUIRE(loans.canApply()); + loans.apply(); +} + +} // namespace + +TEST_CASE("LoansController: an approved loan is listed with its terms", "[bank][app][loans]") { + BankWiring wiring; + wiring.signUp("loans-apply"); + std::int64_t const account = wiring.openAccount(0, 0); + LoansController loans{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&loans] { return loans.accountChoices().size() == 1 && loans.loaded(); })); + CHECK(loans.selectedAccount() == account); + + applyFor(loans, "120.00", "600", "12"); + CHECK(loans.form().principal.peek().empty()); + REQUIRE(settle(wiring.ctx, [&loans] { return loans.loans().size() == 1; })); + LoanRow const loan = loans.loans().front(); + CHECK(loan.title == "Loan #" + std::to_string(loan.id)); + CHECK(loan.detail == "Outstanding 120.00 USD · 600 bps · 12 mo"); + CHECK(loan.statusText == "Active"); + CHECK(loan.statusTone == Tone::Neutral); + CHECK(loan.active); + CHECK(wiring.balanceOf(account) == 12000); // disbursed into the account +} + +TEST_CASE("LoansController: the schedule shows one row per month", "[bank][app][loans]") { + BankWiring wiring; + wiring.signUp("loans-schedule"); + static_cast(wiring.openAccount(0, 0)); + LoansController loans{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&loans] { return loans.accountChoices().size() == 1; })); + applyFor(loans, "1200.00", "0", "12"); + REQUIRE(settle(wiring.ctx, [&loans] { return loans.loans().size() == 1; })); + loans.showSchedule(loans.loans().front().id); + REQUIRE(settle(wiring.ctx, [&loans] { return loans.schedule().size() == 12; })); + CHECK(loans.schedule().front().monthText == "1"); + CHECK(loans.schedule().front().principalText == "100.00 USD"); + CHECK(loans.schedule().front().interestText == "0.00 USD"); + CHECK(loans.schedule().back().monthText == "12"); + CHECK(loans.schedule().back().remainingText == "0.00 USD"); +} + +TEST_CASE("LoansController: repaying the outstanding balance pays the loan off", "[bank][app][loans]") { + BankWiring wiring; + wiring.signUp("loans-repay"); + std::int64_t const account = wiring.openAccount(0, 0); + LoansController loans{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&loans] { return loans.accountChoices().size() == 1; })); + applyFor(loans, "120.00", "0", "12"); + REQUIRE(settle(wiring.ctx, [&loans] { return loans.loans().size() == 1; })); + loans.repay(loans.loans().front().id); + REQUIRE(settle(wiring.ctx, [&loans] { return loans.loans().front().statusText == "Paid off"; })); + CHECK(loans.loans().front().statusTone == Tone::Good); + CHECK_FALSE(loans.loans().front().active); + CHECK(wiring.balanceOf(account) == 0); +} + +TEST_CASE("LoansController: rate and term must be whole numbers; a zero rate is allowed", "[bank][app][loans]") { + BankWiring wiring; + wiring.signUp("loans-valid"); + static_cast(wiring.openAccount(0, 0)); + LoansController loans{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&loans] { return loans.accountChoices().size() == 1; })); + loans.editPrincipal("100"); + loans.editTerm("12"); + CHECK_FALSE(loans.canApply()); // a blank rate is not a zero rate + loans.editRate("0"); + CHECK(loans.canApply()); + loans.editRate("-5"); + CHECK_FALSE(loans.canApply()); + loans.editRate("1.5"); + CHECK_FALSE(loans.canApply()); + loans.editRate("600"); + loans.editTerm("0"); + CHECK_FALSE(loans.canApply()); + loans.editTerm("12"); + loans.editPrincipal("abc"); + CHECK_FALSE(loans.canApply()); +} + +TEST_CASE("Loans view: applying, showing the schedule and repaying through the screen", "[bank][app][view][loans]") { + BankWiring wiring; + wiring.signUp("view-loans"); + LoansController loans{wiring.runtime(), wiring.handlers, wiring.auth, wiring.notices}; + REQUIRE(settle(wiring.ctx, [&loans] { return loans.loaded(); })); + RecordingBackend rec; + auto const mounted = bank::testing::mount(wiring.ctx, rec, bank::client::views::loansView(loans)); + bank::testing::checkGolden("loans", rec.dump()); + + static_cast(wiring.openAccount(0, 0)); + loans.refresh(); + REQUIRE(settle(wiring.ctx, [&loans] { return loans.accountChoices().size() == 1; })); + rec.edit(bank::testing::input(rec, "Principal"), "1200.00"); + rec.edit(bank::testing::input(rec, "Rate (bps)"), "0"); + rec.edit(bank::testing::input(rec, "Months"), "12"); + bank::testing::drain(wiring.ctx); + rec.click(bank::testing::button(rec, "Apply")); + REQUIRE(settle(wiring.ctx, [&loans] { return loans.loans().size() == 1; })); + bank::testing::drain(wiring.ctx); + CHECK(bank::testing::shows(rec, "Loan #" + std::to_string(loans.loans().front().id))); + + rec.click(bank::testing::button(rec, "Schedule")); + REQUIRE(settle(wiring.ctx, [&loans] { return loans.schedule().size() == 12; })); + bank::testing::drain(wiring.ctx); + CHECK(bank::testing::shows(rec, "100.00 USD")); + + rec.click(bank::testing::button(rec, "Repay")); + REQUIRE(settle(wiring.ctx, [&loans] { return !loans.loans().front().active; })); + bank::testing::drain(wiring.ctx); + CHECK(bank::testing::shows(rec, "Paid off")); + CHECK_FALSE(bank::testing::visible(rec, bank::testing::button(rec, "Repay"))); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/bank --target bank_app_tests` +Expected: FAIL — `fatal error: 'controllers/loans_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bank/app/controllers/loans_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/dto/account_dto.hpp" +#include "bank/dto/loan_dto.hpp" +#include "bank_handlers.hpp" +#include "choice.hpp" +#include "notices.hpp" +#include "tone.hpp" + +/// @file +/// The loans screen: apply for a loan, repay one in full, and show a loan's amortization schedule. + +namespace bank::client { + +class AuthController; + +/// @brief The loan application form's user intent, and which schedule is shown. +namespace lending { + +struct State { + morph::reactive::Signal account; ///< the user's pick; 0 for none + morph::reactive::Signal principal; ///< as typed, in major units + morph::reactive::Signal rate; ///< basis points, as typed; blank is not zero + morph::reactive::Signal term; ///< months, as typed + morph::reactive::Signal scheduleLoan; ///< the loan whose schedule is shown; 0 for none +}; + +struct ChooseAccount { + std::int64_t id = 0; +}; +struct EditPrincipal { + std::string text; +}; +struct EditRate { + std::string text; +}; +struct EditTerm { + std::string text; +}; +struct ClearApplication {}; +struct ShowSchedule { + std::int64_t loanId = 0; +}; + +using Msg = std::variant; + +[[nodiscard]] State init(morph::reactive::Runtime& runtime); + +struct Update { + void operator()(State& state, ChooseAccount const& msg) const; + void operator()(State& state, EditPrincipal const& msg) const; + void operator()(State& state, EditRate const& msg) const; + void operator()(State& state, EditTerm const& msg) const; + void operator()(State& state, ClearApplication const& msg) const; + void operator()(State& state, ShowSchedule const& msg) const; +}; + +} // namespace lending + +/// @brief One loan as the screen shows it. +struct LoanRow { + std::int64_t id = 0; + std::int64_t accountId = 0; ///< the account it was disbursed into, which repays it + std::string title; ///< `Loan #id` + std::string detail; ///< outstanding, rate, term + std::int64_t outstanding = 0; ///< minor units; what a repayment pays + std::string statusText; ///< `Active` or `Paid off` + Tone statusTone = Tone::Neutral; + bool active = false; ///< only an active loan can be repaid + bool operator==(LoanRow const&) const = default; +}; + +/// @brief One month of an amortization schedule. +struct InstallmentRow { + int month = 0; + std::string monthText; + std::string principalText; + std::string interestText; + std::string remainingText; + bool operator==(InstallmentRow const&) const = default; +}; + +/// @brief The loans screen's state and commands. +class LoansController { +public: + LoansController(morph::reactive::Runtime& runtime, BankHandlers& handlers, AuthController const& auth, + Notices& notices); + + void chooseAccount(std::int64_t chosenId); + void editPrincipal(std::string text); + void editRate(std::string text); + void editTerm(std::string text); + /// @brief Applies for the loan the form describes; does nothing while `canApply()` is false. + void apply(); + /// @brief Repays an active loan's whole outstanding balance from the account it was paid into. + void repay(std::int64_t loanId); + /// @brief Shows @p loanId's schedule, fetching it again when it is already shown. + void showSchedule(std::int64_t loanId); + void refresh(); + + [[nodiscard]] lending::State const& form() const noexcept { return _form.state(); } + [[nodiscard]] std::vector const& accountChoices() const { return _accountChoices.get(); } + [[nodiscard]] std::int64_t selectedAccount() const { return _account.get(); } + [[nodiscard]] std::vector const& loans() const { return _rows.get(); } + [[nodiscard]] std::vector const& schedule() const { return _installments.get(); } + [[nodiscard]] bool canApply() const { return _canApply.get(); } + [[nodiscard]] bool loaded() const { return _loanList.value().has_value(); } + +private: + morph::reactive::Runtime* _rt; + morph::reactive::Store _form; + morph::reactive::Query _accounts; + morph::reactive::Query _loanList; + morph::reactive::Query _schedule; + morph::reactive::Mutation _apply; + morph::reactive::Mutation _repay; + morph::reactive::Computed> _accountChoices; + morph::reactive::Computed _account; + morph::reactive::Computed> _rows; + morph::reactive::Computed> _installments; + morph::reactive::Computed> _principalMinor; + morph::reactive::Computed> _rateBps; + morph::reactive::Computed> _termMonths; + morph::reactive::Computed _canApply; + FailureReporter _failures; +}; + +} // namespace bank::client +``` + +Create `examples/bank/app/controllers/loans_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "controllers/loans_controller.hpp" + +#include + +#include "account_choices.hpp" +#include "bank/core/types.hpp" +#include "controllers/auth_controller.hpp" +#include "format.hpp" + +namespace bank::client { + +namespace lending { + +State init(morph::reactive::Runtime& runtime) { + return State{.account{runtime, 0}, + .principal{runtime, std::string{}}, + .rate{runtime, std::string{}}, + .term{runtime, std::string{}}, + .scheduleLoan{runtime, 0}}; +} + +void Update::operator()(State& state, ChooseAccount const& msg) const { state.account.set(msg.id); } +void Update::operator()(State& state, EditPrincipal const& msg) const { state.principal.set(msg.text); } +void Update::operator()(State& state, EditRate const& msg) const { state.rate.set(msg.text); } +void Update::operator()(State& state, EditTerm const& msg) const { state.term.set(msg.text); } + +void Update::operator()(State& state, ClearApplication const& /*msg*/) const { + state.principal.set(std::string{}); + state.rate.set(std::string{}); + state.term.set(std::string{}); +} + +void Update::operator()(State& state, ShowSchedule const& msg) const { state.scheduleLoan.set(msg.loanId); } + +} // namespace lending + +namespace { + +LoanRow rowOf(dto::LoanInfo const& loan) { + auto const status = static_cast(loan.status); + bool const paid = status == bank::LoanStatus::PaidOff; + return LoanRow{ + .id = loan.id, + .accountId = loan.accountId, + .title = "Loan #" + std::to_string(loan.id), + .detail = "Outstanding " + fmt::money(loan.outstandingMinor, loan.currency) + " · " + + std::to_string(loan.rateBps) + " bps · " + std::to_string(loan.termMonths) + " mo", + .outstanding = loan.outstandingMinor, + .statusText = paid ? "Paid off" : "Active", + .statusTone = paid ? Tone::Good : Tone::Neutral, + .active = status == bank::LoanStatus::Active, + }; +} + +// The schedule carries no currency; its amounts are shown in USD. +InstallmentRow installmentOf(dto::Installment const& installment) { + return InstallmentRow{ + .month = installment.month, + .monthText = std::to_string(installment.month), + .principalText = fmt::money(installment.principalMinor, 0), + .interestText = fmt::money(installment.interestMinor, 0), + .remainingText = fmt::money(installment.remainingMinor, 0), + }; +} + +} // namespace + +LoansController::LoansController(morph::reactive::Runtime& runtime, BankHandlers& handlers, AuthController const& auth, + Notices& notices) + : _rt{&runtime}, + _form{runtime, &lending::init, lending::Update{}}, + _accounts{runtime, handlers.customers, + [session = &auth]() -> std::optional { + if (!session->signedIn()) { + return std::nullopt; + } + return dto::ListAccounts{}; + }}, + _loanList{runtime, handlers.loans, + [session = &auth]() -> std::optional { + if (!session->signedIn()) { + return std::nullopt; + } + return dto::ListLoans{}; + }}, + _schedule{runtime, handlers.loans, + [this, session = &auth]() -> std::optional { + std::int64_t const loan = _form.state().scheduleLoan.get(); + if (!session->signedIn() || loan == 0) { + return std::nullopt; + } + return dto::LoanScheduleRequest{.loanId = loan}; + }}, + _apply{runtime, handlers.loans, morph::reactive::MutationOptions{.invalidates = {&_loanList}}}, + _repay{runtime, handlers.loans, morph::reactive::MutationOptions{.invalidates = {&_loanList}}}, + _accountChoices{runtime, [this] { return openAccountChoices(_accounts.value()); }}, + _account{runtime, [this] { return resolveChoice(_accountChoices.get(), _form.state().account.get()); }}, + _rows{runtime, + [this] { + std::vector rows; + if (std::optional const& list = _loanList.value(); list.has_value()) { + rows.reserve(list->loans.size()); + for (dto::LoanInfo const& loan : list->loans) { + rows.push_back(rowOf(loan)); + } + } + return rows; + }}, + _installments{runtime, + [this] { + std::vector rows; + if (std::optional const& result = _schedule.value(); + result.has_value()) { + rows.reserve(result->installments.size()); + for (dto::Installment const& installment : result->installments) { + rows.push_back(installmentOf(installment)); + } + } + return rows; + }}, + _principalMinor{runtime, [this] { return fmt::parseMinor(_form.state().principal.get()); }}, + _rateBps{runtime, [this] { return fmt::parseWhole(_form.state().rate.get()); }}, + _termMonths{runtime, [this] { return fmt::parseWhole(_form.state().term.get()); }}, + _canApply{runtime, + [this] { + return _principalMinor.get().has_value() && _account.get() != 0 && + _rateBps.get().value_or(-1) >= 0 && _termMonths.get().value_or(0) > 0; + }}, + _failures{runtime, notices, + {[this] { return _accounts.error(); }, [this] { return _loanList.error(); }, + [this] { return _schedule.error(); }, [this] { return _apply.error(); }, + [this] { return _repay.error(); }}} {} + +void LoansController::chooseAccount(std::int64_t chosenId) { _form.send(lending::ChooseAccount{chosenId}); } +void LoansController::editPrincipal(std::string text) { _form.send(lending::EditPrincipal{std::move(text)}); } +void LoansController::editRate(std::string text) { _form.send(lending::EditRate{std::move(text)}); } +void LoansController::editTerm(std::string text) { _form.send(lending::EditTerm{std::move(text)}); } + +void LoansController::apply() { + if (!_canApply.peek()) { + return; + } + _apply.run(dto::ApplyLoan{.accountId = _account.peek(), + .principalMinor = _principalMinor.peek().value_or(0), + .rateBps = _rateBps.peek().value_or(0), + .termMonths = _termMonths.peek().value_or(0)}); + _form.send(lending::ClearApplication{}); +} + +void LoansController::repay(std::int64_t loanId) { + for (LoanRow const& row : _rows.peek()) { + if (row.id == loanId && row.active) { + _repay.run(dto::RepayLoan{.loanId = loanId, .fromAccountId = row.accountId, .amountMinor = row.outstanding}); + return; + } + } +} + +void LoansController::showSchedule(std::int64_t loanId) { + if (_form.state().scheduleLoan.peek() == loanId) { + _schedule.refetch(); + return; + } + _form.send(lending::ShowSchedule{loanId}); +} + +void LoansController::refresh() { + _rt->batch([this] { + _accounts.refetch(); + _loanList.refetch(); + _schedule.refetch(); + }); +} + +} // namespace bank::client +``` + +Create `examples/bank/app/views/loans_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "controllers/loans_controller.hpp" + +namespace bank::client::views { + +/// @brief The loans screen: the application form, one panel per loan, the schedule table. +/// @param loans Must outlive the mounted view. +/// @return The view. +[[nodiscard]] morph::ui::Node loansView(LoansController& loans); + +} // namespace bank::client::views +``` + +Create `examples/bank/app/views/loans_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "views/loans_view.hpp" + +#include +#include +#include +#include +#include + +#include "views/style.hpp" + +namespace bank::client::views { + +namespace ui = morph::ui; + +ui::Node loansView(LoansController& loans) { + LoansController* const ctl = &loans; + lending::State const* const form = &loans.form(); + + ui::Node applier = ui::panel({ + .title = "Apply for a loan", + .padding = 1, + .child = ui::row({ + .children = {ui::select({.options = [ctl] { return optionsOf(ctl->accountChoices()); }, + .selected = [ctl] { return accountKeyOf(ctl->selectedAccount()); }, + .onSelect = [ctl](ui::Key key) { ctl->chooseAccount(idOf(key)); }}), + ui::textInput({.value = [form] { return form->principal.get(); }, + .onChange = [ctl](std::string text) { ctl->editPrincipal(std::move(text)); }, + .placeholder = "Principal"}), + ui::textInput({.value = [form] { return form->rate.get(); }, + .onChange = [ctl](std::string text) { ctl->editRate(std::move(text)); }, + .placeholder = "Rate (bps)"}), + ui::textInput({.value = [form] { return form->term.get(); }, + .onChange = [ctl](std::string text) { ctl->editTerm(std::move(text)); }, + .placeholder = "Months"}), + ui::button({.label = "Apply", + .onClick = [ctl] { ctl->apply(); }, + .common = {.enabled = [ctl] { return ctl->canApply(); }}})}, + .gap = 1, + }), + }); + + ui::Node list = ui::forEach( + [ctl] { return ctl->loans(); }, [](LoanRow const& row) { return ui::Key{row.id}; }, + [ctl](morph::reactive::Signal const& loan) { + auto const* const row = &loan; + return ui::panel({ + .padding = 1, + .child = ui::row({ + .children = {ui::column({.children = {ui::text({.text = [row] { return row->get().title; }, + .role = ui::TextRole::Heading}), + ui::text({.text = [row] { return row->get().detail; }, + .role = ui::TextRole::Muted})}}), + ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::text({.text = [row] { return row->get().statusText; }, + .role = [row] { return roleOf(row->get().statusTone); }}), + ui::button({.label = "Schedule", + .onClick = [ctl, row] { ctl->showSchedule(row->peek().id); }}), + ui::button({.label = "Repay", + .onClick = [ctl, row] { ctl->repay(row->peek().id); }, + .common = {.visible = [row] { return row->get().active; }}})}, + .gap = 1, + }), + }); + }, + ui::Axis::Vertical, 1); + + ui::Node schedule = ui::table( + {ui::TableColumn{.label = "#", .width = ui::Sizing::fixed(4)}, + ui::TableColumn{.label = "PRINCIPAL", .width = ui::Sizing::stretch()}, + ui::TableColumn{.label = "INTEREST", .width = ui::Sizing::stretch()}, + ui::TableColumn{.label = "REMAINING", .width = ui::Sizing::stretch()}}, + [ctl] { return ctl->schedule(); }, + [](InstallmentRow const& row) { return ui::Key{static_cast(row.month)}; }, + [](morph::reactive::Signal const& installment) { + auto const* const row = &installment; + return std::vector{ + ui::text({.text = [row] { return row->get().monthText; }}), + ui::text({.text = [row] { return row->get().principalText; }}), + ui::text({.text = [row] { return row->get().interestText; }}), + ui::text({.text = [row] { return row->get().remainingText; }}), + }; + }); + + return ui::column({ + .children = {applier, + ui::scroll({.child = list, .common = {.layout = {.height = ui::Sizing::fixed(10)}}}), + ui::panel({.title = "Amortization schedule", + .padding = 1, + .child = schedule, + .common = {.layout = {.height = ui::Sizing::stretch()}}})}, + .gap = 1, + }); +} + +} // namespace bank::client::views +``` + +- [ ] **Step 4: Generate the golden, then run the tests to verify they pass** + +```bash +cmake --build build/bank --target bank_app_tests +BANK_UPDATE_GOLDEN=1 ./build/bank/examples/bank/bank_app_tests "Loans view: applying, showing the schedule and repaying through the screen" +cat examples/bank/tests/app/golden/loans.txt +``` + +Review it: a Panel `Apply for a loan` holding a Select with `options=[] selected=none`, TextInputs `Principal`, +`Rate (bps)`, `Months` and a Button `Apply` (`enabled=false`); a `Scroll` holding the list, an empty `Column`; a +Panel `Amortization schedule` with a `Table` whose columns are `#`, `PRINCIPAL`, `INTEREST`, `REMAINING` and no +`Row`s. + +Run: `./build/bank/examples/bank/bank_app_tests "[bank][app]"` +Expected: PASS — 46 test cases. + +Mutation checks, each restored afterwards: +1. In `_canApply`, replace `_rateBps.get().value_or(-1) >= 0` with `true`. Expected FAIL in "rate and term must be + whole numbers; a zero rate is allowed" at the first `CHECK_FALSE`. +2. In `repay`, pay `row.outstanding / 2`. Expected FAIL in "repaying the outstanding balance pays the loan off" + (times out waiting for `Paid off`). + +- [ ] **Step 5: Commit** + +```bash +git add examples/bank/app examples/bank/tests/app examples/bank/CMakeLists.txt +git commit -m "wip(bank): LoansController and the loans screen + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 8: `BankApplication`, the shell, the demo seed and `makeApplication` + +The old `Main.qml` + `AppShell.qml` + the WASM main's `seedDemo()`. `BankApplication` is the `ui::Application`: it +owns the connection, the handlers, the notice strip and the six controllers (in that order, so the controllers die +first and the bridge last), and the route. Its view is a `Switch` over `screen()` — login or shell — above the +notice strip; the shell is a `Menu` and a `Switch` over the route. Opening a page refreshes it, as `AppShell.qml`'s +`refreshCurrent()` did. `--seed` (and the browser build, Task 10) signs in as `demo`, creating the user with two +EUR accounts and an opening deposit the first time — through the bridge, so it works the same over SQLite, over +the in-memory models and against `ladder_bank_server`. + +**Files:** +- Create: `examples/bank/app/demo_seed.hpp`, `examples/bank/app/demo_seed.cpp` +- Create: `examples/bank/app/bank_application.hpp`, `examples/bank/app/bank_application.cpp` +- Create: `examples/bank/app/views/shell_view.hpp`, `examples/bank/app/views/shell_view.cpp` +- Create: `examples/bank/tests/app/golden/shell.txt` (generated in Step 4) +- Modify: `examples/bank/CMakeLists.txt` — append `app/demo_seed.cpp`, `app/bank_application.cpp` and + `app/views/shell_view.cpp` to `BANK_APP_SOURCES`; append `tests/app/test_bank_application.cpp` to `bank_app_tests` +- Test: `examples/bank/tests/app/test_bank_application.cpp` + +**Interfaces:** +- Consumes: Tasks 1–7; `ui::{Application, AppContext, switchOn, menu, Menu, MenuItem}` (Part 2); + `reactive::Effect` (Part 1); `examples::{AppEnvironment::seed, connect, Connection{bridge, callbacks, ready}, + Link::Remote}` (Part 6); `backend::LocalBackend`, `IBackend::setConnectHandler` (`include/morph/core/backend.hpp`); + `bank::{AccountKind, Currency}`. +- Produces: + - `enum class Route : std::uint8_t { Accounts, MoveMoney, Cards, Payees, Loans }`, `routeTitle(Route)`; + `enum class Screen : std::uint8_t { SignIn, Banking }`; `enum class DemoData : std::uint8_t { None, Seed }`. + - `class DemoSeed { kUsername, kPassword, kDisplayName; DemoSeed(bridge::Bridge&, BankHandlers&, AuthController&, + Notices&); finished(); }`. + - `class BankApplication final : public ui::Application { BankApplication(ui::AppContext&, + std::unique_ptr, DemoData = DemoData::None); view(); navigate(Route); signOut(); route(); + title(); screen(); handlers(); notices(); auth(); accounts(); transactions(); cards(); payees(); loans(); }` — + the demo seed starts once `Connection::ready()` holds, at once for a local link. + - `bank::client::makeApplication(ui::AppContext&, examples::AppEnvironment const&) -> + std::unique_ptr` — the contract's shape for every example (its closing "Conventions"), what + `ui/main.cpp` (Task 9) hands the frontend. Everything else is `env` or a default: `env.db` empty is the + temp-directory database, `env.seed` asks for the demo data, and a WebAssembly build always seeds, because the + hosted page has no command line. + - `views::noticeView(Notices const&)`, `views::shellView(BankApplication&)`, `views::appView(BankApplication&)`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/bank/tests/app/test_bank_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank/dto/account_dto.hpp" +#include "bank_app_test_support.hpp" +#include "bank_application.hpp" +#include "local_setup.hpp" +#include "recorded.hpp" + +using bank::client::BankApplication; +using bank::client::DemoData; +using bank::client::Route; +using bank::client::Screen; +using bank::testing::FakeAppContext; +using bank::testing::RecordingBackend; +using bank::testing::settle; + +namespace { + +struct AppWiring { + explicit AppWiring(DemoData demo = DemoData::None) + : app{std::make_unique( + ctx, + morph::examples::connect(ctx, bank::testing::localEnvironment(), bank::client::localSetup()), + demo)} {} + + FakeAppContext ctx; + std::unique_ptr app; +}; + +// The local models behind a link that reports itself up the way a remote transport does: only when the test +// calls the connect hook it was handed. +class LateBackend final : public morph::backend::LocalBackend { +public: + LateBackend(morph::exec::IExecutor& pool, std::function& connected) + : LocalBackend{pool}, _connected{&connected} {} + + void setConnectHandler(std::function const& handler) override { + if (handler) { + *_connected = handler; + } + } + +private: + std::function* _connected; +}; + +} // namespace + +TEST_CASE("BankApplication: signed out it shows the login screen; signing in shows the shell on Accounts", + "[bank][app][application]") { + AppWiring wiring; + RecordingBackend rec; + auto const mounted = bank::testing::mount(wiring.ctx, rec, wiring.app->view()); + CHECK(wiring.app->screen() == Screen::SignIn); + static_cast(bank::testing::button(rec, "Sign in")); + + static_cast(bank::testing::signUp(wiring.ctx, wiring.app->auth(), "app-shell")); + bank::testing::drain(wiring.ctx); + CHECK(wiring.app->screen() == Screen::Banking); + CHECK(wiring.app->route() == Route::Accounts); + CHECK(bank::testing::shows(rec, "Accounts")); + CHECK_FALSE(rec.find(bank::testing::kButton, "label", "Sign in").has_value()); +} + +TEST_CASE("BankApplication: navigating refreshes the page it opens", "[bank][app][application]") { + AppWiring wiring; + static_cast(bank::testing::signUp(wiring.ctx, wiring.app->auth(), "app-nav")); + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.app->accounts().loaded(); })); + wiring.app->navigate(Route::MoveMoney); + CHECK(wiring.app->title() == "Move Money"); + + // Opened behind the accounts page's back; only the navigation's refresh can show it. + static_cast( + bank::testing::awaitReply(wiring.ctx, wiring.app->handlers().customers.execute(bank::dto::OpenAccount{}))); + bank::testing::drain(wiring.ctx); + CHECK(wiring.app->accounts().cards().empty()); + wiring.app->navigate(Route::Accounts); + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.app->accounts().cards().size() == 1; })); + CHECK(wiring.app->title() == "Accounts"); +} + +TEST_CASE("BankApplication: logging out returns to the login screen on Accounts", "[bank][app][application]") { + AppWiring wiring; + static_cast(bank::testing::signUp(wiring.ctx, wiring.app->auth(), "app-logout")); + wiring.app->navigate(Route::Cards); + wiring.app->signOut(); + CHECK(wiring.app->screen() == Screen::SignIn); + CHECK(wiring.app->route() == Route::Accounts); + CHECK_FALSE(wiring.app->auth().signedIn()); +} + +TEST_CASE("BankApplication: the demo seed signs in as demo with two accounts, and a second start adds none", + "[bank][app][application][seed]") { + { + AppWiring wiring{DemoData::Seed}; + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.app->auth().signedIn(); })); + CHECK(wiring.app->auth().principal() == "demo"); + CHECK(wiring.app->auth().displayName() == "Demo User"); + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.app->accounts().cards().size() == 2; })); + CHECK(wiring.app->accounts().totalText() == "4800.00 EUR"); + CHECK(wiring.app->accounts().cards().front().overdraftText == "Overdraft 500.00 EUR"); + } + { + AppWiring wiring{DemoData::Seed}; + REQUIRE(settle(wiring.ctx, + [&wiring] { return wiring.app->auth().signedIn() && wiring.app->accounts().loaded(); })); + CHECK(wiring.app->accounts().cards().size() == 2); + } +} + +TEST_CASE("BankApplication: the demo seed waits until a remote transport is up", "[bank][app][application][seed]") { + FakeAppContext ctx; + bank::client::localSetup().setupDatabase(bank::testing::localEnvironment().db); + std::function connected; + auto pool = std::make_unique(2); + auto backend = std::make_unique(*pool, connected); + auto const app = std::make_unique( + ctx, + std::make_unique(ctx, morph::examples::Link::Remote, std::move(backend), + std::move(pool)), + DemoData::Seed); + + // Nothing is sent while the link is down: a remote transport rejects calls issued before it connects. + CHECK_FALSE(morph::examples::testing::pumpUntil(ctx.owner(), [&app] { return app->auth().signedIn(); }, + std::chrono::milliseconds{500})); + REQUIRE(connected); + connected(); // the transport's thread; the Connection posts the change to the owner + REQUIRE(settle(ctx, [&app] { return app->auth().signedIn(); })); + CHECK(app->auth().principal() == "demo"); +} + +TEST_CASE("Shell view: the sidebar, the title and the page", "[bank][app][view][application]") { + AppWiring wiring; + std::string const principal = bank::testing::signUp(wiring.ctx, wiring.app->auth(), "view-shell"); + REQUIRE(settle(wiring.ctx, [&wiring] { return wiring.app->accounts().loaded(); })); + RecordingBackend rec; + auto const mounted = bank::testing::mount(wiring.ctx, rec, wiring.app->view()); + bank::testing::checkGolden("shell", rec.dump(), {{principal, ""}}); + CHECK(bank::testing::shows(rec, "@" + principal)); + + std::vector const menus = rec.all(bank::testing::kMenu); + REQUIRE(menus.size() == 1); + rec.chooseIndex(menus.front(), 1); + bank::testing::drain(wiring.ctx); + CHECK(wiring.app->route() == Route::MoveMoney); + CHECK(bank::testing::shows(rec, "Move Money")); + + rec.click(bank::testing::button(rec, "Log out")); + bank::testing::drain(wiring.ctx); + CHECK(wiring.app->screen() == Screen::SignIn); + static_cast(bank::testing::button(rec, "Sign in")); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/bank --target bank_app_tests` +Expected: FAIL — `fatal error: 'bank_application.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bank/app/demo_seed.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include "bank/dto/auth_dto.hpp" +#include "bank_handlers.hpp" +#include "notices.hpp" + +/// @file +/// The demo data the hosted page and `--seed` start with: user `demo` / `demo1234`, a checking account +/// with a 500.00 EUR overdraft and a 4,800.00 EUR opening deposit, and a savings account. + +namespace bank::client { + +class AuthController; + +/// @brief Signs in as the demo user, creating it and its accounts first when it does not exist. +/// +/// Everything goes through the bridge, as a user's own calls would, so it behaves the same over the +/// SQLite models, the in-memory models and a remote server. The session is installed before the +/// accounts are opened (they need a principal) and the user is signed in only once they exist, so +/// the screens' first fetch already lists them. +class DemoSeed { +public: + static constexpr std::string_view kUsername = "demo"; + static constexpr std::string_view kPassword = "demo1234"; + static constexpr std::string_view kDisplayName = "Demo User"; + + /// @brief Starts seeding at once. Every parameter is borrowed and must outlive this object. + DemoSeed(morph::bridge::Bridge& bridge, BankHandlers& handlers, AuthController& auth, Notices& notices); + + /// @brief Whether the seed signed in or gave up. + [[nodiscard]] bool finished() const noexcept { return _finished; } + +private: + void onSignIn(dto::AuthResult const& result); + void onRegistered(dto::AuthResult const& result); + void onChecking(dto::AuthResult const& user, std::int64_t checkingId); + void onDeposited(dto::AuthResult const& user); + void fail(std::exception_ptr const& error); + + morph::bridge::Bridge* _bridge; + BankHandlers* _handlers; + AuthController* _auth; + Notices* _notices; + bool _finished = false; + // Last member, so it is destroyed first: a reply arriving after the application is gone is dropped. + morph::async::CallbackScope _calls; +}; + +} // namespace bank::client +``` + +Create `examples/bank/app/demo_seed.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "demo_seed.hpp" + +#include +#include +#include +#include + +#include "bank/core/types.hpp" +#include "bank/dto/account_dto.hpp" +#include "bank/dto/transaction_dto.hpp" +#include "controllers/auth_controller.hpp" + +namespace bank::client { + +DemoSeed::DemoSeed(morph::bridge::Bridge& bridge, BankHandlers& handlers, AuthController& auth, Notices& notices) + : _bridge{&bridge}, _handlers{&handlers}, _auth{&auth}, _notices{¬ices} { + _handlers->auth + .execute(dto::LoginRequest{.username = std::string{kUsername}, .password = std::string{kPassword}}) + .then(_calls, [this](dto::AuthResult const& result) { onSignIn(result); }) + .onError(_calls, [this](std::exception_ptr error) { fail(error); }); +} + +void DemoSeed::onSignIn(dto::AuthResult const& result) { + if (result.ok) { + _auth->adopt(result); + _finished = true; + return; + } + _handlers->auth + .execute(dto::RegisterUser{.username = std::string{kUsername}, + .password = std::string{kPassword}, + .displayName = std::string{kDisplayName}}) + .then(_calls, [this](dto::AuthResult const& registered) { onRegistered(registered); }) + .onError(_calls, [this](std::exception_ptr error) { fail(error); }); +} + +void DemoSeed::onRegistered(dto::AuthResult const& result) { + if (!result.ok) { + _auth->adopt(result); // shows the refusal + _finished = true; + return; + } + morph::session::Context session; + session.principal = result.principal; + _bridge->setDefaultSession(std::move(session)); + _handlers->customers + .execute(dto::OpenAccount{.kind = static_cast(bank::AccountKind::Checking), + .currency = static_cast(bank::Currency::EUR), + .overdraftMinor = 50000}) + .then(_calls, [this, result](dto::AccountInfo const& checking) { onChecking(result, checking.id); }) + .onError(_calls, [this](std::exception_ptr error) { fail(error); }); +} + +void DemoSeed::onChecking(dto::AuthResult const& user, std::int64_t checkingId) { + _handlers->transactions + .execute(dto::Deposit{.accountId = checkingId, .amountMinor = 480000, .description = "opening deposit"}) + .then(_calls, [this, user](dto::TxnInfo const& /*deposit*/) { onDeposited(user); }) + .onError(_calls, [this](std::exception_ptr error) { fail(error); }); +} + +void DemoSeed::onDeposited(dto::AuthResult const& user) { + _handlers->customers + .execute(dto::OpenAccount{.kind = static_cast(bank::AccountKind::Savings), + .currency = static_cast(bank::Currency::EUR)}) + .then(_calls, + [this, user](dto::AccountInfo const& /*savings*/) { + _auth->adopt(user); + _finished = true; + }) + .onError(_calls, [this](std::exception_ptr error) { fail(error); }); +} + +void DemoSeed::fail(std::exception_ptr const& error) { + _notices->error(morph::reactive::errorMessage(error)); + _finished = true; +} + +} // namespace bank::client +``` + +Create `examples/bank/app/bank_application.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bank_handlers.hpp" +#include "controllers/accounts_controller.hpp" +#include "controllers/auth_controller.hpp" +#include "controllers/cards_controller.hpp" +#include "controllers/loans_controller.hpp" +#include "controllers/payees_controller.hpp" +#include "controllers/transactions_controller.hpp" +#include "demo_seed.hpp" +#include "notices.hpp" + +/// @file +/// The bank client as one `ui::Application`, and the factory the binary hands its frontend. + +namespace bank::client { + +/// @brief The signed-in user's pages, in sidebar order. +enum class Route : std::uint8_t { Accounts, MoveMoney, Cards, Payees, Loans }; + +/// @brief Which top-level screen is shown. +enum class Screen : std::uint8_t { SignIn, Banking }; + +/// @brief Whether the application signs in as the demo user on start. +enum class DemoData : std::uint8_t { None, Seed }; + +/// @brief A page's heading, which is also its sidebar entry. +[[nodiscard]] std::string_view routeTitle(Route route) noexcept; + +/// @brief The bank client: the session, six screens, the route between them and one notice strip. +class BankApplication final : public morph::ui::Application { +public: + /// @param ctx The frontend's context: the runtime, and the scheduler the notice strip hides on. + /// @param connection The bridge every handler calls through. Owned: it outlives the controllers. + /// @param demo Whether to sign in as the demo user, as soon as the connection is ready. + BankApplication(morph::ui::AppContext& ctx, std::unique_ptr connection, + DemoData demo = DemoData::None); + ~BankApplication() override = default; + BankApplication(BankApplication const&) = delete; + BankApplication& operator=(BankApplication const&) = delete; + BankApplication(BankApplication&&) = delete; + BankApplication& operator=(BankApplication&&) = delete; + + [[nodiscard]] morph::ui::Node view() override; + + /// @brief Opens @p route, fetching its page again; opening the page already shown does nothing. + void navigate(Route route); + /// @brief Signs out and returns to the first page for the next sign-in. + void signOut(); + + [[nodiscard]] Route route() const { return _route.get(); } + [[nodiscard]] std::string const& title() const { return _title.get(); } + [[nodiscard]] Screen screen() const { return _screen.get(); } + + [[nodiscard]] BankHandlers& handlers() noexcept { return _handlers; } + [[nodiscard]] Notices& notices() noexcept { return _notices; } + [[nodiscard]] AuthController& auth() noexcept { return _auth; } + [[nodiscard]] AccountsController& accounts() noexcept { return _accounts; } + [[nodiscard]] TransactionsController& transactions() noexcept { return _transactions; } + [[nodiscard]] CardsController& cards() noexcept { return _cards; } + [[nodiscard]] PayeesController& payees() noexcept { return _payees; } + [[nodiscard]] LoansController& loans() noexcept { return _loans; } + +private: + morph::reactive::Runtime* _rt; + std::unique_ptr _connection; + BankHandlers _handlers; + Notices _notices; + AuthController _auth; + AccountsController _accounts; + TransactionsController _transactions; + CardsController _cards; + PayeesController _payees; + LoansController _loans; + morph::reactive::Signal _route; + morph::reactive::Computed _title; + morph::reactive::Computed _screen; + std::unique_ptr _seed; + // Starts `_seed` once the connection is ready; after `_seed`, so it is destroyed first. + std::unique_ptr _seedWhenReady; +}; + +/// @brief Connects as @p env says (local by default, `--server` for remote) and builds the application. +/// +/// Every knob is `env` or a default: an empty `env.db` is `morph_bank.db` in the temp directory, `env.seed` signs +/// in as the demo user, and a WebAssembly build always does, because the hosted page has no command line. +/// @param ctx The frontend's context. +/// @param env The command line's (or, in the browser, the page's) settings. +/// @return The application. +/// @throws morph::examples::TransportError when `--server` is given and this frontend cannot reach one. +[[nodiscard]] std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env); + +} // namespace bank::client +``` + +Create `examples/bank/app/bank_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "bank_application.hpp" + +#include + +#include "local_setup.hpp" +#include "views/shell_view.hpp" + +namespace bank::client { + +std::string_view routeTitle(Route route) noexcept { + switch (route) { + case Route::Accounts: + return "Accounts"; + case Route::MoveMoney: + return "Move Money"; + case Route::Cards: + return "Cards"; + case Route::Payees: + return "Payees & Bills"; + case Route::Loans: + return "Loans"; + } + return "Accounts"; +} + +BankApplication::BankApplication(morph::ui::AppContext& ctx, std::unique_ptr connection, + DemoData demo) + : _rt{&ctx.runtime()}, + _connection{std::move(connection)}, + _handlers{_connection->bridge(), _connection->callbacks()}, + _notices{ctx.runtime(), ctx.scheduler()}, + _auth{ctx.runtime(), _connection->bridge(), _handlers, _notices}, + _accounts{ctx.runtime(), _handlers, _auth, _notices}, + _transactions{ctx.runtime(), _handlers, _auth, _notices}, + _cards{ctx.runtime(), _handlers, _auth, _notices}, + _payees{ctx.runtime(), _handlers, _auth, _notices}, + _loans{ctx.runtime(), _handlers, _auth, _notices}, + _route{ctx.runtime(), Route::Accounts}, + _title{ctx.runtime(), [this] { return std::string{routeTitle(_route.get())}; }}, + _screen{ctx.runtime(), [this] { return _auth.signedIn() ? Screen::Banking : Screen::SignIn; }} { + if (demo == DemoData::Seed) { + // A remote transport rejects calls issued before it connects; a local link is ready at once, so this + // effect's first run starts the seed there. + _seedWhenReady = std::make_unique(ctx.runtime(), [this] { + if (_seed == nullptr && _connection->ready().get()) { + _seed = std::make_unique(_connection->bridge(), _handlers, _auth, _notices); + } + }); + } +} + +morph::ui::Node BankApplication::view() { return views::appView(*this); } + +void BankApplication::navigate(Route route) { + if (_route.peek() == route) { + return; + } + _rt->batch([&] { + _route.set(route); + switch (route) { + case Route::Accounts: + _accounts.refresh(); + break; + case Route::MoveMoney: + _transactions.refresh(); + break; + case Route::Cards: + _cards.refresh(); + break; + case Route::Payees: + _payees.refresh(); + break; + case Route::Loans: + _loans.refresh(); + break; + } + }); +} + +void BankApplication::signOut() { + _rt->batch([this] { + _auth.signOut(); + _route.set(Route::Accounts); + }); +} + +std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env) { +#if defined(__EMSCRIPTEN__) + constexpr bool kAlwaysSeed = true; +#else + constexpr bool kAlwaysSeed = false; +#endif + return std::make_unique(ctx, morph::examples::connect(ctx, env, localSetup()), + env.seed || kAlwaysSeed ? DemoData::Seed : DemoData::None); +} + +} // namespace bank::client +``` + +Create `examples/bank/app/views/shell_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +namespace bank::client { +class BankApplication; +class Notices; +} // namespace bank::client + +namespace bank::client::views { + +/// @brief The notice strip: the last notice, shown while it is visible, in its tone. +[[nodiscard]] morph::ui::Node noticeView(Notices const& notices); + +/// @brief The signed-in shell: the sidebar menu, the page heading and the current page. +[[nodiscard]] morph::ui::Node shellView(BankApplication& app); + +/// @brief The whole client: login or the shell, above the notice strip. +[[nodiscard]] morph::ui::Node appView(BankApplication& app); + +} // namespace bank::client::views +``` + +Create `examples/bank/app/views/shell_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include "views/shell_view.hpp" + +#include "bank_application.hpp" +#include "views/accounts_view.hpp" +#include "views/cards_view.hpp" +#include "views/login_view.hpp" +#include "views/loans_view.hpp" +#include "views/move_money_view.hpp" +#include "views/payees_view.hpp" +#include "views/style.hpp" + +namespace bank::client::views { + +namespace ui = morph::ui; + +ui::Node noticeView(Notices const& notices) { + Notices const* const strip = ¬ices; + return ui::text({.text = [strip] { return strip->text(); }, + .role = [strip] { return roleOf(strip->tone()); }, + .common = {.visible = [strip] { return strip->visible(); }}}); +} + +ui::Node shellView(BankApplication& app) { + BankApplication* const shell = &app; + AuthController const* const auth = &app.auth(); + auto const navigateTo = [shell](Route route) { return [shell, route] { shell->navigate(route); }; }; + + ui::Node sidebar = ui::column({ + .children = {ui::text({.text = "Morph Bank", .role = ui::TextRole::Heading}), + ui::text({.text = "personal banking", .role = ui::TextRole::Muted}), + ui::menu({.items = {ui::MenuItem{.label = "Accounts", .onSelect = navigateTo(Route::Accounts)}, + ui::MenuItem{.label = "Move Money", .onSelect = navigateTo(Route::MoveMoney)}, + ui::MenuItem{.label = "Cards", .onSelect = navigateTo(Route::Cards)}, + ui::MenuItem{.label = "Payees & Bills", .onSelect = navigateTo(Route::Payees)}, + ui::MenuItem{.label = "Loans", .onSelect = navigateTo(Route::Loans)}}}), + ui::spacer({.common = {.layout = {.height = ui::Sizing::stretch()}}}), + ui::text({.text = [auth] { return auth->displayName(); }}), + ui::text({.text = [auth] { return auth->handle(); }, .role = ui::TextRole::Muted}), + ui::button({.label = "Log out", .onClick = [shell] { shell->signOut(); }})}, + .gap = 1, + .common = {.layout = {.width = ui::Sizing::fixed(24), .height = ui::Sizing::stretch()}}, + }); + + ui::Node page = ui::switchOn([shell] { return shell->route(); }, + {{Route::Accounts, accountsView(app.accounts())}, + {Route::MoveMoney, moveMoneyView(app.transactions())}, + {Route::Cards, cardsView(app.cards())}, + {Route::Payees, payeesView(app.payees())}, + {Route::Loans, loansView(app.loans())}}); + + ui::Node content = ui::column({ + .children = {ui::text({.text = [shell] { return shell->title(); }, .role = ui::TextRole::Heading}), page}, + .gap = 1, + .common = {.layout = {.width = ui::Sizing::stretch(), .height = ui::Sizing::stretch()}}, + }); + + return ui::row({.children = {sidebar, content}, .gap = 2}); +} + +ui::Node appView(BankApplication& app) { + BankApplication* const shell = &app; + return ui::column({ + .children = {ui::switchOn([shell] { return shell->screen(); }, + {{Screen::SignIn, loginView(app.auth())}, {Screen::Banking, shellView(app)}}), + noticeView(app.notices())}, + .gap = 1, + }); +} + +} // namespace bank::client::views +``` + +- [ ] **Step 4: Generate the golden, then run the tests to verify they pass** + +```bash +cmake --build build/bank --target bank_app_tests +BANK_UPDATE_GOLDEN=1 ./build/bank/examples/bank/bank_app_tests "Shell view: the sidebar, the title and the page" +cat examples/bank/tests/app/golden/shell.txt +``` + +Review it: a `Column` holding the screen switch (a `Slot`) and a Text with `visible=false` (the notice strip); +inside the `Slot`, a `Row` of the sidebar — a `Column` with Heading `Morph Bank`, Muted `personal banking`, a `Menu` +with `items=[Accounts,Move Money,Cards,Payees & Bills,Loans]`, a `Spacer`, Text `Tester`, Muted `@`, Button +`Log out` — and the content `Column` with Heading `Accounts` above the page's `Slot`, holding the accounts page as +`accounts.txt` shows it (total `0`). No login form. + +Run: `./build/bank/examples/bank/bank_app_tests "[bank][app]"` +Expected: PASS — 52 test cases. + +Mutation checks, each restored afterwards: +1. In `BankApplication::navigate`, delete the `switch`. Expected FAIL in "navigating refreshes the page it opens" + (times out with no cards). +2. In `DemoSeed::onDeposited`, delete `_auth->adopt(user);`. Expected FAIL in "the demo seed signs in as demo with + two accounts, and a second start adds none" (times out waiting for `signedIn()`). +3. In `BankApplication::signOut`, delete `_route.set(Route::Accounts);`. Expected FAIL in "logging out returns to the + login screen on Accounts". +4. In `BankApplication`'s constructor, drop `&& _connection->ready().get()` from the seed effect. Expected FAIL in + "the demo seed waits until a remote transport is up" at the `CHECK_FALSE` (it signs in before the link is up). + +- [ ] **Step 5: Commit** + +```bash +git add examples/bank/app examples/bank/tests/app examples/bank/CMakeLists.txt +git commit -m "wip(bank): BankApplication, the shell, the demo seed and makeApplication + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 9: The `bank` binary and its frontend smoke tests + +`ui/main.cpp` is spec 4 §3's composition root, unchanged: it reads the environment, offers every frontend this +configure built (Qt Quick first, then the terminal), lets `ui::selectFrontend` choose (`--ui=`, `MORPH_UI`, else the +first usable) and runs `bank::client::makeApplication(ctx, env)`. Everything bank-specific — the default database, +the browser's demo seed — lives in `makeApplication` (Task 8). The binary is built when at least one frontend is; +`bank_app` and its tests are not gated on either (spec 4 §6). The smoke tests are a binary of their own, +`bank_smoke_tests`, because the Qt Quick frontend constructs its own `QGuiApplication` and so runs only under a +`main` that owns no Qt application object (`morph_test_main`). + +**Files:** +- Create: `examples/bank/ui/main.cpp` +- Modify: `examples/bank/CMakeLists.txt` — (a) after the `bank_app` block (ending with the toolkit-free + `foreach`), the `bank` executable; (b) in the tests block, after `catch_discover_tests(bank_app_tests …)`, the + `bank_smoke_tests` executable +- Test: `examples/bank/tests/smoke/test_bank_frontend_smoke.cpp` + +**Interfaces:** +- Consumes: Task 8's `bank::client::makeApplication`; `ui::{FrontendOption, selectFrontend, AppContext, + ApplicationFactory, FrontendSelectionError}` (Part 2, `morph/ui/frontend.hpp`); `tui::frontendOption(FrontendConfig + = {})` (Part 3, `morph/tui/frontend.hpp`); `qt_quick::frontendOption(int&, char**, + ui::EnvironmentReader = ui::processEnvironment())` (Part 4, `morph/qt_quick/frontend.hpp`); `examples::AppEnvironment::fromArgs` (Part 6); `examples::testing::{runFrontendSmoke, + SmokeFrontend}` (`testkit/frontend_smoke.hpp`, target `morph::example_testkit`) and CMake + `morph_example_frontends( )` (`cmake/morph_example_app.cmake`) (Part 6). +- Produces: CMake target `bank` (native), target `bank_smoke_tests`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/bank/tests/smoke/test_bank_frontend_smoke.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The application mounts and quits on every frontend this configure built: makeApplication over this +// process's own local database, on the terminal through scripted input and on Qt Quick offscreen. A case for +// a frontend the configure did not build is skipped by the harness. + +#include +#include +#include +#include + +#include "bank_application.hpp" +#include "bank_test_support.hpp" + +namespace { + +morph::ui::ApplicationFactory bankFactory() { + return [](morph::ui::AppContext& ctx) { + morph::examples::AppEnvironment env; + env.db = bank::testing::connectionString(); + return bank::client::makeApplication(ctx, env); + }; +} + +} // namespace + +TEST_CASE("bank mounts and quits on the terminal frontend", "[bank][smoke][tui]") { + morph::examples::testing::runFrontendSmoke(bankFactory(), morph::examples::testing::SmokeFrontend::Tui); +} + +TEST_CASE("bank mounts and quits on Qt Quick, offscreen", "[bank][smoke][qt]") { + morph::examples::testing::runFrontendSmoke(bankFactory(), morph::examples::testing::SmokeFrontend::QtQuick); +} +``` + +In `examples/bank/CMakeLists.txt`, place (b) — after `catch_discover_tests(bank_app_tests …)`: + +```cmake + # ── bank_smoke_tests: the application on each built frontend ───────── + # Mounts makeApplication's view and quits -- on the terminal through + # scripted input, on Qt Quick offscreen. A binary of its own because + # the Qt Quick frontend constructs its own QGuiApplication, and + # morph_test_main owns no Qt application object. The harness is + # morph::example_testkit (examples/common/app), which links every built + # frontend; it is linked by name, which CMake resolves at generate time. + # It exists only where the bank binary does. + if(TARGET bank) + add_executable(bank_smoke_tests tests/smoke/test_bank_frontend_smoke.cpp) + target_include_directories(bank_smoke_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests) + target_link_libraries(bank_smoke_tests PRIVATE bank_app morph::example_testkit morph_test_main) + target_compile_features(bank_smoke_tests PRIVATE cxx_std_23) + apply_bigobj(bank_smoke_tests) + if(DEFINED AF_SANITIZER) + apply_sanitizers(bank_smoke_tests ${AF_SANITIZER}) + endif() + # Offscreen: the runner may have no display, and this suite never + # looks at a pixel. The QGuiApplication is created inside the test + # case, not in main, so discovery needs no platform plugin. + catch_discover_tests(bank_smoke_tests + DISCOVERY_MODE PRE_TEST + PROPERTIES + LABELS "bank" + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + endif() +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake -S . -B build/bank && cmake --build build/bank --target bank_smoke_tests` +Expected: FAIL — `ninja: error: unknown target 'bank_smoke_tests'` (no `bank` target yet, so the block is skipped). + +- [ ] **Step 3: Implement** + +Create `examples/bank/ui/main.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The bank client's composition root: read the environment, offer every frontend this build has, +// let the user or the environment pick one, and run the application on it. The same file is the +// browser build's main, with Qt Quick the only frontend. + +#include +#include +#include +#include +#include + +#include "bank_application.hpp" + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#endif +#if MORPH_EXAMPLE_HAS_TUI +#include +#endif + +int main(int argc, char** argv) { + try { + auto const env = morph::examples::AppEnvironment::fromArgs(argc, argv); + std::vector built; +#if MORPH_EXAMPLE_HAS_QT_QUICK + built.push_back(morph::qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_EXAMPLE_HAS_TUI + built.push_back(morph::tui::frontendOption()); +#endif + auto const frontend = morph::ui::selectFrontend(built, argc, argv); + return frontend->run([&env](morph::ui::AppContext& ctx) { return bank::client::makeApplication(ctx, env); }); + } catch (std::exception const& error) { + std::cerr << "bank: " << error.what() << '\n'; + } catch (...) { + std::cerr << "bank: unknown error\n"; + } + return 1; +} +``` + +In `examples/bank/CMakeLists.txt`, place (a) — after the `bank_app` block: + +```cmake +# ── bank: the one client binary ────────────────────────────────────────────── +# ui/main.cpp links every frontend this configure built and picks one at run +# time (--ui=, MORPH_UI, else the first usable). Built when at least one +# frontend is. morph_example_frontends (cmake/morph_example_app.cmake) links +# them and defines MORPH_EXAMPLE_HAS_TUI / MORPH_EXAMPLE_HAS_QT_QUICK for +# main's #if; it names the frontend targets, which the root creates after it +# adds examples/bank and CMake resolves at generate time. Not +# morph_add_example_ui, which calls apply_warnings(): bank_app's headers bring +# the ORM's, which are not -Werror clean (bank_lib's reason). +if(MORPH_BUILD_TUI OR MORPH_BUILD_QT_QUICK) + if(MORPH_BUILD_QT_QUICK) + find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2) + qt_add_executable(bank ui/main.cpp) + else() + add_executable(bank ui/main.cpp) + endif() + target_link_libraries(bank PRIVATE bank_app) + morph_example_frontends(bank PRIVATE) + target_compile_features(bank PRIVATE cxx_std_23) + apply_bigobj(bank) + if(DEFINED AF_SANITIZER) + apply_sanitizers(bank ${AF_SANITIZER}) + endif() +else() + message(STATUS "examples/bank: the bank binary needs MORPH_BUILD_TUI or MORPH_BUILD_QT_QUICK; not built") +endif() +``` + +- [ ] **Step 4: Run the tests to verify they pass, and run the binary** + +```bash +cmake --build build/bank --target bank bank_smoke_tests +ctest --test-dir build/bank -L bank -R 'bank mounts' --output-on-failure +``` + +Expected: PASS — two tests (`… on the terminal frontend`, `… on Qt Quick, offscreen`), neither skipped on +`build/bank`, which builds both frontends. + +Then by hand, each from a terminal (the TUI needs one): + +```bash +./build/bank/examples/bank/bank --ui=tui --seed # opens signed in as Demo User; Tab to the menu, + # Enter on "Move Money", deposit 10, Ctrl+C quits +./build/bank/examples/bank/bank --ui=qt --seed # the same in a window +./build/bank/examples/bank/bank --ui=nonsense; echo "exit $?" + # "bank: …" naming the built frontends, exit 1 +cmake --build build/bank --target ladder_bank_server +BANK_DB="DRIVER=SQLite3;Database=$PWD/build/bank/remote.db;Timeout=5000" BANK_PORT=54321 \ + ./build/bank/examples/bank/ladder_bank_server & +./build/bank/examples/bank/bank --ui=qt --server ws://127.0.0.1:54321 --seed +./build/bank/examples/bank/bank --ui=tui --server ws://127.0.0.1:54321 # sign in as demo / demo1234 +kill %1 +``` + +Expected: both frontends show the demo user's two accounts; the remote runs reach the server (its log shows the +calls) and a deposit in one client shows in the other after navigating. Record what was seen for Task 13's +commit body — these runs are observed, not asserted by a test. + +Mutation check: make `BankApplication::view()` throw `std::runtime_error{"broken"}`. Expected FAIL in both smoke +tests (the run does not exit 0). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/bank/ui examples/bank/tests/smoke examples/bank/CMakeLists.txt +git commit -m "wip(bank): the bank binary on an injected frontend, and its smoke tests + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 10: The browser build on the in-memory models + +The WebAssembly client becomes the same `ui/main.cpp` and app sources, compiled with Qt Quick as the only frontend +and against the in-memory models `gui_wasm/` already has: its shadow model headers, `bank::wasm` store and +`*_model_wasm.cpp` move to `examples/bank/wasm/` unchanged, and only the build around them is new. A native test +binary compiles the very same set, so a controller that calls an action the in-memory models do not serve fails a +local `ctest`, not only a browser. + +**Files:** +- Move: `examples/bank/gui_wasm/` → `examples/bank/wasm/` (`git mv`); delete `examples/bank/wasm/main_wasm.cpp` +- Create: `examples/bank/wasm/local_setup_wasm.cpp`; rewrite `examples/bank/wasm/CMakeLists.txt` +- Modify: `examples/bank/wasm/include/bank/models/auth_model.hpp` (line 5) and + `examples/bank/wasm/include/bank/wasm/store_ops.hpp` (lines 19–20) — comments that name the QML controllers +- Modify: `examples/bank/CMakeLists.txt` — (a) after `set(BANK_APP_SOURCES …)`, `set(BANK_INMEMORY_SOURCES …)`; + (b) the `if(EMSCRIPTEN)` branch; (c) in the tests block, after the `bank_smoke_tests` block, `bank_inmemory_tests` +- Modify: `.github/workflows/wasm-demo.yml` — header comment, `paths`, both Qt installs, Configure, Build, Stage bundle +- Test: `examples/bank/tests/inmemory/test_inmemory_models.cpp` + +**Interfaces:** +- Consumes: Tasks 1–9 (`BANK_APP_SOURCES`, `BankApplication`, `DemoData::Seed`, `localSetup()`'s declaration, + `makeApplication`'s WebAssembly seed default); `examples::LocalSetup::workers`, `FakeAppContext` and CMake + `morph_example_frontends` (Part 6); `morph::qt_quick` under Emscripten (Part 4, spec 3 §6). +- Produces: `bank::client::localSetup()` for the in-memory build (one worker, no database); CMake variable + `BANK_INMEMORY_SOURCES`; the browser target `bank` (Emscripten); target `bank_inmemory_tests` (native). + +- [ ] **Step 1: Write the failing test** + +Create `examples/bank/tests/inmemory/test_inmemory_models.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The browser build's model set, run natively: the app sources compiled against wasm/'s shadow model +// headers and in-memory models, started the way the hosted page starts — seeded and signed in — and +// then driven through a deposit. A controller calling an action the in-memory models do not serve +// fails here instead of only in a browser. + +#include +#include +#include +#include + +#include "bank_application.hpp" +#include "local_setup.hpp" +#include "test_app_context.hpp" + +using bank::client::BankApplication; +using bank::client::DemoData; +using bank::testing::settle; +using bank::testing::FakeAppContext; + +TEST_CASE("the in-memory models serve the seeded demo and a deposit", "[bank][inmemory]") { + FakeAppContext ctx; + auto const app = std::make_unique( + ctx, morph::examples::connect(ctx, morph::examples::AppEnvironment{}, bank::client::localSetup()), + DemoData::Seed); + REQUIRE(settle(ctx, [&app] { return app->auth().signedIn(); })); + CHECK(app->auth().principal() == "demo"); + REQUIRE(settle(ctx, [&app] { return app->accounts().cards().size() == 2; })); + CHECK(app->accounts().totalText() == "4800.00 EUR"); + + bank::client::TransactionsController& txns = app->transactions(); + REQUIRE(settle(ctx, [&txns] { return txns.accountChoices().size() == 2; })); + txns.editAmount("200.00"); + txns.deposit(); + REQUIRE(settle(ctx, [&app] { return app->notices().visible(); })); + CHECK(app->notices().text() == "Transaction posted"); + app->accounts().refresh(); + REQUIRE(settle(ctx, [&app] { return app->accounts().totalText() == "5000.00 EUR"; })); +} +``` + +In `examples/bank/CMakeLists.txt`, place (c) — after the `bank_smoke_tests` block: + +```cmake + # ── bank_inmemory_tests: the browser build's models, natively ──────── + # The app sources compiled exactly as wasm/ compiles them — shadow model + # headers first, in-memory models, no bank_lib — so a controller that + # calls an action the in-memory models do not serve fails here. One + # worker (local_setup_wasm.cpp): the in-memory store has no locks, + # because the browser runs everything on one thread. + add_executable(bank_inmemory_tests + tests/inmemory/test_inmemory_models.cpp + ${BANK_APP_SOURCES} + ${BANK_INMEMORY_SOURCES} + ) + target_include_directories(bank_inmemory_tests BEFORE PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/wasm/include + ${CMAKE_CURRENT_SOURCE_DIR}/app + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/tests/app) + target_link_libraries(bank_inmemory_tests PRIVATE morph::morph morph::ladder_app_common morph_test_main) + target_compile_features(bank_inmemory_tests PRIVATE cxx_std_23) + apply_bigobj(bank_inmemory_tests) + if(DEFINED AF_SANITIZER) + apply_sanitizers(bank_inmemory_tests ${AF_SANITIZER}) + endif() + catch_discover_tests(bank_inmemory_tests + DISCOVERY_MODE PRE_TEST + PROPERTIES LABELS "bank") +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake -S . -B build/bank && cmake --build build/bank --target bank_inmemory_tests` +Expected: FAIL — `fatal error: 'Lightweight/Lightweight.hpp' file not found`: `wasm/include` does not exist yet, so +the model headers resolve to the native ones, which need the ORM this target does not link. + +- [ ] **Step 3: Implement** + +Move the in-memory models and retire the old entry point: + +```bash +git mv examples/bank/gui_wasm examples/bank/wasm +git rm examples/bank/wasm/main_wasm.cpp +``` + +Edit the two comments that name the QML layer. In `examples/bank/wasm/include/bank/models/auth_model.hpp` replace + +```cpp +// WASM shadow of include/bank/models/auth_model.hpp: the SAME class + action +// registrations the controllers/QML expect, but with no Lightweight/ODBC +``` + +with + +```cpp +// WASM shadow of include/bank/models/auth_model.hpp: the SAME class + action +// registrations the app's controllers expect, but with no Lightweight/ODBC +``` + +and in `examples/bank/wasm/include/bank/wasm/store_ops.hpp` replace + +```cpp +/// in-memory `Db`. They throw the same `bank::` domain errors so the GUI +/// controllers classify failures identically. +``` + +with + +```cpp +/// in-memory `Db`. They throw the same `bank::` domain errors, so a failure +/// reads the same in the browser as natively. +``` + +Create `examples/bank/wasm/local_setup_wasm.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// localSetup() for the in-memory models: no database to set up, and one worker. The store has no +// locks because the browser runs every model on its one thread; a native run of the same models +// (bank_inmemory_tests) keeps them on one thread too. + +#include + +#include "local_setup.hpp" + +namespace bank::client { + +morph::examples::LocalSetup localSetup() { + return morph::examples::LocalSetup{.setupDatabase = [](std::string const& /*database*/) {}, .workers = 1}; +} + +} // namespace bank::client +``` + +Replace `examples/bank/wasm/CMakeLists.txt` with: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# +# The bank client in the browser: ui/main.cpp and the app sources, with Qt +# Quick the only frontend, compiled against the in-memory models. Lightweight +# (ODBC/SQLite) cannot run in a browser, so include/bank/models/*.hpp here +# shadow the native model headers -- the same classes and registrations, +# persisting to bank::wasm::Db -- and come first on the include path, while +# bank/dto and bank/core resolve to the shared headers. bank_inmemory_tests +# (../CMakeLists.txt) runs the same set natively. Added in an Emscripten +# configure with MORPH_BUILD_QT_QUICK=ON. + +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2) +qt_standard_project_setup(REQUIRES 6.5) + +qt_add_executable(bank + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/main.cpp + ${BANK_APP_SOURCES} + ${BANK_INMEMORY_SOURCES} +) +target_include_directories(bank BEFORE PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/../app + ${CMAKE_CURRENT_SOURCE_DIR}/../include) +target_link_libraries(bank PRIVATE morph::morph morph::ladder_app_common) +# Qt Quick, and MORPH_EXAMPLE_HAS_TUI=0 / MORPH_EXAMPLE_HAS_QT_QUICK=1: the +# terminal UI is never built under Emscripten. +morph_example_frontends(bank PRIVATE) +target_compile_features(bank PRIVATE cxx_std_23) +apply_bigobj(bank) +``` + +In `examples/bank/CMakeLists.txt`, place (a) — directly after the `set(BANK_APP_SOURCES …)` block: + +```cmake +# The browser build's persistence: the in-memory models behind the shadow +# headers in wasm/include, and the money formatting they share with bank_lib. +set(BANK_INMEMORY_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/wasm/local_setup_wasm.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/wasm/src/models/auth_model_wasm.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/wasm/src/models/account_model_wasm.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/wasm/src/models/customer_model_wasm.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/wasm/src/models/transaction_model_wasm.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/wasm/src/models/card_model_wasm.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/wasm/src/models/payee_model_wasm.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/wasm/src/models/payment_model_wasm.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/wasm/src/models/loan_model_wasm.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/core/money.cpp +) +``` + +and (b) — replace the whole `if(EMSCRIPTEN) … endif()` block (its comment included) with: + +```cmake +# ── WebAssembly build ──────────────────────────────────────────────────────── +# Lightweight (ODBC/SQLite) cannot exist in a browser, so an Emscripten +# configure skips the native stack -- Lightweight, bank_lib, bank_app, the CLI, +# the server and the tests -- and builds only the browser client (wasm/), which +# compiles the same app sources against the in-memory models. +if(EMSCRIPTEN) + if(MORPH_BUILD_QT_QUICK) + add_subdirectory(wasm) + else() + message(STATUS "examples/bank: the browser client needs MORPH_BUILD_QT_QUICK=ON; not built") + endif() + return() +endif() +``` + +In `.github/workflows/wasm-demo.yml`: + +1. Replace the header comment's first two lines with + `# Builds the bank client for the browser -- examples/bank/ui/main.cpp over the in-memory models in` + `# examples/bank/wasm -- and publishes it to GitHub Pages under /demo/, alongside the Doxygen docs at the` + (the remaining two lines stay). +2. Under both `push.paths` and `pull_request.paths`, after `'examples/bank/**'`, add `'examples/common/app/**'`, + `'src/qt/**'` and `'src/qt_quick/**'`. +3. In both `jurplel/install-qt-action` steps add `modules: qtwebsockets` (MORPH_BUILD_QT needs it; the frontend + builds on `morph::qt`'s `QtExecutor`). +4. In Configure, replace `-DMORPH_BUILD_BANK_GUI=ON -DMORPH_BUILD_TESTS=OFF` with + `-DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_TESTS=OFF`. +5. In Build, replace `--target bank_gui_wasm` with `--target bank`. +6. Replace the Stage bundle step's `run:` body with: + +```bash + mkdir -p site + BIN=build-wasm/examples/bank/wasm + cp "$BIN"/bank.js "$BIN"/bank.wasm "$BIN"/qtloader.js site/ + # GitHub rejects any single file over 100 MB on push; fail here, in the + # build log, rather than in the deploy step's git-push error. + ls -la site/ + WASM_BYTES=$(stat -c%s site/bank.wasm) + echo "bank.wasm: $WASM_BYTES bytes" + if [ "$WASM_BYTES" -gt 104857600 ]; then + echo "::error::bank.wasm is $WASM_BYTES bytes, over GitHub's 100 MB push limit" + exit 1 + fi + # Serve the Qt loader page as the directory index. + cp "$BIN"/bank.html site/index.html +``` + +- [ ] **Step 4: Run the test to verify it passes, and build the browser client where a toolchain exists** + +Run: `cmake -S . -B build/bank && cmake --build build/bank --target bank_inmemory_tests && ctest --test-dir build/bank +-L bank -R 'in-memory' --output-on-failure` +Expected: PASS — 1 test. + +With Qt for WebAssembly and its emsdk installed (otherwise `wasm-demo.yml` builds it on the pull request, Task 12): + +```bash +source /path/to/emsdk/emsdk_env.sh && export EM_CACHE="$PWD/.emcache" +/path/to/qt6-wasm/bin/qt-cmake -S . -B build-wasm -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel \ + -DQT_HOST_PATH=/path/to/qt6-host -DMORPH_BUILD_EXAMPLES=ON -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_TESTS=OFF +cmake --build build-wasm --target bank +python3 -m http.server -d build-wasm/examples/bank/wasm 8000 # open bank.html: signed in as Demo User +``` + +Mutation check: in `examples/bank/wasm/src/models/transaction_model_wasm.cpp`, make `execute(dto::Deposit const&)` +throw `bank::ValidationError{"broken"}`. Expected FAIL in "the in-memory models serve the seeded demo and a +deposit" (the seed's opening deposit fails; it never signs in). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/bank/wasm examples/bank/tests/inmemory examples/bank/CMakeLists.txt .github/workflows/wasm-demo.yml +git commit -m "wip(bank): the browser build is the same main over the in-memory models + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 11: Remove the QML client and `MORPH_BUILD_BANK_GUI`; the docs and CI that named them + +Every behaviour the QML client's tests pinned now has a controller or view test (Tasks 1–8), so `gui/`, its two +test binaries and the option go, together with every sentence and flag that names them. + +**Files:** +- Delete: `examples/bank/gui/` (13 QML files, 7 controllers, `Format.hpp`, `BankClient.*`, `Theme.hpp`, `main.cpp`, + `CMakeLists.txt`), `examples/bank/tests/gui/` (`test_bank_gui_format.cpp` — ported in Task 1; + `test_bank_gui_qml_behaviour.cpp` — ported in Tasks 4 and 6; `test_bank_qml_surface.cpp` — dropped with the QML + it audited) +- Modify: `examples/bank/CMakeLists.txt` — the `# ── Qt 6 GUI (opt-in)` block, the `bank_gui_tests` / + `bank_gui_qml_tests` block, three comments +- Modify: `CMakeLists.txt` — line 28 (the option) and line 747 (a comment) +- Modify: `CMakePresets.json` — `windows-everything` and `linux-everything` +- Modify: `.github/workflows/ci.yml` — the `bank-sanitizers` job, `linux-all-features`' Configure, the `clang-tidy` + job's Configure; `.github/workflows/nightly-slow-checks.yml` — line 256 +- Modify: `scripts/check_coverage_objects.sh` — line 109 +- Modify: `examples/bank/README.md`, `examples/TESTING.md`, `examples/LADDER.md`, `README.md` +- Test: the grep below, the configure warning, and `ctest -L bank` + +**Interfaces:** +- Consumes: everything Tasks 1–10 produced (the replacement targets the docs and CI now name). +- Produces: none — after this task no file outside `CHANGELOG.md`, `docs/superpowers/` and `docs/analysis/` names + the removed client. + +- [ ] **Step 1: Write the failing check** + +```bash +git grep -nE 'MORPH_BUILD_BANK_GUI|bank_gui_(tests|qml_tests|lib|wasm)|examples/bank/gui(/|_wasm|\b)|bank/gui_wasm|bank.s .gui_wasm|tests/gui/' -- \ + CMakeLists.txt CMakePresets.json .github scripts examples/bank examples/TESTING.md examples/LADDER.md README.md +``` + +The check passes when it prints nothing. (`ci.yml`'s quoted clang-tidy report naming +`test_bank_gui_qml_behaviour.cpp` is a dated record of one CI run and does not match.) + +- [ ] **Step 2: Run it to verify it fails** + +Run the command above. +Expected: FAIL — matches in each listed file, among them `CMakeLists.txt:28:option(MORPH_BUILD_BANK_GUI …`, +`CMakePresets.json:105`, `.github/workflows/ci.yml:1284`, `scripts/check_coverage_objects.sh:109` and +`examples/bank/CMakeLists.txt:185`. + +- [ ] **Step 3: Remove and rewrite** + +Delete the client and its suites: + +```bash +git rm -r examples/bank/gui examples/bank/tests/gui +``` + +**`examples/bank/CMakeLists.txt`.** Delete the four-line block +`# ── Qt 6 GUI (opt-in) ──…` / `if(MORPH_BUILD_BANK_GUI)` / `add_subdirectory(gui)` / `endif()`. Delete the block from +` # ── bank_gui_tests: the GUI's own suite ──…` through the `endif()` that closes `if(TARGET bank_gui_lib)` +(the one directly after `catch_discover_tests(bank_gui_qml_tests …)`). In the `ladder_bank_server` comment replace + +```cmake +# Gated on Qt because the transport is morph::qt's QtWebSocketServer, exactly +# as the macro's version is; MORPH_BUILD_BANK_GUI is *not* the gate, since this +# is a headless server that needs Qt6::Core and no GUI. +# +# The gate is the *option*, not `TARGET morph::qt`: the root CMakeLists adds +# examples/bank (line ~523) well before it creates morph::qt (line ~596), so a +# TARGET check here is false even when Qt is on. Naming the target in +# target_link_libraries is fine regardless -- CMake resolves link names at +# generate time, by which point the alias exists. bank/gui/CMakeLists.txt +# depends on the same ordering. +``` + +with + +```cmake +# Gated on Qt because the transport is morph::qt's QtWebSocketServer, exactly +# as the macro's version is; it is a headless server that needs Qt6::Core and +# no frontend. +# +# The gate is the *option*, not `TARGET morph::qt`: the root CMakeLists adds +# examples/bank well before it creates morph::qt, so a TARGET check here is +# false even when Qt is on. Naming the target in target_link_libraries is fine +# regardless -- CMake resolves link names at generate time, by which point the +# alias exists. The bank binary's morph::qt_quick link relies on the same. +``` + +and, inside the block, replace + +```cmake + # one -- the same call bank_gui_qml_tests makes further down for the same + # reason. WebSockets comes along because morph::qt's INTERFACE link +``` + +with + +```cmake + # one. WebSockets comes along because morph::qt's INTERFACE link +``` + +In the tests block replace `# LABELS "bank" on all three bank suites.` with `# LABELS "bank" on every bank suite.` +and `# No RESOURCE_LOCK on any of the three calls, and that is a decision` with +`# No RESOURCE_LOCK on any of these calls, and that is a decision`. + +**`CMakeLists.txt`.** Delete line 28, +`option(MORPH_BUILD_BANK_GUI "Build the Qt 6 GUI for the bank example" OFF)`. On line 747 replace +`# single-threaded WASM build (which only wants the bank GUI target).` with +`# single-threaded WASM build (which only wants the bank's browser client).` + +**`CMakePresets.json`.** Delete `"MORPH_BUILD_BANK_GUI": "ON",` from `windows-everything` and `linux-everything`. +Then `grep -n 'MORPH_BUILD_TUI\|MORPH_BUILD_QT_QUICK' CMakePresets.json`: Parts 3 and 4 put both in each +`*-everything` preset; add `"MORPH_BUILD_TUI": "ON",` / `"MORPH_BUILD_QT_QUICK": "ON",` after +`"MORPH_BUILD_BANK_EXAMPLE": "ON",` in any of the two that lacks one. + +**`scripts/check_coverage_objects.sh`.** Replace `bank_tests|bank_gui_tests|bank_gui_qml_tests)` with +`bank_tests|bank_app_tests|bank_inmemory_tests|bank_smoke_tests)`. + +**`.github/workflows/ci.yml`, `bank-sanitizers`.** First run `grep -n 'MORPH_BUILD_TUI' .github/workflows/ci.yml`: +if the job Part 3 enabled the terminal frontend in has a step for its dependencies (libunicode's UCD download +cache), copy that step into `bank-sanitizers` before its Configure step. Then: + +1. Replace the header-comment paragraph from ` # What that covers, stated exactly, because the obvious claim is too + strong.` + through ` # bounded by how much of bank they drive.` with: + + ```yaml + # What that covers, stated exactly, because the obvious claim is too strong. + # An out-of-range double -> std::int64_t conversion in + # examples/bank/app/format.hpp's parseMinor -- on the path of every amount + # typed into the bank client -- exits bank_app_tests 1 here only because a + # test calls parseMinor with such a value: a sanitizer leg reaches only + # what the suites drive, and this one's reach is bounded by how much of + # bank they drive. + ``` + +2. In the next paragraph replace `Two measured reasons.` with `Two reasons.`, replace `(1) That leg builds no Qt, and + its own comment reserves the matrix against GUI + stacks; bank's GUI is where this class of UB lives, so covering it means adding Qt there.` with `(1) That leg + builds no Qt, and its own comment reserves the matrix against GUI stacks; this job builds Qt Quick for the bank + binary and its offscreen smoke test.`, and replace its measured `(2)` sentences (from `(2) Cold, cacheless` to + `the matrix's.`) with `(2) Folding it in would lengthen the slowest leg of a three-leg matrix, whose duration is + then the matrix's.` +3. Replace the paragraph from ` # The bill was measured: turning bank on` through + ` # suppressed here and there is no allowlist entry.` with + ` # Nothing is suppressed here and there is no allowlist entry.` +4. Replace the Configure step's comment and name and the `-DMORPH_BUILD_BANK_GUI=ON \` line: + + ```yaml + # MORPH_BUILD_TUI and MORPH_BUILD_QT_QUICK: bank_app -- the controllers + # and format.hpp, the numeric-conversion surface this job exists for -- + # builds without them, but the bank binary and bank_smoke_tests exist + # only when a frontend does, and the smoke tests run the application + # end to end on each. No MORPH_BUILD_LADDER: bank is not a rung, so it + # would add every rung's tree for nothing. + - name: Configure (clang-ubsan, bank example + both frontends) + run: | + cmake --preset clang-ubsan \ + -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_TUI=ON \ + -DMORPH_BUILD_QT_QUICK=ON \ + ``` + + (the compiler and launcher lines after it stay). +5. In the Build step's comment replace `it is *not* bank's three suites, which` with `it is *not* bank's suites, + which`. +6. Replace the whole comment above ` - name: Every ctest binary is instrumented` (from + ` # The same assertion linux-sanitizers, ladder-sanitizers and kanban-tsan` through + ` # all nine there whether or not the variable is set.`) with: + + ```yaml + # The same assertion linux-sanitizers, ladder-sanitizers and kanban-tsan + # make. It is not a formality here: every bank target needs an + # apply_sanitizers() call of its own, and this sweep is what says so + # rather than letting the leg report a clean bank run over + # uninstrumented binaries. + # + # QT_QPA_PLATFORM=offscreen, because the sweep's first act is + # `ctest --show-only=json-v1`, which runs every PRE_TEST discovery in + # this step's environment: a Qt-linked suite that needs a platform + # plugin to list its tests aborts there without it, and Catch2's + # CatchAddTests.cmake then fails the whole listing, not that suite's + # share. The Test step below declares the same value, because the + # sweep's subject is the binaries that step runs. + ``` + +7. In the Test step's comment replace `against 28 tests and 6s for bank's own three suites.` with + `against seconds for bank's own suites.` + +**`.github/workflows/ci.yml`, `linux-all-features` Configure.** Replace the comment paragraph from +` # MORPH_BUILD_BANK_GUI (with MORPH_BUILD_BANK_EXAMPLE, which it needs` through +` # meta-gates.` with: + +```yaml + # MORPH_BUILD_BANK_EXAMPLE builds the bank client too: bank_app and + # its tests always, and the bank binary and its smoke tests because + # MORPH_BUILD_TUI and MORPH_BUILD_QT_QUICK are on here. This leg can + # host both: it installs Qt ${{ env.QT_VERSION }} from aqtinstall (Qt + # Quick needs 6.5+, which the distro Qt other bank jobs use cannot + # give) and the ODBC/SQLite/yaml-cpp/libzip set the bank example's + # Lightweight fetch requires. +``` + +and delete the `-DMORPH_BUILD_BANK_GUI=ON \` line; if the command has no `-DMORPH_BUILD_TUI=ON \` and +`-DMORPH_BUILD_QT_QUICK=ON \` yet (Parts 3–4 add them), put them in its place. + +**`.github/workflows/ci.yml`, `clang-tidy` Configure.** Replace the paragraph from +` # MORPH_BUILD_BANK_GUI is the second flag "every optional feature is ON` through +` # on a line that carries a finding. Clearing them is separate work from` … `deliberately not folded in here.` +with: + +```yaml + # MORPH_BUILD_TUI and MORPH_BUILD_QT_QUICK are what put bank's + # ui/main.cpp and tests/smoke/ in this job's database: without a + # frontend the bank binary and its smoke tests are not configured, and + # the filter below would drop their sources unanalysed. bank_app and its + # other tests are in the database either way. +``` + +and treat the `-DMORPH_BUILD_BANK_GUI=ON \` line as in `linux-all-features`. + +**`.github/workflows/nightly-slow-checks.yml`.** Treat line 256 the same way. + +**`examples/bank/README.md`.** + +1. Replace `It ships the models, a full test suite, a scripted CLI driver, and a **Qt 6` / + `desktop GUI** — which also builds to **WebAssembly** and runs entirely in the browser.` with + `It ships the models, a full test suite, a scripted CLI driver, and **one client** that runs in a` / + `terminal or a Qt Quick window — and builds to **WebAssembly**, running entirely in the browser.` +2. In the demo call-out replace `First load fetches a ~31 MB \`.wasm\`, so` with `First load fetches the \`.wasm\`, + so`. +3. Replace `is \`wasm-demo.yml\`, and only its WebAssembly GUI.` with `is \`wasm-demo.yml\`, and only its WebAssembly + client.` +4. Replace the paragraph `Conventions bank **shares** with the rungs: …` (through + `` `SimulatedRemoteBackend` in `test_remote.cpp` plus the scenario corpus below. ``) with: + + ```markdown + Conventions bank **shares** with the rungs: persistence exclusively through the + Lightweight ORM, with the schema owned by `LIGHTWEIGHT_SQL_MIGRATION` + definitions — [`LADDER.md`](../LADDER.md) calls that "bank's pattern" and binds + every rung to it — models as the application, plain aggregates on the wire, and + a toolkit-free client on an injected frontend. Conventions it does **not** + follow: no schema-driven forms (its client hand-writes one controller per + screen), and no [`TESTING.md`](../TESTING.md) dual-deployment-mode rig — its + remote coverage is `SimulatedRemoteBackend` in `test_remote.cpp` plus the + scenario corpus below. + ``` + +5. Replace the whole `### Qt 6 QML GUI` section (heading through the `BANK_GUI_SMOKE` paragraph) with: + + ````markdown + ### The client: one app, any frontend + + The client is **`bank_app`** (`app/`), a toolkit-free library, and **`bank`** (`ui/main.cpp`), the + one binary that runs it. + + - **Controllers** (`app/controllers/`), one per screen: `AuthController`, `AccountsController`, + `TransactionsController`, `CardsController`, `PayeesController`, `LoansController`. Each keeps what + the user picked and typed in a `morph::reactive::Store`, reads through `Query`s keyed on the session + (signing out clears every screen), writes through `Mutation`s that name the queries they invalidate, + and turns results into display-ready rows in `Computed`s; money is formatted in `app/format.hpp`, + never in a view. Failures, and the `Transaction posted` / `Bill paid` confirmations, go to one shared + notice strip (`app/notices.hpp`). + - **Views** (`app/views/`), one `morph::ui` tree per screen, bindings only. `BankApplication` + (`app/bank_application.hpp`) owns the controllers and shows the login screen or the shell: a menu, + the page heading and a switch over the current page. Opening a page fetches it again. + - **Toolkit-free by construction:** `bank_app` links `morph`, `bank_lib` and `examples/common`'s app + layer and nothing that brings Qt or a terminal; the configure fails if it links one, and + `app/toolkit_free.cpp` stops the build if a Qt module reaches its compile line. + - **`bank`** is built when `MORPH_BUILD_TUI` or `MORPH_BUILD_QT_QUICK` is on and links whichever are. + It picks one at run time: `--ui=tui` or `--ui=qt`, else `MORPH_UI`, else Qt Quick where a display is + available, else the terminal. + + ```sh + cmake -G Ninja -B build -S . -DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_TUI=ON \ + -DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_NET=ON + cmake --build build --target bank + ./build/examples/bank/bank # local; database in the temp directory + ./build/examples/bank/bank --ui=tui --db bank.db # the terminal, a database of your own + ./build/examples/bank/bank --seed # signed in as demo / demo1234 + ./build/examples/bank/bank --server ws://127.0.0.1:54321 # against ladder_bank_server (above) + ``` + + Remote mode uses `QtWebSocketBackend` on Qt Quick and `morph::net`'s socket backend on the terminal; + the latter needs `MORPH_BUILD_NET` and is POSIX-only, so a Windows terminal client runs locally only. + + **What looks different from the old QML client.** Behaviour is kept; the look is each frontend's own. + + - No custom components: panels, buttons and pickers are the frontend's, and status pills are coloured + text. `Active` and `Open` take the success tone; `Frozen` and `Cancelled` both take the error tone, + because the shared palette has no warning tone. + - Accounts are a vertical list rather than a flow of cards. The sidebar is a menu; the current page is + named by the heading rather than by a highlighted entry. + - A button whose input is incomplete or invalid is disabled, where the QML button stayed enabled and + answered with an error notice (`Enter a valid amount.`, `Pick an account.`, …). + - The transfer amount field is labelled `Transfer amount`; a loan's rate and term must be whole numbers. + - There is no screenshot mode (`BANK_GUI_SMOKE`); `bank_smoke_tests` runs the application on both + frontends instead. + + Kept, and tested (see [Tests](#tests)): the notice texts and their 3.2 seconds; the account the + move-money picker names is the account the next deposit lands in, across the refresh every deposit + causes; every screen fetches again when opened; a blank or unparseable overdraft or card limit means + none. + ```` + +6. Replace the `### WebAssembly demo (self-contained, GitHub Pages)` section's body after its first paragraph + (from `` `gui_wasm/` is a **single-threaded WebAssembly** build `` through the paragraph ending + `` gated `if(NOT EMSCRIPTEN)` in `CMakeLists.txt`. ``) with: + + ````markdown + It is the same `ui/main.cpp` and the same app sources, built single-threaded for WebAssembly with Qt + Quick as the only frontend, running **entirely in the browser** — the morph model layer is the "server + in the background", with no external process. Lightweight (ODBC/SQLite) can't run in a browser, so + `wasm/` swaps persistence for an **in-memory store** (`wasm/include/bank/wasm/`) behind **shadow model + headers** that come ahead of the native ones on the include path; the controllers, views and DTOs + compile unchanged. `bank_inmemory_tests` compiles the same set natively and runs the seeded demo and a + deposit through it. Single-threaded ⇒ no SharedArrayBuffer ⇒ **no COOP/COEP headers**, so plain GitHub + Pages hosts it. The page always starts with `--seed`'s demo user and accounts. + + Build locally (needs Qt-for-WASM + a matching emsdk): + + ``` + source /path/to/qt6-wasm/emsdk/emsdk_env.sh + export EM_CACHE="$PWD/.emcache" + /path/to/qt6-wasm/bin/qt-cmake -S . -B build-wasm -G Ninja \ + -DMORPH_BUILD_EXAMPLES=ON -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_TESTS=OFF + cmake --build build-wasm --target bank + python3 -m http.server -d build-wasm/examples/bank/wasm 8000 # open bank.html + ``` + + CI (`.github/workflows/wasm-demo.yml`) builds the bundle with a matched host+wasm Qt pair and publishes + it to `…github.io//demo/`, coexisting with the Doxygen docs at the site root. The native build is + unaffected — the native stack is gated `if(NOT EMSCRIPTEN)` in `CMakeLists.txt`. + ```` + + In the section's first paragraph replace `the first load fetches a ~31 MB \`.wasm\`` with + `the first load fetches the \`.wasm\``. +7. In `## Tests`, replace everything from `` `tests/gui/` is a second binary, `bank_gui_tests`, `` through the end + of the `bank_gui_qml_tests` code block with: + + ````markdown + The client has three more binaries, all labelled `bank` (`ctest -L bank`): + + - **`bank_app_tests`** (`tests/app/`) — the controllers, headless: each runs over a local bridge on + `examples/common`'s `FakeAppContext` (a `MainThreadExecutor` and a manual scheduler) and waits by + pumping, never by sleeping. It carries what the QML suites held — `parseMinor`'s cases, the move-money picker + sequence, the posted and paid notices — and the case the old controller could not pass: select + account A, then B, and A's late history reply must not replace B's history. Each screen also has a + view test on `ui::testing::RecordingBackend` that drives its controls and compares the tree dump + with `tests/app/golden/.txt`; after an intended change, regenerate with + `BANK_UPDATE_GOLDEN=1` and review the diff. + - **`bank_inmemory_tests`** (`tests/inmemory/`) — the app over the browser build's in-memory models, + natively. + - **`bank_smoke_tests`** (`tests/smoke/`) — the application mounts and quits on each frontend the + configure built: the terminal through scripted input, Qt Quick offscreen. Built only with a frontend, + as a binary of its own, because the Qt Quick frontend constructs its own `QGuiApplication`. + + ```sh + cmake --build build --target bank_app_tests bank_inmemory_tests bank_smoke_tests + ctest --test-dir build -L bank --output-on-failure + ``` + ```` + +8. Replace the `## Status` paragraph with: + + ```markdown + Models, tests, CLI, the client (terminal and Qt Quick, one binary, local or remote), and a + self-contained WebAssembly build hosted on GitHub Pages are complete. Possible extensions: durable + in-browser persistence (IDBFS/OPFS) for the WASM build, and switching its in-browser backend to + `RemoteServer` + `SimulatedRemoteBackend` to surface the JSON wire protocol. + ``` + +**`examples/TESTING.md`.** + +1. Replace `` QProcess client harness, the Qt-owning Catch2 `main()`), the pump helpers in `` / + `` `examples/bank/tests/bank_test_support.hpp`, and the presenter shape of `` / + `` `examples/bank/gui/controllers/`. `` with `` QProcess client harness, the Qt-owning Catch2 `main()`), and the `` + / + `` pump helpers in `examples/bank/tests/bank_test_support.hpp`. `` +2. Replace the bullet `- There are **zero GUI tests** in the repo today. Bank's controllers are …` (five lines, + through `` sleep-pumped screenshot smoke inside `gui/main.cpp`. ``) with + `` - Bank's client is a toolkit-free app (`examples/bank/app/`): its controllers are tested headless in `` / + `` `bank_app_tests` over a local bridge, and `bank --server` reaches `ladder_bank_server` over a socket. `` +3. Replace `` access (`bank`'s `AppShell.qml` calls `controllers[current].refresh()` `` / + `through a \`var\` array, so five reachable invokables sweep as unreferenced);` with + `access (an invokable reached only through a \`var\` array sweeps as unreferenced);` +4. Replace `` `ledger`, `lims` and `kanban` — and by `examples/bank`, which is not a rung. `` with + `` `ledger`, `lims` and `kanban`. `` +5. Delete the three paragraphs from `` `bank` is where the two shell shapes diverge, `` through + `no-platform-plugin property.` (the blank line after them stays). +6. Replace `` executor itself: the **WASM constraint-parity mode** (exactly bank's `` / `` `__EMSCRIPTEN__` wiring). `` + with `executor itself: the **WASM constraint-parity mode** (what` / `` `examples::connect` builds under Emscripten). + `` +7. Replace ``- Do **not** copy bank's `gui_wasm` shadow-header pattern`` with + ``- Do **not** copy bank's `wasm/` shadow-header pattern``. + +**`examples/LADDER.md`.** Replace `` no schema-driven forms, no `examples/common/gui` presenter, and no dual-mode `` / +`` [`TESTING.md`](TESTING.md) rig (it does use the shared testkit's QML surface `` / `audit).` with +`` no schema-driven forms and no dual-mode [`TESTING.md`](TESTING.md) rig; its `` / +`client is a toolkit-free app on an injected frontend, as every rung's is.` + +**`README.md`** (line 523–525). Replace `` DTO/entity layers, a Qt desktop GUI, and a self-contained single-threaded Qt +`` / +`**WASM** build.` with `DTO/entity layers, one client for the terminal and Qt Quick, and the same client` / +`built as a self-contained single-threaded **WASM** page.` + +- [ ] **Step 4: Run the check, the build and the suites** + +```bash +git grep -nE 'MORPH_BUILD_BANK_GUI|bank_gui_(tests|qml_tests|lib|wasm)|examples/bank/gui(/|_wasm|\b)|bank/gui_wasm|bank.s .gui_wasm|tests/gui/' -- \ + CMakeLists.txt CMakePresets.json .github scripts examples/bank examples/TESTING.md examples/LADDER.md README.md +cmake -S . -B build/bank -DMORPH_BUILD_BANK_GUI=ON 2>&1 | grep -A2 'not used by the project' +cmake --build build/bank && ctest --test-dir build/bank -L bank --output-on-failure +pre-commit run markdownlint --files examples/bank/README.md examples/TESTING.md examples/LADDER.md README.md +``` + +Expected: the grep prints nothing; the configure reports `MORPH_BUILD_BANK_GUI` among the manually-specified +variables not used by the project (the option is gone); every `bank`-labelled test passes (`bank_tests`, +`bank_app_tests` 52, `bank_inmemory_tests` 1, `bank_smoke_tests` 2); markdownlint is clean. + +Mutation check: put `-DMORPH_BUILD_BANK_GUI=ON \` back into `nightly-slow-checks.yml`. Expected: the grep prints +that line. Remove it again. + +- [ ] **Step 5: Commit** + +```bash +git add -A examples/bank CMakeLists.txt CMakePresets.json .github scripts examples/TESTING.md examples/LADDER.md README.md +git commit -m "wip(bank): remove the QML client and MORPH_BUILD_BANK_GUI + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 12: Whole-part verification + +**Files:** none new; a fix lands in the file it concerns. + +- [ ] **Step 1: The full build and every bank suite** + +```bash +cmake --build build/bank && ctest --test-dir build/bank --output-on-failure +ctest --test-dir build/bank -L bank --output-on-failure +``` + +Expected: everything passes — the whole configure's suites, not only bank's; `-L bank` lists `bank_tests`' +cases, 52 `bank_app_tests` cases, 1 `bank_inmemory_tests` case and 2 `bank_smoke_tests` cases. + +- [ ] **Step 2: The app builds and tests without any frontend, and the binary needs one** + +```bash +cmake -S . -B build/bank-nofe -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_BANK_EXAMPLE=ON +cmake --build build/bank-nofe --target bank_app_tests bank_inmemory_tests +ctest --test-dir build/bank-nofe -L bank --output-on-failure +cmake --build build/bank-nofe --target bank +``` + +Expected: the two suites build and pass with `MORPH_BUILD_TUI` and `MORPH_BUILD_QT_QUICK` off (spec 4 §6: +controller and view tests need neither frontend); the last command fails with `unknown target 'bank'`. + +- [ ] **Step 3: The ladder configure, where examples/ adds the app layer** + +```bash +cmake -S . -B build/all -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all \ + -DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_NET=ON -DMORPH_BUILD_FORMS_QML=ON +cmake --build build/all && ctest --test-dir build/all -L bank --output-on-failure +``` + +Expected: configures (the root adds `examples/common/app` once, for the ladder and bank alike), builds, and every +bank suite passes. + +- [ ] **Step 4: Sanitizers, as CI's `bank-sanitizers` job runs them** (Linux) + +```bash +cmake --preset clang-ubsan -DMORPH_BUILD_QT=ON -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT_QUICK=ON +QT_QPA_PLATFORM=offscreen cmake --build --preset clang-ubsan +QT_QPA_PLATFORM=offscreen bash scripts/check_sanitizer_instrumentation.sh build/clang-ubsan ubsan +QT_QPA_PLATFORM=offscreen UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \ + ctest --preset clang-ubsan -L bank --output-on-failure +cmake --preset clang-asan -DMORPH_BUILD_BANK_EXAMPLE=ON +cmake --build --preset clang-asan --target bank_app_tests bank_inmemory_tests +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-asan/examples/bank/bank_app_tests asan +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-asan/examples/bank/bank_inmemory_tests asan +./build/clang-asan/examples/bank/bank_app_tests && ./build/clang-asan/examples/bank/bank_inmemory_tests +``` + +Expected: the sweep reports every ctest binary carrying `__ubsan_` symbols; no UBSan or ASan finding. The ASan +run is what observes the gates this part relies on: a reply arriving after a controller is gone (`CallbackScope` +last), a hide timer after `Notices` is gone (`TimerHandle` last), a seed reply after the application is gone. On +macOS use an ASan configure of the same tree in place of the presets. + +- [ ] **Step 5: clang-tidy over the changed lines** — CONTRIBUTING's recipe, "Running the `clang-tidy-diff` gate + locally", on a configure with `-DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON + -DMORPH_BUILD_QT_QUICK=ON` added, over this part's paths: + +```bash +git fetch origin master +git diff -U0 origin/master...HEAD -- examples/bank > /tmp/bank.diff +files=$(grep -c '^+++ ' /tmp/bank.diff) +test "$files" -gt 0 || { echo "no changed files -- wrong diff base?"; exit 1; } +echo "clang-tidy-diff over $files changed file(s)" +python3 "$(find /usr/lib/llvm-*/share/clang /usr/share/clang -name clang-tidy-diff.py | head -1)" \ + -p1 -path build/clang-debug -j "$(nproc)" -quiet \ + -extra-arg=-std=c++23 -extra-arg=-Wno-missing-include-dirs < /tmp/bank.diff +``` + +Expected: the file count is printed and non-zero (about 60 files), and no finding. + +- [ ] **Step 6: What this part does not need** — no header under `include/morph` changed and no CMake component + was added, so the Docs build and `scripts/check_install_export.sh` have nothing new to check; the WebAssembly + build is `wasm-demo.yml`'s job on the pull request when no local Qt-for-WASM toolchain ran Task 10 Step 4. Say + which in the hand-off. + +- [ ] **Step 7: Commit any fixes** + +```bash +git add -A examples/bank .github CMakeLists.txt CMakePresets.json scripts +git commit -m "wip(bank): fixes from the verification gates + +Signed-off-by: Christian Parpart " +``` + +Skip the commit if there was nothing to fix, and say so in the hand-off. + +--- + +### Task 13: Squash this part into its one commit + +- [ ] **Step 1: Write the message** + +Write `/tmp/bank-message.txt`: + +```text +examples/bank: one app, any frontend + +The bank client is bank_app, a toolkit-free library: six controllers +(Auth, Accounts, Transactions, Cards, Payees, Loans) built from Store, +Query and Mutation, a morph::ui view per screen, and BankApplication. +One binary, bank, runs it on the terminal or Qt Quick, locally or +against ladder_bank_server; the browser build is the same main over the +in-memory models. The move-money history is a Query keyed on the +selection, so a late reply for a previously selected account no longer +replaces the one shown. The QML client and MORPH_BUILD_BANK_GUI are gone. + +Signed-off-by: Christian Parpart +``` + +Add one paragraph before the trailer for the deviation this plan makes on purpose: Part 6 expects bank's binary to +use `morph_add_example_ui`; it uses `morph_example_frontends` directly, because that helper calls `apply_warnings()` +and bank's targets cannot — the model headers include the Lightweight ORM's, which are not `-Werror` clean. If +Task 9's manual runs or Task 12 found something else the plan did not say, add one paragraph per item as well: what +the plan said, what was done instead, and why (master plan, "The docs commit"). + +- [ ] **Step 2: Squash** + +Follow the master plan's "Squashing a part" procedure with `key=bank` and `git commit -F /tmp/bank-message.txt`. + +Expected: `git diff --exit-code "$pre" HEAD` prints `squash preserved the tree`, and `git log --oneline master..HEAD` +ends with `examples/bank: one app, any frontend` directly after +`examples/common: Qt-free app environment, transport, poller and test waits; gallery and workout`. diff --git a/docs/superpowers/plans/2026-10-04-declarative-ui-tui-8-examples-ladder-a.md b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-8-examples-ladder-a.md new file mode 100644 index 000000000..974d74c55 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-8-examples-ladder-a.md @@ -0,0 +1,9438 @@ +# Declarative UI, Part 8 — the ladder rungs pastebin, bookmarks and polls Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Re-express pastebin, bookmarks and polls as toolkit-free app libraries (controllers, views, an +`ui::Application`) plus one `ui/main.cpp` binary each that picks the TUI or Qt Quick at runtime, and delete their +QML, `gui_lib`, `gui`, `gui_wasm` and the tests that only covered those. + +**Architecture:** Each rung gains `app/` (target `ladder__app`: `controllers/`, `views/`, +`_application.{hpp,cpp}`) and `ui/main.cpp` (target ``), on the `morph_add_rung` conventions Part 6 +adds. Controllers own `BridgeHandler`s, `Query`/`Mutation` nodes, forms-engine sessions and `Computed` display +projections; views are `ui::Node` bindings over them; the application owns the transport (`examples::connect`) +and the controllers. The domain library loses Qt: pastebin's and bookmarks' `App` QObjects move to a +server-only `ladder__server_app` library this plan adds to `morph_add_rung`. + +**Tech Stack:** C++23; `morph::reactive` (Part 1), `morph::ui` (Part 2), `morph::tui` (Part 3), +`morph::qt_quick` (Part 4), the forms engine (Part 5: `Form`, `FormSession`, `formView`, +`handlerChoiceFetcher`), `examples/common/app` (Part 6: `connect`, `Connection`, `Wiring`, `mapCompletion`, +`Poller`) and its testkit (`frontend_smoke.hpp`); Catch2 v3; the ladder testkit's `BackendRig`, `DbFixture` and +`pumpUntil`. + +**Spec:** `docs/superpowers/specs/2026-10-04-examples-migration-design.md` (spec 4) §1–§3, §5 (pastebin, +bookmarks, polls), §6, §7, §8, §9. + +This is **Part 8 of 11** of the declarative-UI program. Read the master plan +`docs/superpowers/plans/2026-10-04-declarative-ui-tui.md` first: it fixes the branch, the commit layout and the +`wip(...)` commit convention every task below follows. This part has **three groups** with keys `pastebin` +(Tasks 1–8), `bookmarks` (Tasks 9–17) and `polls` (Tasks 18–25); each group ends by squashing its own `wip` +commits into one commit, in that order. + +## Global Constraints + +- Part 1's constraints apply unchanged (SPDX first line, `#pragma once`, naming, comments without history, + `-Weverything -Werror`, clang-tidy clean, `Signed-off-by` trailer). +- Examples only: **no `CHANGELOG.md` entry, no `docs/spec/` file** (CONTRIBUTING, "The changelog"). Each rung's + README carries the record; `docs/GETTING-STARTED.md`'s pastebin walkthrough is kept true by the pastebin group. +- `app/` is toolkit-free: no ``, `morph/qt*`, `morph/tui`, `morph/qt_quick` include anywhere under `app/`, + and `ladder__lib` links no Qt once the rung has `app/`. Each group's verification task proves both from + the build's own command lines, not from reading CMake. +- Controllers include nothing from `morph::ui`; views contain bindings only — every format and every + `std::string` a view shows is produced by a controller (`Computed` or a plain accessor). +- Identifiers obey `readability-identifier-length` (≥ 3 characters for parameters and variables, lambda + parameters and test locals included; the only short names allowed are parameters `i j k x y n N fn cb op` and + variables `i j k x y lk cb op fn`): never `id`, `c`, `rt`, `ui` as a parameter or variable — `pasteId`, + `controller`, `runtime`. Struct and class data members are exempt, but this plan's fixtures name theirs + `runtime` too. The namespace alias `namespace ui = ::morph::ui;` is allowed (it is not a variable). +- Headers under `app/` use no unchecked `operator[]` on a container + (`cppcoreguidelines-pro-bounds-avoid-unchecked-container-access`): `.at()` or iteration. +- Each rung exposes `[[nodiscard]] std::unique_ptr ::client::makeApplication(ui::AppContext& + ctx, examples::AppEnvironment const& env)` (the interface contract's "Conventions for every example + application"); its local database setup and any other knob are built inside from `env`, so every `ui/main.cpp` + has the shape of spec 4 §3. +- Every existing **model test file stays byte-identical** (each group's verification diffs them against + `master`). Only `test_*_presenter.cpp`, `test_*_qml_bridges.cpp` and `test_gui_qml_smoke.cpp` are deleted. +- Controller and view tests run over the ladder's `BackendRig` rather than Part 6's `FakeAppContext`: the three + backend modes it offers (Local, LocalSingleThread, Socket) are what the deleted presenter suites covered. Its + client executors are Qt-driven (`QtExecutor`, `QtDrivenMainThreadExecutor`), so the wait is + `testkit/pump.hpp`'s `pumpUntil` (Part 6 keeps that header for exactly this case). Test code never names a Qt + type; the reactive `Runtime` is built on `*rig.executor()`. +- Frontend smoke tests live in `tests/smoke/` (their own binary, C3); every other client test in `tests/`. +- Client namespaces are `pastebin::client`, `bookmarks::client`, `polls::client` (`pastebin::app` and + `bookmarks::app` stay the servers' `App`). + +## What this plan consumes from Parts 2, 5 and 6 + +The interface contract fixes names; these are the further facts this plan relies on, as Parts 2, 5 and 6 define +them. The **first step of Task 1** confirms each against the tree; one that does not hold means an upstream part +did not land as its plan says — stop and report it rather than adapting here. + +- **C1 (Part 6 Task 9, `cmake/morph_add_rung.cmake`):** `app/**/*.cpp` → STATIC `ladder__app` (alias + `morph::ladder__app`), PUBLIC include dirs `${_dir}/app` and `${_dir}/include`, linking `morph::morph`, + `morph::ladder_app_common` and, natively, `morph::ladder__lib` (Lightweight's includes demoted to SYSTEM); + `apply_warnings` on it. It is built under Emscripten too, without the rung library. +- **C2 (Part 6 Task 9):** `ui/*.cpp` → executable `` (e.g. `pastebin`) through `morph_add_example_ui`, + built when `MORPH_BUILD_TUI` (natively) or `MORPH_BUILD_QT_QUICK` is on, linking `morph::ladder__app`, the + rung library whole-archive and every built frontend (`morph_example_frontends`), with `MORPH_EXAMPLE_HAS_TUI` + and `MORPH_EXAMPLE_HAS_QT_QUICK` **always defined, to `0` or `1`** (so `#if` is `-Wundef`-clean); under + Emscripten it builds with Qt Quick only and needs `MORPH_CLIENT_ONLY`. +- **C3 (Part 6 Task 9):** `ladder__tests` links `morph::ladder__app` and `morph::example_testkit` and + no longer compiles `tests/smoke/`. `tests/smoke/*.cpp` is its own binary, `ladder__smoke_tests`, whose + `main` is `morph_test_main` (it owns no Qt application object; the Qt Quick frontend constructs its own), + linking the app, `morph::example_testkit` (which carries both `MORPH_EXAMPLE_HAS_*` definitions PUBLIC) and the + whole rung library; its ctest names are prefixed `.smoke.` and labelled `ladder-`. +- **C4 (Part 6 Tasks 2–8, contract):** `examples/common/app/app_environment.hpp` (`AppEnvironment{server, db, + user, seed, pollId}`, `fromArgs`), `transport.hpp` (`connect(ui::AppContext&, AppEnvironment const&, + LocalSetup)`, `Connection` with `bridge()`, `callbacks()`, `ready()`, `link()`, `LocalSetup{setupDatabase, + workers}`, `TransportError`), `uuid.hpp` (`newUuid`), `poller.hpp`, `wiring.hpp` (`Wiring{runtime, scheduler, + bridge, callbacks}`, all borrowed), `completion_map.hpp` (`mapCompletion(owner, token, from, onValue, + onError)`), and `examples/common/testkit/frontend_smoke.hpp` (`runFrontendSmoke`, `SmokeFrontend`, which skips a + frontend the build lacks), all reachable with `examples/common` as an include root (`#include + "app/transport.hpp"`). `connect()` with no `--server` calls `local.setupDatabase(env.db)` when it is set, then + builds a `LocalBackend`. `Connection::ready()` is a tracked `reactive::Signal const&`: always true for a + local link, the transport's state for a remote one. `AppEnvironment::fromArgs` also reads the page url's + `?server=` and `?poll=` under WebAssembly (`detail::applyQuery`). +- **C5 (Part 6 Task 7, `poller.hpp`):** `examples::Poller(reactive::Runtime&, reactive::Scheduler&, + Fetch, Cursor start, OnEvent, PollerOptions = {})` with `Page` = `PollPage{events, next}`, + `Fetch = std::function(Cursor const&)>`, `OnEvent = std::function`, + `stoppedBy()` and `PollerOptions{interval}`. A poller starts from a fixed cursor, so the vote controller holds + one per open poll; every use sits in `VoteController::{startFeed, stopFeed, eventsSince}` and its + `_onFeedStopped` effect (Task 21). +- **C6 (Part 2 Task 2):** `RecordingBackend` names a widget kind by its widget interface without `Widget` + (`"Button"`, `"Text"`, `"Table"`, `"TextInput"`, `"Select"`, `"Panel"`, `"Checkbox"`), except that a stack is + `"Column"` or `"Row"` by its axis (a table row is a `"Row"`), and a prop by its setter without `set`, first + letter lower-cased (`label`, `text`, `enabled`, `visible`, `title`, `placeholder`, `selected`), booleans as + `true`/`false`. The mount sets `visible`/`enabled` only when bound or `false`, so a bound `enabled` reads + `"true"` or `"false"` and an unbound one reads empty. A hidden or disabled widget ignores the interaction + helpers. This plan's view tests use `find`, `all`, `prop`, `exists` and the helpers; none compares a `dump()` + or `log()`. +- **C7 (Part 5 Tasks 6, 10b, 11, 12):** `FormSession(Runtime&, FormModel, Submitter, ChoiceFetcher, + FormSessionOptions = {})`; its `pending()`, `lastReply()` and `lastError()` are a `Mutation`'s in-flight count, + last result and error, so they settle in one batch per reply, `lastError()` is cleared by the next success, and + `lastReply()` is equality-gated (two identical replies are one change — which is why Task 2's `FormSuccess` + exists: the session has no per-submission success signal). `submit()` sets `pending()` before it returns and + issues nothing while `body()` is `nullopt`; a required text field with an empty draft does not encode; + `prefill(json)` replaces every draft and never submits; `reset()` blanks every draft; `body()` is tracked. + `Form(Runtime&, BridgeHandler&, ChoiceFetcher, FormSessionOptions = {})` submits through its + session and executes on **that** handler (so a typed form on an `AllowShared` handler reaches the attached + instance); its action's `validate()` joins readiness through `FormSessionOptions::accepts`, so a body + `validate()` refuses is never sent — `form.ready()` and `form.session().ready()` are both false. A runtime form + over `bridgeSubmitter` has no such gate: `validate()` runs in the handler and the refusal is `lastError()`. + `FormModel::forAction()` reads `A`'s own schema (throwing `std::logic_error` when it cannot). + `handlerChoiceFetcher(callbacks, handlers...)` fetches Choice options through the given handlers; + `bridgeChoiceFetcher(bridge, callbacks)` through handlers of its own. `formView(FormSession&, + FormViewOptions{overrides, gridColumns, submitLabel, flatGridColumns})` draws the explicit-mode Submit button + labelled `submitLabel`, its `enabled` bound to `ready()`. + +--- + +## Review Focus + +1. **Opening a paste consumes a read, so nothing but an explicit activation may issue `GetPaste`** — not a list + refresh, not a re-render, not moving the table's selection (Task 5 test "pasteScreen: moving the selection + opens nothing, activating a row opens it once"). +2. **A controller destroyed while its form submission is in flight** must drop the reply instead of writing + into freed storage (Task 4 test "a controller destroyed with a create in flight drops its reply"; ASan is + the observer). +3. **A Login reply never carries the bearer token into anything a view can show**, and the session is installed + before the reply becomes visible (Task 10 test "A Login reply is redacted, and the session is installed + before it shows"). +4. **The option-draft list at its bounds** (2 and 20) refuses removal and addition, and removing a middle draft + keeps every other draft's key, so their rows keep their widgets (Task 18 test "option drafts: the bounds + hold and a removal keeps the other drafts' keys"). +5. **A late `OpenPoll` reply for a poll the user already left** must not attach it (Task 19 test "a reply for a + poll the user has left never attaches it"). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `cmake/morph_add_rung.cmake` | Qt-free `ladder__lib` once a rung has `app/`; `src/server/app/*.cpp` + `src/server/include/` → `ladder__server_app` (Qt), linked by the server and the tests | +| `examples/common/app/form_success.hpp` | `examples::FormSuccess` — runs a callback each time a form submission settles without error | +| `examples/common/app/tests/test_form_success.cpp` | its tests, in Part 6's `examples_common_app_tests` | +| `examples/pastebin/src/server/{app,include/pastebin/app}/` | `pastebin::app::App`, moved from `src/app/` and `include/` | +| `examples/pastebin/app/controllers/paste_controller.{hpp,cpp}` | list `Query`, open/delete `Mutation`s, the `Form`, display projections | +| `examples/pastebin/app/views/paste_view.{hpp,cpp}` | `pasteScreen()` | +| `examples/pastebin/app/pastebin_application.{hpp,cpp}` | `PastebinApplication`, `makeApplication()` and the local database setup | +| `examples/pastebin/ui/main.cpp` | composition root, native and WebAssembly | +| `examples/pastebin/tests/{client_fixture.hpp,test_paste_controller.cpp,test_paste_view.cpp}`, `tests/smoke/test_pastebin_frontends.cpp` | controller, view and smoke tests | +| `examples/bookmarks/src/server/{app,include/bookmarks/app}/` | `bookmarks::app::App` and `metadata_fetcher.hpp`, moved | +| `examples/bookmarks/app/controllers/{bookmark_text,forms_routing,session_controller,shared_feed_controller,tag_controller,bookmark_list_controller,bookmark_detail_controller}.{hpp,cpp}` | display text, the six forms' routing, and one controller per screen | +| `examples/bookmarks/app/views/bookmark_views.{hpp,cpp}` | login, list, detail, tags, feed panes and the library screen | +| `examples/bookmarks/app/bookmarks_application.{hpp,cpp}`, `ui/main.cpp` | application and composition root | +| `examples/bookmarks/tests/{client_fixture.hpp,test_bookmark_text.cpp,test_forms_routing.cpp,test_session_controller.cpp,test_tag_and_feed_controllers.cpp,test_bookmark_list_controller.cpp,test_bookmark_detail_controller.cpp,test_bookmark_views.cpp}`, `tests/smoke/test_bookmarks_frontends.cpp` | tests | +| `examples/polls/app/controllers/{poll_text,landing_controller,create_poll_controller,vote_controller,polls_navigator}.{hpp,cpp}` | display text, one controller per screen, navigation | +| `examples/polls/app/views/poll_views.{hpp,cpp}` | landing, create-poll and vote screens | +| `examples/polls/app/polls_application.{hpp,cpp}`, `ui/main.cpp` | application (create offered natively, hidden in the browser) and composition root | +| `examples/polls/tests/{client_fixture.hpp,test_create_poll_controller.cpp,test_vote_controller.cpp,test_vote_forms.cpp,test_vote_activity.cpp,test_polls_navigator.cpp,test_poll_views.cpp}`, `tests/smoke/test_polls_frontends.cpp` | tests | +| `examples/{pastebin,bookmarks,polls}/CMakeLists.txt` | the WebAssembly server url on target ``; WASM-only DTO sources on `ladder__app` | +| `.github/workflows/wasm-ladder.yml` | builds target `` for a rung with `ui/main.cpp` (Part 4 already configures `MORPH_BUILD_QT_QUICK` there) | +| `.github/workflows/ci.yml` | `ladder-tests` configures `MORPH_BUILD_TUI` (Part 4 already adds `MORPH_BUILD_QT_QUICK` to it; Parts 3 and 4 add both to `linux-all-features`) | +| `scripts/coverage.sh`, `codecov.yml` | `app/` measured, `ui/` ignored like the old `main()` shells | +| `examples/{pastebin,bookmarks,polls}/README.md`, `docs/GETTING-STARTED.md` | running it, the client, visible differences | + +Deleted per group: `examples//gui/` (with `gui/qml/`), `gui_lib/`, `gui_wasm/`, +`tests/test_*_presenter.cpp`, `tests/test_*_qml_bridges.cpp`, `tests/test_gui_qml_smoke.cpp`. + +## Build and test commands (used by every task) + +```bash +cmake -S . -B build/all -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_NET=ON -DMORPH_BUILD_FORMS_QML=ON # once (master plan, "build/all") +cmake --build build/all --target ladder_pastebin_tests # or ladder_bookmarks_tests / ladder_polls_tests +QT_QPA_PLATFORM=offscreen ./build/all/examples/pastebin/ladder_pastebin_tests "[pastebin][controller]" +ctest --test-dir build/all -L ladder-pastebin --output-on-failure # the whole rung +``` + +Run test binaries from the repository root so `DbFixture`'s `morph_ladder_test.db` lands in one place. +Configuring prints `morph: warnings: ... strict=ON`; if it does not, stop and say so (CONTRIBUTING). + +--- +## Group 1 — pastebin (key `pastebin`) + +### Task 1: Preflight, a server-only `App` library, and pastebin's `App` moved into it + +**Files:** +- Modify: `cmake/morph_add_rung.cmake` — the header's directory table; the `ladder_${_rung}_lib` block (its + `target_link_libraries`/`set_target_properties` lines); a new `ladder_${_rung}_server_app` block directly + before `# ── ladder__server`; the `ladder_${_rung}_server` block; the tests block, after the + `WHOLE_ARCHIVE` link +- Move: `examples/pastebin/include/pastebin/app/app.hpp` → + `examples/pastebin/src/server/include/pastebin/app/app.hpp` +- Move: `examples/pastebin/src/app/app.cpp` → `examples/pastebin/src/server/app/app.cpp` +- Test: the rung's existing `tests/test_paste_model.cpp` (its expiry-sweep cases construct `pastebin::app::App`) + +**Interfaces:** +- Consumes: C1–C7 (confirmed in Step 1); `morph_add_rung()`'s existing blocks headed + `# ── ladder__lib`, `# ── ladder__server` and `# ── ladder__tests` (Part 6 adds its `app/`, + `ui/` and smoke blocks around them and edits none of the lines replaced below). +- Produces: the convention `src/server/app/*.cpp` + `src/server/include/` → STATIC `ladder__server_app` + (alias `morph::ladder__server_app`, Qt6::Core, AUTOMOC), linked by `ladder__server` and + `ladder__tests`; `ladder__lib` links no Qt once `examples//app/` exists. Both rules are + written for any rung, by directory alone: Part 9's kanban (whose server app adds `Qt6::Network` itself) and + ledger use them unchanged. + +- [ ] **Step 1: Confirm what this plan consumes from Parts 2, 5 and 6** + +```bash +grep -n 'ladder_${_rung}_app\|morph_add_example_ui\|_smoke_tests' cmake/morph_add_rung.cmake +grep -n 'MORPH_EXAMPLE_HAS_TUI\|MORPH_EXAMPLE_HAS_QT_QUICK' cmake/morph_example_app.cmake +ls examples/common/app/{app_environment,transport,uuid,poller,wiring,completion_map}.hpp \ + examples/common/testkit/{frontend_smoke,wait}.hpp +grep -n 'struct PollPage\|struct PollerOptions\|stoppedBy()' examples/common/app/poller.hpp +grep -n 'ready() const\|struct LocalSetup' examples/common/app/transport.hpp +grep -n 'struct Wiring' examples/common/app/wiring.hpp +grep -n 'mapCompletion' examples/common/app/completion_map.hpp +grep -n 'forAction()' include/morph/forms/engine/field_model.hpp +grep -n 'accepts' include/morph/forms/engine/form_session.hpp +grep -n 'handlerChoiceFetcher' include/morph/forms/engine/handler_submitter.hpp +grep -n 'submitLabel' include/morph/forms/engine/form_view.hpp +grep -n '"Column" : "Row"' include/morph/ui/testing/recording_backend.hpp +``` + +Expected: every command prints at least one line, matching C1–C7. These are the upstream plans' own names, already +written into the code below; a command that prints nothing means that part has not landed as its plan says — stop +and report which, rather than adapting this plan around it. + +- [ ] **Step 2: Run the rung's model tests as the baseline** + +```bash +cmake --build build/all --target ladder_pastebin_tests +QT_QPA_PLATFORM=offscreen ./build/all/examples/pastebin/ladder_pastebin_tests "[pastebin][model]" +``` + +Expected: PASS. This is the check the move below must keep green; the move adds no behaviour of its own. + +- [ ] **Step 3: Teach `morph_add_rung` the server-only library and the Qt-free domain library** + +In `cmake/morph_add_rung.cmake`, in the header's "Directory -> target convention" table, after the +`src/server/*.cpp` line, add: + +```cmake +# src/server/app/*.cpp + src/server/include/ -> ladder__server_app STATIC (Qt6::Core; the server's App) +# +# A rung with app/ (the declarative client layout) gets a ladder__lib that links no Qt: the client +# library links it, and Qt must not reach the client through it. A server's QObject App lives in +# ladder__server_app instead, which the server and the rung's tests link. +``` + +In the `ladder_${_rung}_lib` block, replace + +```cmake + target_link_libraries(ladder_${_rung}_lib PUBLIC morph::morph Lightweight::Lightweight Qt6::Core) + target_compile_features(ladder_${_rung}_lib PUBLIC cxx_std_23) + set_target_properties(ladder_${_rung}_lib PROPERTIES AUTOMOC ON) +``` + +with + +```cmake + target_link_libraries(ladder_${_rung}_lib PUBLIC morph::morph Lightweight::Lightweight) + # A rung still on the QML layout declares its QObject App in src/app/, inside this library. + if(NOT EXISTS "${_dir}/app") + target_link_libraries(ladder_${_rung}_lib PUBLIC Qt6::Core) + set_target_properties(ladder_${_rung}_lib PROPERTIES AUTOMOC ON) + endif() + target_compile_features(ladder_${_rung}_lib PUBLIC cxx_std_23) +``` + +Directly before the line `# ── ladder__server: standalone server binary (native only) ────`, insert: + +```cmake + # ── ladder__server_app: the server's Qt-side bootstrap (native only) ── + # A server's App may be a QObject driven by QTimers (an expiry sweep, a metadata worker, an outbox + # relay). It runs only in the server process, so it lives here rather than in ladder__lib. The + # rung's tests link it as well: they drive those background jobs directly. Its headers stay under + # src/server/include//app/, so every #include "/app/app.hpp" keeps its spelling. + set(_server_app "") + if(NOT EMSCRIPTEN AND TARGET ladder_${_rung}_lib) + file(GLOB_RECURSE _server_app_sources CONFIGURE_DEPENDS "${_dir}/src/server/app/*.cpp") + if(_server_app_sources) + # Listed so AUTOMOC sees a Q_OBJECT header that does not sit beside its .cpp. + file(GLOB_RECURSE _server_app_headers CONFIGURE_DEPENDS "${_dir}/src/server/include/*.hpp") + add_library(ladder_${_rung}_server_app STATIC ${_server_app_sources} ${_server_app_headers}) + add_library(morph::ladder_${_rung}_server_app ALIAS ladder_${_rung}_server_app) + target_include_directories(ladder_${_rung}_server_app PUBLIC "${_dir}/src/server/include") + target_link_libraries(ladder_${_rung}_server_app PUBLIC + morph::ladder_${_rung}_lib morph::qt morph_qt_impl Qt6::Core) + target_compile_features(ladder_${_rung}_server_app PUBLIC cxx_std_23) + set_target_properties(ladder_${_rung}_server_app PROPERTIES AUTOMOC ON) + apply_bigobj(ladder_${_rung}_server_app) + # Lightweight's headers are not -Werror clean (see ladder__lib) — no apply_warnings(). + if(AF_COVERAGE) + apply_coverage(ladder_${_rung}_server_app) + endif() + if(DEFINED AF_SANITIZER) + apply_sanitizers(ladder_${_rung}_server_app ${AF_SANITIZER}) + endif() + set(_server_app ladder_${_rung}_server_app) + endif() + endif() + +``` + +In the `ladder_${_rung}_server` block, replace + +```cmake + file(GLOB_RECURSE _server_sources CONFIGURE_DEPENDS "${_dir}/src/server/*.cpp") + if(_server_sources AND TARGET ladder_${_rung}_lib) + add_executable(ladder_${_rung}_server ${_server_sources}) + target_link_libraries(ladder_${_rung}_server PRIVATE + morph::ladder_${_rung}_lib morph::qt morph_qt_impl Qt6::Core) +``` + +with + +```cmake + file(GLOB_RECURSE _server_sources CONFIGURE_DEPENDS "${_dir}/src/server/*.cpp") + # src/server/app/ is ladder__server_app's, linked below rather than compiled twice. + list(FILTER _server_sources EXCLUDE REGEX "/src/server/app/") + if(_server_sources AND TARGET ladder_${_rung}_lib) + add_executable(ladder_${_rung}_server ${_server_sources}) + target_link_libraries(ladder_${_rung}_server PRIVATE + morph::ladder_${_rung}_lib ${_server_app} morph::qt morph_qt_impl Qt6::Core) +``` + +In the tests block, directly after the `unset(_lightweight_includes)` that closes the +`if(TARGET ladder_${_rung}_lib)` branch (still inside that branch), add: + +```cmake + if(_server_app) + target_link_libraries(ladder_${_rung}_tests PRIVATE ${_server_app}) + endif() +``` + +- [ ] **Step 4: Move pastebin's `App` into the server** + +```bash +mkdir -p examples/pastebin/src/server/include/pastebin/app examples/pastebin/src/server/app +git mv examples/pastebin/include/pastebin/app/app.hpp examples/pastebin/src/server/include/pastebin/app/app.hpp +git mv examples/pastebin/src/app/app.cpp examples/pastebin/src/server/app/app.cpp +``` + +In the moved `app.hpp`, replace the sentence + +```cpp +/// expiry sweep. Nothing here decides deployment mode (`Local`/`Remote`) — +/// that stays `examples/common/gui::AppContext`'s job on the client side; +/// this is exclusively the server side. +``` + +with + +```cpp +/// expiry sweep. Nothing here decides deployment mode (`Local`/`Remote`) — +/// that is the client's (`examples::connect`, in `examples/common/app/`); +/// this is exclusively the server side, built into `ladder_pastebin_server_app`. +``` + +Nothing else in either file changes: `#include "pastebin/app/app.hpp"` resolves through the new library's include +directory, and `clock.hpp` through `ladder_pastebin_lib`'s. + +- [ ] **Step 5: Run the tests to verify they still pass** + +```bash +cmake --build build/all --target ladder_pastebin_tests ladder_pastebin_server +QT_QPA_PLATFORM=offscreen ./build/all/examples/pastebin/ladder_pastebin_tests "[pastebin][model]" +``` + +Expected: PASS, the same case count as Step 2. Mutation check: delete the three lines added to the tests block +(`if(_server_app) … endif()`), rebuild. Expected FAIL at link: `undefined reference to +`pastebin::app::App::App(` (the sweep cases in `test_paste_model.cpp`). Restore. + +- [ ] **Step 6: Commit** + +```bash +git add cmake/morph_add_rung.cmake examples/pastebin/src examples/pastebin/include +git commit -m "wip(pastebin): a server-only App library, and pastebin's App moved into it + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 2: `examples::FormSuccess` + +A form's submission is not a `Mutation` the controller owns, so it cannot carry `MutationOptions::invalidates`. +All three rungs need "after this form succeeds, refetch that and reset the form"; this is the one helper. Part 5's +`FormSession` has no equivalent (C7): it exposes `pending()`, `lastReply()` and `lastError()`, but no success +hook, and `lastReply()` is equality-gated, so a watch on it merges two identical replies into one success. + +**Files:** +- Create: `examples/common/app/form_success.hpp` +- Modify: `examples/common/app/tests/CMakeLists.txt` — append `test_form_success.cpp` as the last source of + `add_executable(examples_common_app_tests …)` +- Test: `examples/common/app/tests/test_form_success.cpp` + +**Interfaces:** +- Consumes: `forms::FormSession(reactive::Runtime&, FormModel, Submitter, ChoiceFetcher, FormSessionOptions = {})`, + `pending()`, `lastError()`, `prefill()`, `submit()`; `forms::FormModel::fromSchema`; `forms::schemaJson()`; + `reactive::Effect`, `Runtime::untracked`; `morph::ladder::testkit::StepExecutor` + (`examples/common/testkit/step_executor.hpp`, the executor Part 6's `examples_common_app_tests` already pumps). +- Produces: `morph::examples::FormSuccess(reactive::Runtime&, forms::FormSession&, std::function)` — + non-copyable, non-movable; header-only in `examples/common/app/`, so `morph_ladder_app_common` carries it. + +- [ ] **Step 1: Write the failing test** + +Create `examples/common/app/tests/test_form_success.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/form_success.hpp" +#include "testkit/step_executor.hpp" + +namespace { + +using morph::ladder::testkit::StepExecutor; +using Reply = morph::async::Completion; + +/// @brief A one-field action whose schema the session reads; nothing executes it. +struct Note { + std::string text; + static constexpr bool explicitSubmit = true; +}; + +/// @brief A submitter whose replies the test settles by hand. +struct ManualServer { + StepExecutor* owner = nullptr; + std::vector replies; + + [[nodiscard]] morph::forms::Submitter submitter() { + return [this](std::string_view, std::string) { + auto [completion, promise] = Reply::makeSettleable(owner); + replies.push_back(std::move(promise)); + return std::move(completion); + }; + } +}; + +} // namespace + +TEST_CASE("FormSuccess runs once per successful submission and never for a failure", "[form-success]") { + StepExecutor owner; + morph::reactive::Runtime runtime{owner}; + ManualServer server{.owner = &owner, .replies = {}}; + auto model = morph::forms::FormModel::fromSchema("Note", morph::forms::schemaJson()); + REQUIRE(model.has_value()); + // Note has no Choice field, so the session needs no fetcher. + morph::forms::FormSession session{runtime, std::move(*model), server.submitter(), morph::forms::ChoiceFetcher{}}; + int successes = 0; + morph::examples::FormSuccess const watch{runtime, session, [&] { ++successes; }}; + owner.runAll(); + CHECK(successes == 0); + + session.prefill(R"({"text":"one"})"); + session.submit(); + owner.runAll(); + REQUIRE(server.replies.size() == 1); + server.replies[0].resolve("{}"); + owner.runAll(); + CHECK(successes == 1); + + // An identical reply text is still a second success. + session.submit(); + owner.runAll(); + REQUIRE(server.replies.size() == 2); + server.replies[1].resolve("{}"); + owner.runAll(); + CHECK(successes == 2); + + session.submit(); + owner.runAll(); + REQUIRE(server.replies.size() == 3); + server.replies[2].reject(std::make_exception_ptr(std::runtime_error{"refused"})); + owner.runAll(); + CHECK(successes == 2); +} +``` + +In `examples/common/app/tests/CMakeLists.txt`, append `test_form_success.cpp` as the last source of +`add_executable(examples_common_app_tests …)`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target examples_common_app_tests` +Expected: FAIL — `'app/form_success.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/common/app/form_success.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +namespace morph::examples { + +/// @brief Runs a callback each time a form's submission settles without an error. +/// +/// A form's reply is not a `Mutation` its controller owns, so it cannot list the queries a success +/// invalidates; this watches the session instead. It tracks `pending()` alone: a falling edge with no +/// `lastError()` is one successful submission whatever the reply text, so two identical replies (an +/// acknowledgement twice) are two successes — a watch on `lastReply()` would merge them by equality. The +/// callback runs untracked, inside the flush that saw the edge, so it may write signals and refetch queries. +class FormSuccess { +public: + /// @param runtime The runtime the session belongs to. Borrowed: it must outlive this object. + /// @param session The session to watch. Borrowed: it must outlive this object. + /// @param onSuccess Runs once per successful submission. + FormSuccess(reactive::Runtime& runtime, forms::FormSession& session, std::function onSuccess) + : _rt{&runtime}, _session{&session}, _onSuccess{std::move(onSuccess)}, _watch{runtime, [this] { observe(); }} {} + + ~FormSuccess() = default; + FormSuccess(FormSuccess const&) = delete; + FormSuccess& operator=(FormSuccess const&) = delete; + FormSuccess(FormSuccess&&) = delete; + FormSuccess& operator=(FormSuccess&&) = delete; + +private: + void observe() { + bool const pending = _session->pending(); + bool const settled = _wasPending && !pending; + _wasPending = pending; + if (!settled) { + return; + } + _rt->untracked([this] { + if (_session->lastError() == nullptr) { + _onSuccess(); + } + }); + } + + reactive::Runtime* _rt; + forms::FormSession* _session; + std::function _onSuccess; + bool _wasPending = false; + // Last: it runs in its constructor and reads every member above. + reactive::Effect _watch; +}; + +} // namespace morph::examples +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target examples_common_app_tests \ + && ./build/all/examples/common/app/tests/examples_common_app_tests "[form-success]" +``` + +Expected: PASS. Mutation check: change `bool const settled = _wasPending && !pending;` to +`bool const settled = !pending;`. Expected FAIL at the first `CHECK(successes == 0)` (the construction run counts +as a success). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/common/app/form_success.hpp examples/common/app/tests/test_form_success.cpp \ + examples/common/app/tests/CMakeLists.txt +git commit -m "wip(pastebin): FormSuccess, a callback per successful form submission + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 3: `PasteController` — the listing, the open paste and its delete + +**Files:** +- Create: `examples/pastebin/app/controllers/paste_controller.hpp`, + `examples/pastebin/app/controllers/paste_controller.cpp` +- Create: `examples/pastebin/tests/client_fixture.hpp` +- Test: `examples/pastebin/tests/test_paste_controller.cpp` + +**Interfaces:** +- Consumes: `reactive::Query(Runtime&, BridgeHandler&, Key, QueryOptions = {})`, `value()`, + `error()`, `pending()`, `refetch()`; `reactive::Mutation(Runtime&, BridgeHandler&, + MutationOptions)`, `run()`, `lastResult()`, `error()`, `pending()`; `MutationOptions{invalidates}`; + `Signal`, `Computed`, `Effect`, `errorMessage`; `bridge::BridgeHandler(Bridge&, IExecutor*)`; + `pastebin::PasteModel` and its DTOs (`pastebin/dto/paste_dto.hpp`); `morph::units::toString` + (`include/morph/util/quantity.hpp:1246`), `Quantity::hasValue` (`:748`), `Timestamp::hasValue`/`operator*` + (`include/morph/util/datetime.hpp:397`), `DateTime::toIso8601` (`:127`); `BackendRig`, `DbFixture`, `pumpUntil`. +- Produces (Tasks 4–6 rely on these): `pastebin::client::{PasteRow, PasteFact, StatusLine, isoOrEmpty, readsText, + visibilityText, editabilityText, rowOf, factsOf, PasteController}`; `PasteController(reactive::Runtime&, + bridge::Bridge&, exec::IExecutor&)` with `rows()`, `countText()`, `refresh()`, `highlight()`, `highlighted()`, + `listLoading()`, `open()`, `remove()`, `current()`, `canDelete()`, `detailTitle()`, `facts()`, `content()`, + `statusText()`, + `statusIsError()`, `hasStatus()`; `pastebin::testing::{ClientFixture, seedPaste, pasteOf}`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/pastebin/tests/client_fixture.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +#include "controllers/paste_controller.hpp" +#include "pastebin/dto/paste_dto.hpp" +#include "pastebin/models/paste_model.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/pump.hpp" + +/// @file +/// The wiring every pastebin client test shares: one `BackendRig` client, a reactive runtime owned by the +/// rig's client executor (the executor every handler delivers on), and a `PasteController` over both. + +namespace pastebin::testing { + +/// @brief One client of one rig, with its controller. Members die controller first, rig last. +struct ClientFixture { + /// @param mode The deployment shape under test. + explicit ClientFixture(::morph::ladder::testkit::Mode mode) + : rig{mode, 1}, runtime{*rig.executor()}, controller{runtime, rig.bridge(0), *rig.executor()} {} + + ::morph::ladder::testkit::BackendRig rig; ///< Backend, executors and (Socket) server. + ::morph::reactive::Runtime runtime; ///< Owned by the rig's client executor. + client::PasteController controller; ///< The controller under test. +}; + +/// @brief A minimal valid `CreatePaste`. +/// @param content Paste body. +/// @param syntax Syntax label. +/// @return The action. +[[nodiscard]] inline CreatePaste pasteOf(std::string content, std::string syntax = "text") { + CreatePaste action; + action.content = std::move(content); + action.syntax = std::move(syntax); + return action; +} + +/// @brief Stores a paste through the model directly — the process and database every rig mode shares. +/// @param action The paste to store. +/// @return Its id. +[[nodiscard]] inline std::string seedPaste(CreatePaste const& action) { + PasteModel model; + PasteId const created = model.execute(action).id; + REQUIRE(created.hasValue()); + return *created; +} + +} // namespace pastebin::testing +``` + +Create `examples/pastebin/tests/test_paste_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "client_fixture.hpp" +#include "clock.hpp" +#include "controllers/paste_controller.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; +using pastebin::client::PasteController; +using pastebin::client::PasteFact; +using pastebin::client::PasteRow; +using pastebin::testing::ClientFixture; +using pastebin::testing::pasteOf; +using pastebin::testing::seedPaste; + +/// @brief Whether a row type carries a paste's body. +template +concept CarriesContent = requires(Row const& row) { row.content; }; + +[[nodiscard]] std::vector keysOf(std::vector const& facts) { + std::vector keys; + for (auto const& fact : facts) { + keys.push_back(fact.key); + } + return keys; +} + +[[nodiscard]] std::string lineFor(PasteController const& controller, std::string const& key) { + for (auto const& fact : controller.facts()) { + if (fact.key == key) { + return fact.line; + } + } + return {}; +} + +[[nodiscard]] bool listed(PasteController const& controller, std::string const& pasteId) { + return std::ranges::any_of(controller.rows(), [&](PasteRow const& row) { return row.pasteId == pasteId; }); +} + +void openAndWait(PasteController& controller, std::string const& pasteId) { + controller.open(pasteId); + REQUIRE(pumpUntil([&] { return controller.detailTitle() == pasteId; })); +} + +} // namespace + +// A listing never carries a paste's body; the row type cannot hold one. +static_assert(!CarriesContent); + +TEST_CASE("PasteController lists the public pastes in the narrower row shape, all three backend modes", + "[pastebin][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + std::vector created; + for (int i = 0; i < 3; ++i) { + created.push_back(seedPaste(pasteOf("listed " + std::to_string(i)))); + } + auto hidden = pasteOf("hidden"); + hidden.visibility = pastebin::Visibility::Private; + std::string const privateId = seedPaste(hidden); + + ClientFixture client{mode}; + REQUIRE(pumpUntil([&] { return client.controller.rows().size() == 3; })); + for (auto const& pasteId : created) { + CHECK(listed(client.controller, pasteId)); + } + CHECK_FALSE(listed(client.controller, privateId)); + CHECK(client.controller.countText() == "3 public paste(s)"); + for (auto const& row : client.controller.rows()) { + CHECK(row.visibility == "Public"); + CHECK(row.syntax == "text"); + CHECK(row.createdAt.find('T') != std::string::npos); + } +} + +TEST_CASE("PasteController::open shows every fact of the paste and consumes one read, all three backend modes", + "[pastebin][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + std::string const pasteId = seedPaste(pasteOf("bag contents", "cpp")); + ClientFixture client{mode}; + CHECK_FALSE(client.controller.canDelete()); + + openAndWait(client.controller, pasteId); + auto const& controller = client.controller; + CHECK(keysOf(controller.facts()) == + std::vector{"syntax", "visibility", "editability", "created", "expires", "reads", "burn after"}); + CHECK(lineFor(controller, "syntax") == "syntax: cpp"); + CHECK(lineFor(controller, "visibility") == "visibility: Public"); + CHECK(lineFor(controller, "editability") == "editability: Immutable"); + CHECK(lineFor(controller, "expires") == "expires: never"); + CHECK(lineFor(controller, "reads") == "reads: 1"); + CHECK(lineFor(controller, "burn after") == "burn after: no limit"); + CHECK(lineFor(controller, "created").find('T') != std::string::npos); + CHECK(controller.content() == "bag contents"); + CHECK(controller.statusText() == "opened " + pasteId + ", read 1 time(s)"); + CHECK_FALSE(controller.statusIsError()); + CHECK(controller.canDelete()); +} + +TEST_CASE("PasteController renders the engaged arm of every formatted fact", "[pastebin][controller]") { + DbFixture fixture; + auto action = pasteOf("fully engaged", "cpp"); + action.expiresAt = ::morph::time::Timestamp{*morph::ladder::now() + std::chrono::hours{24}}; + action.burnAfterReads = pastebin::Reads{::morph::math::Rational{9, pastebin::Reads::declaredPrecision()}}; + action.visibility = pastebin::Visibility::Private; + action.editability = pastebin::Editability::Editable; + std::string const pasteId = seedPaste(action); + ClientFixture client{Mode::Local}; + + openAndWait(client.controller, pasteId); + CHECK(lineFor(client.controller, "visibility") == "visibility: Private"); + CHECK(lineFor(client.controller, "editability") == "editability: Editable"); + std::string const expires = lineFor(client.controller, "expires"); + CHECK(expires.find('T') != std::string::npos); + CHECK(expires.back() == 'Z'); + CHECK(lineFor(client.controller, "burn after") == "burn after: 9"); + // Private: opened by id, never listed. + CHECK_FALSE(listed(client.controller, pasteId)); +} + +TEST_CASE("Opening the last allowed read burns the paste, and the listing drops it, all three backend modes", + "[pastebin][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto action = pasteOf("one read only"); + action.burnAfterReads = pastebin::Reads::fromDouble(1.0); + std::string const pasteId = seedPaste(action); + ClientFixture client{mode}; + REQUIRE(pumpUntil([&] { return listed(client.controller, pasteId); })); + + openAndWait(client.controller, pasteId); + CHECK(lineFor(client.controller, "reads") == "reads: 1"); + // GetPaste invalidates the listing: the burn shows without a manual refresh. + REQUIRE(pumpUntil([&] { return !listed(client.controller, pasteId); })); +} + +TEST_CASE("Refreshing the list never re-reads the open paste", "[pastebin][controller]") { + DbFixture fixture; + auto action = pasteOf("three reads"); + action.burnAfterReads = pastebin::Reads::fromDouble(3.0); + std::string const pasteId = seedPaste(action); + ClientFixture client{Mode::Local}; + + openAndWait(client.controller, pasteId); + REQUIRE(lineFor(client.controller, "reads") == "reads: 1"); + for (int i = 0; i < 3; ++i) { + client.controller.refresh(); + REQUIRE(client.controller.listLoading()); + REQUIRE(pumpUntil([&] { return !client.controller.listLoading(); })); + CHECK(listed(client.controller, pasteId)); + } + // The next explicit open is the second read: three refreshes consumed none. + client.controller.open(pasteId); + REQUIRE(pumpUntil([&] { return lineFor(client.controller, "reads") == "reads: 2"; })); +} + +TEST_CASE("PasteController::remove deletes the open paste, and opening it again fails, all three backend modes", + "[pastebin][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + std::string const pasteId = seedPaste(pasteOf("doomed")); + ClientFixture client{mode}; + openAndWait(client.controller, pasteId); + + client.controller.remove(); + REQUIRE(pumpUntil([&] { return !client.controller.current().has_value() && client.controller.rows().empty(); })); + CHECK(client.controller.statusText() == "deleted"); + CHECK_FALSE(client.controller.canDelete()); + + client.controller.open(pasteId); + REQUIRE(pumpUntil([&] { return client.controller.statusIsError(); })); + CHECK(client.controller.statusText().find("GetPaste") != std::string::npos); + CHECK_FALSE(client.controller.current().has_value()); +} + +TEST_CASE("An unknown id reports the model's message, and remove() with nothing open issues nothing", + "[pastebin][controller]") { + DbFixture fixture; + ClientFixture client{Mode::Local}; + client.controller.remove(); + CHECK_FALSE(client.controller.hasStatus()); + + client.controller.open("no-such-paste"); + REQUIRE(pumpUntil([&] { return client.controller.statusIsError(); })); + CHECK_FALSE(client.controller.statusText().empty()); + CHECK_FALSE(client.controller.current().has_value()); +} + +TEST_CASE("A failed listing reports its error instead of crashing", "[pastebin][controller]") { + DbFixture fixture; + ClientFixture client{Mode::Local}; + REQUIRE(pumpUntil([&] { return !client.controller.listLoading(); })); + CHECK(client.controller.countText() == "0 public paste(s)"); + { + // A genuine store error, through the schema: DbFixture re-creates it for the next case. + ::Lightweight::SqlStatement stmt; + static_cast(stmt.ExecuteDirect("DROP TABLE pastes")); + } + client.controller.refresh(); + REQUIRE(pumpUntil([&] { return client.controller.statusIsError(); })); + CHECK_FALSE(client.controller.statusText().empty()); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_pastebin_tests` +Expected: FAIL — `'controllers/paste_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/pastebin/app/controllers/paste_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pastebin/dto/paste_dto.hpp" +#include "pastebin/models/paste_model.hpp" + +/// @file +/// The controller behind pastebin's one screen: the public listing, the open paste and its delete, and the +/// create form. Toolkit-free; the view binds to its accessors and formats nothing itself. + +namespace pastebin::client { + +/// @brief One public paste in the listing, formatted for display. It carries no content: a listing never +/// shows a paste's body, which is why `PasteSummary` is narrower than `PasteView`. +struct PasteRow { + std::string pasteId; ///< The animal-name id; also the row's key. + std::string syntax; ///< The syntax label. + std::string visibility; ///< "Public" or "Private". + std::string createdAt; ///< ISO-8601 creation instant. + + /// @brief Field-wise equality, so an unchanged listing does not re-render. + bool operator==(PasteRow const&) const = default; +}; + +/// @brief One labelled fact about the open paste, with the line the view shows. +struct PasteFact { + std::string key; ///< The fact's name; also its row key. + std::string line; ///< "key: value". + + /// @brief Field-wise equality. + bool operator==(PasteFact const&) const = default; +}; + +/// @brief The screen's status line: the last list, open, delete or create outcome. +struct StatusLine { + std::string text; ///< Empty when there is nothing to report. + bool isError = false; ///< Whether `text` reports a failure. + + /// @brief Field-wise equality. + bool operator==(StatusLine const&) const = default; +}; + +/// @brief An instant as ISO-8601, or empty when unset. +/// @param instant The instant. +/// @return Its text. +[[nodiscard]] std::string isoOrEmpty(::morph::time::Timestamp const& instant); + +/// @brief A read count as text (`morph::units::toString`, so `"N/A"` when unset). +/// @param reads The count. +/// @return Its text. +[[nodiscard]] std::string readsText(Reads const& reads); + +/// @brief "Public" or "Private". +/// @param visibility The visibility. +/// @return Its word. +[[nodiscard]] std::string visibilityText(Visibility visibility); + +/// @brief "Immutable" or "Editable". +/// @param editability The editability. +/// @return Its word. +[[nodiscard]] std::string editabilityText(Editability editability); + +/// @brief One listing row. +/// @param summary The row as `ListPastes` returned it. +/// @return The display row. +[[nodiscard]] PasteRow rowOf(PasteSummary const& summary); + +/// @brief The open paste's facts, in display order: syntax, visibility, editability, created, expires +/// ("never" when unset), reads, burn after ("no limit" when unset). +/// @param view The paste. +/// @return The facts. +[[nodiscard]] std::vector factsOf(PasteView const& view); + +/// @brief pastebin's screen controller. +/// +/// The listing is a `Query`, fetched on construction and on `refresh()`. Opening a paste is a +/// `Mutation`, never a query: `GetPaste` consumes one of the paste's allowed reads, so only an +/// explicit `open()` may issue it — never a refetch, a re-render or a selection change. A successful open +/// and a successful delete each invalidate the listing, which is how a burn shows up without a manual +/// refresh. Highlighting a row (the table's selection) is separate from opening it for the same reason. +class PasteController { +public: + /// @param runtime The runtime; its owner must be @p callbacks. + /// @param bridge The client's bridge. + /// @param callbacks Where replies are delivered. + PasteController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks); + + ~PasteController() = default; + PasteController(PasteController const&) = delete; + PasteController& operator=(PasteController const&) = delete; + PasteController(PasteController&&) = delete; + PasteController& operator=(PasteController&&) = delete; + + /// @brief The public listing. Tracked. + /// @return Its rows, newest first; empty before the first reply. + [[nodiscard]] std::vector const& rows() const { return _rows.get(); } + + /// @brief "N public paste(s)". Tracked. + /// @return The count line. + [[nodiscard]] std::string const& countText() const { return _countText.get(); } + + /// @brief Whether a listing request is in flight. Tracked. + /// @return True from issue to reply. + [[nodiscard]] bool listLoading() const { return _list.pending(); } + + /// @brief Fetches the listing again. Never re-reads the open paste. + void refresh() { _list.refetch(); } + + /// @brief Highlights a row without opening it. + /// @param pasteId The row, or `nullopt` for none. + void highlight(std::optional pasteId) { _highlighted.set(std::move(pasteId)); } + + /// @brief The highlighted row. Tracked. + /// @return Its id, or `nullopt`. + [[nodiscard]] std::optional const& highlighted() const { return _highlighted.get(); } + + /// @brief Opens a paste, consuming one of its reads. + /// @param pasteId The paste. + void open(std::string pasteId); + + /// @brief Deletes the open paste; does nothing when none is open. + void remove(); + + /// @brief The open paste. Tracked. + /// @return It, or `nullopt`. + [[nodiscard]] std::optional const& current() const { return _current.get(); } + + /// @brief Whether the delete button acts. Tracked. + /// @return True while a paste is open and no delete is in flight. + [[nodiscard]] bool canDelete() const { return _canDelete.get(); } + + /// @brief The detail pane's title: the open paste's id, or a hint. Tracked. + /// @return The title. + [[nodiscard]] std::string const& detailTitle() const { return _detailTitle.get(); } + + /// @brief The open paste's facts. Tracked. + /// @return `factsOf(*current())`, or empty. + [[nodiscard]] std::vector const& facts() const { return _facts.get(); } + + /// @brief The open paste's content. Tracked. + /// @return The body, or empty. + [[nodiscard]] std::string const& content() const { return _content.get(); } + + /// @brief The status line's text. Tracked. + /// @return The text, or empty. + [[nodiscard]] std::string const& statusText() const { return _status.get().text; } + + /// @brief Whether the status line reports a failure. Tracked. + /// @return True for a failure. + [[nodiscard]] bool statusIsError() const { return _status.get().isError; } + + /// @brief Whether there is a status to show. Tracked. + /// @return True when the text is non-empty. + [[nodiscard]] bool hasStatus() const { return !_status.get().text.empty(); } + +private: + void reportError(std::exception_ptr const& error); + void reportInfo(std::string text); + + ::morph::reactive::Runtime* _rt; + ::morph::bridge::BridgeHandler _handler; + ::morph::reactive::Signal> _highlighted; + ::morph::reactive::Signal> _current; + ::morph::reactive::Signal _status; + ::morph::reactive::Query _list; + ::morph::reactive::Mutation _open; + ::morph::reactive::Mutation _delete; + ::morph::reactive::Computed> _rows; + ::morph::reactive::Computed _countText; + ::morph::reactive::Computed _canDelete; + ::morph::reactive::Computed _detailTitle; + ::morph::reactive::Computed> _facts; + ::morph::reactive::Computed _content; + ::morph::reactive::Effect _onListFailed; + ::morph::reactive::Effect _onOpened; + ::morph::reactive::Effect _onOpenFailed; + ::morph::reactive::Effect _onDeleted; + ::morph::reactive::Effect _onDeleteFailed; +}; + +} // namespace pastebin::client +``` + +Create `examples/pastebin/app/controllers/paste_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/paste_controller.hpp" + +#include +#include +#include + +namespace pastebin::client { + +std::string isoOrEmpty(::morph::time::Timestamp const& instant) { + return instant.hasValue() ? (*instant).toIso8601() : std::string{}; +} + +std::string readsText(Reads const& reads) { return ::morph::units::toString(reads); } + +std::string visibilityText(Visibility visibility) { + return visibility == Visibility::Private ? "Private" : "Public"; +} + +std::string editabilityText(Editability editability) { + return editability == Editability::Editable ? "Editable" : "Immutable"; +} + +PasteRow rowOf(PasteSummary const& summary) { + return PasteRow{.pasteId = summary.id.hasValue() ? *summary.id : std::string{}, + .syntax = summary.syntax, + .visibility = visibilityText(summary.visibility), + .createdAt = isoOrEmpty(summary.createdAt)}; +} + +std::vector factsOf(PasteView const& view) { + auto const fact = [](std::string const& key, std::string const& value) { + return PasteFact{.key = key, .line = key + ": " + value}; + }; + std::string const expires = isoOrEmpty(view.expiresAt); + return {fact("syntax", view.syntax), + fact("visibility", visibilityText(view.visibility)), + fact("editability", editabilityText(view.editability)), + fact("created", isoOrEmpty(view.createdAt)), + fact("expires", expires.empty() ? std::string{"never"} : expires), + fact("reads", readsText(view.readCount)), + fact("burn after", + view.burnAfterReads.hasValue() ? readsText(view.burnAfterReads) : std::string{"no limit"})}; +} + +PasteController::PasteController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks) + : _rt{&runtime}, + _handler{bridge, &callbacks}, + _highlighted{runtime, std::nullopt}, + _current{runtime, std::nullopt}, + _status{runtime, StatusLine{}}, + _list{runtime, _handler, [] { return std::optional{ListPastes{}}; }}, + _open{runtime, _handler, ::morph::reactive::MutationOptions{.invalidates = {&_list}}}, + _delete{runtime, _handler, ::morph::reactive::MutationOptions{.invalidates = {&_list}}}, + _rows{runtime, + [this] { + std::vector rows; + if (auto const& page = _list.value()) { + rows.reserve(page->pastes.size()); + for (auto const& summary : page->pastes) { + rows.push_back(rowOf(summary)); + } + } + return rows; + }}, + _countText{runtime, [this] { return std::to_string(_rows.get().size()) + " public paste(s)"; }}, + _canDelete{runtime, [this] { return _current.get().has_value() && !_delete.pending(); }}, + _detailTitle{runtime, + [this] { + auto const& paste = _current.get(); + return paste.has_value() && paste->id.hasValue() ? *paste->id + : std::string{"no paste open: activate one in the list"}; + }}, + _facts{runtime, + [this] { + auto const& paste = _current.get(); + return paste.has_value() ? factsOf(*paste) : std::vector{}; + }}, + _content{runtime, + [this] { + auto const& paste = _current.get(); + return paste.has_value() ? paste->content : std::string{}; + }}, + _onListFailed{runtime, [this] { reportError(_list.error()); }}, + _onOpened{runtime, + [this] { + auto const& opened = _open.lastResult(); + if (!opened.has_value()) { + return; + } + _rt->untracked([&] { + _current.set(*opened); + std::string const pasteId = opened->id.hasValue() ? *opened->id : std::string{}; + reportInfo("opened " + pasteId + ", read " + readsText(opened->readCount) + " time(s)"); + }); + }}, + _onOpenFailed{runtime, [this] { reportError(_open.error()); }}, + _onDeleted{runtime, + [this] { + if (!_delete.lastResult().has_value()) { + return; + } + _rt->untracked([&] { + _current.set(std::nullopt); + _highlighted.set(std::nullopt); + reportInfo("deleted"); + }); + }}, + _onDeleteFailed{runtime, [this] { reportError(_delete.error()); }} {} + +void PasteController::open(std::string pasteId) { _open.run(GetPaste{.id = PasteId{std::move(pasteId)}}); } + +void PasteController::remove() { + auto const& paste = _current.peek(); + if (!paste.has_value() || !paste->id.hasValue()) { + return; + } + _delete.run(DeletePaste{.id = paste->id}); +} + +void PasteController::reportError(std::exception_ptr const& error) { + if (error != nullptr) { + _status.set(StatusLine{.text = ::morph::reactive::errorMessage(error), .isError = true}); + } +} + +void PasteController::reportInfo(std::string text) { _status.set(StatusLine{.text = std::move(text), .isError = false}); } + +} // namespace pastebin::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_pastebin_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/pastebin/ladder_pastebin_tests "[pastebin][controller]" +``` + +Expected: PASS. Mutation check: in the constructor, change +`_open{runtime, _handler, ::morph::reactive::MutationOptions{.invalidates = {&_list}}}` +to `_open{runtime, _handler}`. Expected FAIL in "Opening the last allowed read burns the paste, and the listing drops +it" (the wait for the row to disappear times out). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/pastebin/app examples/pastebin/tests/client_fixture.hpp examples/pastebin/tests/test_paste_controller.cpp +git commit -m "wip(pastebin): PasteController — listing, explicit open, delete + +Signed-off-by: Christian Parpart " +``` + +--- +### Task 4: The typed create form + +**Files:** +- Modify: `examples/pastebin/app/controllers/paste_controller.hpp` — includes, two public accessors after + `hasStatus()`, a private method and three members +- Modify: `examples/pastebin/app/controllers/paste_controller.cpp` — three constructor initialisers, `onCreated()` +- Modify: `examples/pastebin/tests/client_fixture.hpp` — `createVia()` +- Test: `examples/pastebin/tests/test_paste_controller.cpp` — four cases appended + +**Interfaces:** +- Consumes: `forms::Form(Runtime&, BridgeHandler&, ChoiceFetcher, FormSessionOptions = {})`, + `session()`, `set()`, `ready()`, `lastResult()` (`typed_form.hpp`); `forms::handlerChoiceFetcher(IExecutor&, + Handlers&...)` (`handler_submitter.hpp`, Part 5 Task 10b); `FormSession::submit()`, `reset()`, `ready()`, + `body()`, `pending()`, `lastError()`; `examples::FormSuccess` (Task 2). +- Produces: `PasteController::createForm() -> forms::Form&`, + `PasteController::lastCreatedId() -> std::optional const&`; `pastebin::testing::createVia()`. + +- [ ] **Step 1: Write the failing test** + +Append to `examples/pastebin/tests/client_fixture.hpp`, inside `namespace pastebin::testing`, after `seedPaste`: + +```cpp +/// @brief Fills the create form's two required fields, submits it and waits for the new id. +/// @param controller The controller whose form to use. +/// @param content Paste body. +/// @param syntax Syntax label. +/// @return The new paste's id. +[[nodiscard]] inline std::string createVia(client::PasteController& controller, std::string content, + std::string syntax = "text") { + auto& form = controller.createForm(); + form.set<&CreatePaste::content>(std::move(content)); + form.set<&CreatePaste::syntax>(std::move(syntax)); + REQUIRE(form.ready()); + std::optional const before = controller.lastCreatedId(); + form.session().submit(); + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return controller.lastCreatedId() != before; })); + return controller.lastCreatedId().value_or(std::string{}); +} +``` + +and add `#include ` to its includes. + +Append to `examples/pastebin/tests/test_paste_controller.cpp`: + +```cpp +TEST_CASE("The create form stores pastes, lists them and resets itself, all three backend modes", + "[pastebin][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + ClientFixture client{mode}; + std::vector created; + for (int i = 0; i < 3; ++i) { + created.push_back(pastebin::testing::createVia(client.controller, "created " + std::to_string(i))); + } + REQUIRE(pumpUntil([&] { return client.controller.rows().size() == 3; })); + for (auto const& pasteId : created) { + CHECK_FALSE(pasteId.empty()); + CHECK(listed(client.controller, pasteId)); + } + CHECK(client.controller.statusText() == "created " + created.back()); + // reset(): the required fields are empty again, so the form waits for new input. + CHECK_FALSE(client.controller.createForm().ready()); +} + +TEST_CASE("The create form is not ready while CreatePaste::validate refuses, and submitting it sends nothing", + "[pastebin][controller]") { + DbFixture fixture; + ClientFixture client{Mode::Local}; + auto& form = client.controller.createForm(); + form.set<&pastebin::CreatePaste::syntax>(std::string{"text"}); + CHECK_FALSE(form.ready()); // content is required + + form.set<&pastebin::CreatePaste::content>(std::string{"body"}); + form.set<&pastebin::CreatePaste::syntax>(std::string(pastebin::kMaxSyntaxBytes + 1, 'x')); + // The schema carries no length bound for syntax; CreatePaste::validate() does, and it gates the session itself. + CHECK_FALSE(form.ready()); + CHECK_FALSE(form.session().ready()); + CHECK_FALSE(form.session().body().has_value()); + + form.session().submit(); + CHECK_FALSE(form.session().pending()); // nothing went out + REQUIRE(pumpUntil([&] { return !client.controller.listLoading(); })); + CHECK(form.session().lastError() == nullptr); + CHECK(client.controller.rows().empty()); + CHECK_FALSE(client.controller.lastCreatedId().has_value()); + + form.set<&pastebin::CreatePaste::syntax>(std::string{"text"}); + CHECK(form.ready()); // the same draft, once validate() accepts it +} + +TEST_CASE("A private paste is created and opened by id, but never listed", "[pastebin][controller]") { + DbFixture fixture; + ClientFixture client{Mode::Local}; + auto& form = client.controller.createForm(); + form.set<&pastebin::CreatePaste::visibility>(pastebin::Visibility::Private); + std::string const pasteId = pastebin::testing::createVia(client.controller, "hidden"); + REQUIRE(pumpUntil([&] { return !client.controller.listLoading(); })); + CHECK(client.controller.rows().empty()); + + openAndWait(client.controller, pasteId); + CHECK(lineFor(client.controller, "visibility") == "visibility: Private"); +} + +TEST_CASE("a controller destroyed with a create in flight drops its reply", "[pastebin][controller]") { + // LocalSingleThread: every dispatch and completion runs on the rig's one executor, so the abandoned + // create is strictly ordered before the live one, and its reply reaches the dead controller's gate + // before the live reply arrives. ASan is the observer: without the gate the reply writes freed storage. + DbFixture fixture; + morph::ladder::testkit::BackendRig rig{Mode::LocalSingleThread, 1}; + morph::reactive::Runtime runtime{*rig.executor()}; + { + PasteController abandoned{runtime, rig.bridge(0), *rig.executor()}; + auto& form = abandoned.createForm(); + form.set<&pastebin::CreatePaste::content>(std::string{"submitted, then abandoned"}); + form.set<&pastebin::CreatePaste::syntax>(std::string{"text"}); + REQUIRE(form.ready()); + form.session().submit(); + // No pump before this brace: the reply cannot have been delivered yet. + } + PasteController live{runtime, rig.bridge(0), *rig.executor()}; + static_cast(pastebin::testing::createVia(live, "submitted and awaited")); + // The abandoned create ran on the model; only its delivery was dropped. + REQUIRE(pumpUntil([&] { return live.rows().size() == 2; })); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_pastebin_tests` +Expected: FAIL — `no member named 'createForm' in 'pastebin::client::PasteController'`. + +- [ ] **Step 3: Implement** + +In `examples/pastebin/app/controllers/paste_controller.hpp`, add after `#include `: + +```cpp +#include +#include +``` + +and after `#include "pastebin/models/paste_model.hpp"`: + +```cpp +#include "app/form_success.hpp" +``` + +Add to the class doc comment, before its closing line: + +```cpp +/// The create form is a typed `forms::Form` on the controller's own handler; its submission is +/// explicit (`CreatePaste::explicitSubmit`), `CreatePaste::validate()` gates its readiness, and a success lists the +/// new paste, reports its id and resets the form. +``` + +After `hasStatus()`, add: + +```cpp + /// @brief The typed create form; the view renders its session. + /// @return The form. + [[nodiscard]] ::morph::forms::Form& createForm() noexcept { return _create; } + + /// @brief The id the last successful create returned. Tracked. + /// @return It, or `nullopt` before the first. + [[nodiscard]] std::optional const& lastCreatedId() const { return _lastCreated.get(); } +``` + +After `void reportInfo(std::string text);` add `void onCreated();`. Add the member +`::morph::reactive::Signal> _lastCreated;` directly after `_status`, the member +`::morph::forms::Form _create;` directly after `_delete`, and as the **last** member: + +```cpp + // Last: it watches `_create` and calls into everything above. + ::morph::examples::FormSuccess _onCreated; +``` + +In `paste_controller.cpp`, add the initialisers in declaration order — after `_status{runtime, StatusLine{}},`: + +```cpp + _lastCreated{runtime, std::nullopt}, +``` + +after `_delete{runtime, _handler, ::morph::reactive::MutationOptions{.invalidates = {&_list}}},` (the fetcher goes +through the same handler; `CreatePaste` names no Choice today, and one added later must reach this model too): + +```cpp + _create{runtime, _handler, ::morph::forms::handlerChoiceFetcher(callbacks, _handler)}, +``` + +and replace the final `_onDeleteFailed{runtime, [this] { reportError(_delete.error()); }} {}` with: + +```cpp + _onDeleteFailed{runtime, [this] { reportError(_delete.error()); }}, + _onCreated{runtime, _create.session(), [this] { onCreated(); }} {} +``` + +Add after `PasteController::remove()`: + +```cpp +void PasteController::onCreated() { + auto const& result = _create.lastResult(); + std::string const created = result.has_value() && result->id.hasValue() ? *result->id : std::string{}; + _lastCreated.set(created); + reportInfo("created " + created); + _list.refetch(); + _create.session().reset(); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_pastebin_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/pastebin/ladder_pastebin_tests "[pastebin][controller]" +``` + +Expected: PASS. Mutation check: delete `_list.refetch();` from `onCreated()`. Expected FAIL in "The create form +stores pastes, lists them and resets itself" (the wait for three rows times out). Restore. Second mutation +check, on the ASan build of Task 8: a typed form's reply reaches its session through `Form::execute`'s relay, gated +by the form's `_relay` scope, so that is the gate this case exercises. In `include/morph/forms/engine/typed_form.hpp`, +change `Form::execute`'s `.then(_relay,` to `.thenDetached(` and its `.onError(_relay,` to `.onErrorDetached(`; +expected `AddressSanitizer: heap-use-after-free` in "a controller destroyed with a create in flight drops its +reply" (the relay writes the destroyed form's `_lastResult`). Restore. If the case stays clean under that mutation, +the destroyed `BridgeHandler` dropped the reply first: say so in the hand-off instead of reporting the form's gate +as observed. + +- [ ] **Step 5: Commit** + +```bash +git add examples/pastebin/app examples/pastebin/tests/client_fixture.hpp examples/pastebin/tests/test_paste_controller.cpp +git commit -m "wip(pastebin): the typed CreatePaste form + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 5: `pasteScreen` + +**Files:** +- Create: `examples/pastebin/app/views/paste_view.hpp`, `examples/pastebin/app/views/paste_view.cpp` +- Test: `examples/pastebin/tests/test_paste_view.cpp` + +**Interfaces:** +- Consumes: `ui::{Node, Key, text, button, column, row, panel, scroll, busy, table, forEach, TableColumn, + TableOptions, SelectionMode, Sizing, TextRole}` (Part 2 `view.hpp`); `ui::Mounted` (`mount.hpp`); + `ui::testing::RecordingBackend::{find, all, prop, click, selectRows, activateRow}`; + `forms::formView(FormSession&, FormViewOptions)`, `FormViewOptions{overrides, gridColumns, submitLabel, + flatGridColumns}` (`form_view.hpp`; with `gridColumns = 1` the implicit group is one column too); + `PasteController` (Tasks 3–4). +- Produces: `pastebin::client::pasteScreen(PasteController&) -> ui::Node`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/pastebin/tests/test_paste_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "client_fixture.hpp" +#include "testkit/db_fixture.hpp" +#include "views/paste_view.hpp" + +namespace { + +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; +using morph::ui::testing::RecordingBackend; +using pastebin::testing::ClientFixture; +using pastebin::testing::pasteOf; +using pastebin::testing::seedPaste; + +} // namespace + +TEST_CASE("pasteScreen: the tree, and its refresh drives the listing", "[pastebin][view]") { + DbFixture fixture; + std::string const pasteId = seedPaste(pasteOf("viewed", "cpp")); + ClientFixture client{Mode::Local}; + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, pastebin::client::pasteScreen(client.controller)}; + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", pasteId).has_value(); })); + + CHECK(backend.find("Panel", "title", "New paste").has_value()); + CHECK(backend.find("Button", "label", "Create paste").has_value()); + CHECK(backend.find("Text", "text", "1 public paste(s)").has_value()); + CHECK(backend.find("Panel", "title", "no paste open: activate one in the list").has_value()); + auto const remove = backend.find("Button", "label", "Delete this paste"); + REQUIRE(remove.has_value()); + CHECK(backend.prop(*remove, "enabled") == "false"); + CHECK(backend.all("Table").size() == 1); + + static_cast(seedPaste(pasteOf("second"))); + auto const refresh = backend.find("Button", "label", "Refresh list"); + REQUIRE(refresh.has_value()); + backend.click(*refresh); + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", "2 public paste(s)").has_value(); })); +} + +TEST_CASE("pasteScreen: moving the selection opens nothing, activating a row opens it once", "[pastebin][view]") { + DbFixture fixture; + auto action = pasteOf("two reads"); + action.burnAfterReads = pastebin::Reads::fromDouble(2.0); + std::string const pasteId = seedPaste(action); + ClientFixture client{Mode::Local}; + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, pastebin::client::pasteScreen(client.controller)}; + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", pasteId).has_value(); })); + auto const tables = backend.all("Table"); + REQUIRE(tables.size() == 1); + + backend.selectRows(tables.front(), {morph::ui::Key{pasteId}}); + REQUIRE(pumpUntil([&] { return client.controller.highlighted() == std::optional{pasteId}; })); + CHECK_FALSE(client.controller.current().has_value()); + + backend.activateRow(tables.front(), morph::ui::Key{pasteId}); + // One read: had the selection opened the paste too, this would say 2. + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", "reads: 1").has_value(); })); + CHECK(backend.find("Panel", "title", pasteId).has_value()); + + auto const remove = backend.find("Button", "label", "Delete this paste"); + REQUIRE(remove.has_value()); + REQUIRE(pumpUntil([&] { return backend.prop(*remove, "enabled") == "true"; })); + backend.click(*remove); + REQUIRE(pumpUntil([&] { return client.controller.rows().empty() && !client.controller.current().has_value(); })); + CHECK(backend.find("Text", "text", "deleted").has_value()); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_pastebin_tests` +Expected: FAIL — `'views/paste_view.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/pastebin/app/views/paste_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "controllers/paste_controller.hpp" + +/// @file +/// pastebin's one screen as a view tree: bindings over `PasteController`, and nothing else. + +namespace pastebin::client { + +/// @brief The create form, the listing and the open paste side by side, under a status line. +/// +/// Selecting a row only highlights it; activating it (Enter, a double click) opens it, because opening +/// consumes one of the paste's reads. +/// @param controller The screen's controller. Borrowed: it must outlive the mounted tree. +/// @return The screen. +[[nodiscard]] ::morph::ui::Node pasteScreen(PasteController& controller); + +} // namespace pastebin::client +``` + +Create `examples/pastebin/app/views/paste_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "views/paste_view.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace pastebin::client { + +namespace { + +namespace ui = ::morph::ui; + +/// @brief A table key as the paste id it stands for. +[[nodiscard]] std::optional pasteIdOf(ui::Key const& key) { + if (auto const* pasteId = std::get_if(&key)) { + return *pasteId; + } + return std::nullopt; +} + +/// @brief The highlighted paste as the table's selection. +[[nodiscard]] std::vector selectionOf(std::optional const& pasteId) { + return pasteId.has_value() ? std::vector{ui::Key{*pasteId}} : std::vector{}; +} + +[[nodiscard]] ui::Node statusLine(PasteController& controller) { + return ui::text({.text = [&controller] { return controller.statusText(); }, + .role = [&controller] { + return controller.statusIsError() ? ui::TextRole::Error : ui::TextRole::Success; + }, + .common = {.visible = [&controller] { return controller.hasStatus(); }}}); +} + +[[nodiscard]] ui::Node pasteTable(PasteController& controller) { + return ui::table( + {ui::TableColumn{.label = "Paste"}, ui::TableColumn{.label = "Syntax"}, + ui::TableColumn{.label = "Visibility"}, ui::TableColumn{.label = "Created", .width = ui::Sizing::stretch()}}, + [&controller] { return controller.rows(); }, [](PasteRow const& row) { return ui::Key{row.pasteId}; }, + [](::morph::reactive::Signal const& row) { + return std::vector{ui::text({.text = [&row] { return row.get().pasteId; }}), + ui::text({.text = [&row] { return row.get().syntax; }}), + ui::text({.text = [&row] { return row.get().visibility; }}), + ui::text({.text = [&row] { return row.get().createdAt; }})}; + }, + ui::TableOptions{ + .selectionMode = ui::SelectionMode::Single, + .selection = [&controller] { return selectionOf(controller.highlighted()); }, + .onSelectionChange = + [&controller](std::vector const& keys) { + controller.highlight(keys.empty() ? std::nullopt : pasteIdOf(keys.front())); + }, + .onActivate = + [&controller](ui::Key const& key) { + if (auto pasteId = pasteIdOf(key)) { + controller.open(std::move(*pasteId)); + } + }, + .common = {.layout = {.height = ui::Sizing::stretch()}}}); +} + +[[nodiscard]] ui::Node listPane(PasteController& controller) { + return ui::column( + {.children = {ui::panel({.title = "New paste", + .padding = 1, + .child = ::morph::forms::formView( + controller.createForm().session(), + {.overrides = {}, .gridColumns = 1, .submitLabel = "Create paste"})}), + ui::row({.children = {ui::button({.label = "Refresh list", + .onClick = [&controller] { controller.refresh(); }}), + ui::busy({.active = [&controller] { return controller.listLoading(); }, + .label = "loading"}), + ui::text({.text = [&controller] { return controller.countText(); }, + .role = ui::TextRole::Muted})}, + .gap = 2}), + pasteTable(controller)}, + .gap = 1, + .common = {.layout = {.width = ui::Sizing::stretch()}}}); +} + +[[nodiscard]] ui::Node detailPane(PasteController& controller) { + return ui::panel( + {.title = [&controller] { return controller.detailTitle(); }, + .padding = 1, + .child = ui::column( + {.children = {ui::forEach( + [&controller] { return controller.facts(); }, + [](PasteFact const& fact) { return ui::Key{fact.key}; }, + [](::morph::reactive::Signal const& fact) { + return ui::text({.text = [&fact] { return fact.get().line; }}); + }), + ui::scroll({.child = ui::text({.text = [&controller] { return controller.content(); }}), + .common = {.layout = {.height = ui::Sizing::stretch()}}}), + ui::button({.label = "Delete this paste", + .onClick = [&controller] { controller.remove(); }, + .common = {.enabled = [&controller] { return controller.canDelete(); }}})}, + .gap = 1}), + .common = {.layout = {.width = ui::Sizing::stretch()}}}); +} + +} // namespace + +::morph::ui::Node pasteScreen(PasteController& controller) { + return ui::column( + {.children = {statusLine(controller), + ui::row({.children = {listPane(controller), detailPane(controller)}, + .gap = 2, + .common = {.layout = {.height = ui::Sizing::stretch()}}})}, + .gap = 1}); +} + +} // namespace pastebin::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_pastebin_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/pastebin/ladder_pastebin_tests "[pastebin][view]" +``` + +Expected: PASS. Mutation check: in `pasteTable`'s `.onSelectionChange`, add +`controller.open(*pasteIdOf(keys.front()));` +after the `highlight(...)` call. Expected FAIL in "pasteScreen: moving the selection opens nothing, activating a +row opens it once" (`CHECK_FALSE(client.controller.current().has_value())`, then `reads: 1` never appears). +Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/pastebin/app/views examples/pastebin/tests/test_paste_view.cpp +git commit -m "wip(pastebin): pasteScreen, the one screen as a view tree + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 6: The application, the one binary, and the old client deleted + +**Files:** +- Create: `examples/pastebin/app/pastebin_application.hpp`, `examples/pastebin/app/pastebin_application.cpp` +- Create: `examples/pastebin/ui/main.cpp` +- Modify: `examples/pastebin/CMakeLists.txt` — the WebAssembly server url block +- Modify: `.github/workflows/wasm-ladder.yml` — the named-target build step (Part 4 already configures + `MORPH_BUILD_QT_QUICK` and builds `morph_qt_quick` there) +- Modify: `.github/workflows/ci.yml` — the `ladder-tests` Configure step +- Modify: `scripts/coverage.sh` — the rung source loop (`for _sub in include src gui_lib`) and its comment +- Modify: `codecov.yml` — pastebin's component comment and its two `ignore:` entries +- Delete: `examples/pastebin/gui/`, `examples/pastebin/gui_lib/`, `examples/pastebin/gui_wasm/`, + `examples/pastebin/tests/test_paste_presenter.cpp`, `test_paste_qml_bridges.cpp`, `test_gui_qml_smoke.cpp` +- Test: `examples/pastebin/tests/smoke/test_pastebin_frontends.cpp` (binary `ladder_pastebin_smoke_tests`, C3) + +**Interfaces:** +- Consumes: `ui::{Application, AppContext, ApplicationFactory, FrontendOption, selectFrontend}` + (`morph/ui/frontend.hpp`); `tui::frontendOption(FrontendConfig = {})` (`morph/tui/frontend.hpp`, Part 3); + `qt_quick::frontendOption(int&, char**, ui::EnvironmentReader = ui::processEnvironment())` + (`morph/qt_quick/frontend.hpp`, Part 4); `examples::{AppEnvironment::fromArgs, connect, Connection::bridge, + Connection::callbacks, LocalSetup}`; `examples::testing::{runFrontendSmoke, SmokeFrontend}`; + `morph::ladder::testkit::DbFixture::computeConnectionString` (public, `examples/common/testkit/db_fixture.hpp`); + `pastebin::db::setup` (`examples/pastebin/include/pastebin/db/database.hpp`). +- Produces: `pastebin::client::PastebinApplication(ui::AppContext&, examples::AppEnvironment const&)`, + `pastebin::client::makeApplication(ui::AppContext&, examples::AppEnvironment const&) -> + std::unique_ptr` (the contract's convention: the local database setup — `env.db`, else + `PASTEBIN_DB`, else `pastebin.db` — is built inside); the `pastebin` binary. + +Claims of the deleted test files, and where each one now lives: + +| Deleted case | Re-expressed as | +|---|---| +| presenter: create then get round-trips (3 modes) | `test_paste_controller.cpp` "The create form stores pastes…" + "PasteController::open shows every fact…" | +| presenter: edit replaces content and syntax (3 modes) | dropped — no screen offers `EditPaste` (it never had one); the model behaviour is `test_paste_model.cpp` "EditPaste replaces an editable paste's content and syntax" | +| presenter: remove, then get fails (3 modes) | "PasteController::remove deletes the open paste, and opening it again fails" | +| presenter: list returns the pastes just created (3 modes) | "The create form stores pastes, lists them…" | +| presenter: every action routes its failure | create: "The create form is not ready while CreatePaste::validate refuses…" (`validate()` gates the typed form, so a refused body never reaches the wire); get: "An unknown id reports the model's message…"; delete: same case (`remove()` with nothing open issues nothing — the only way the UI can send a disengaged id); list: "A failed listing reports its error…" | +| presenter: get unknown id emits failed | "An unknown id reports the model's message…" | +| bridges: the QML surface audit | dropped with the QML (spec 4 §7) | +| bridges: a successful create relayed (3 modes) | "The create form stores pastes…" (`statusText() == "created "`) | +| bridges: a rejected create relayed, nothing stored | "The create form is not ready while CreatePaste::validate refuses, and submitting it sends nothing" | +| bridges: destroyed with a submit in flight | "a controller destroyed with a create in flight drops its reply" | +| bridges: the open bag carries every key (3 modes) | "PasteController::open shows every fact…" | +| bridges: rows in the narrower shape, public only | "PasteController lists the public pastes in the narrower row shape" (+ `static_assert(!CarriesContent)`) | +| bridges: the engaged arm of every formatted field | "PasteController renders the engaged arm of every formatted fact" | +| bridges: remove, then open fails with the model's message (3 modes) | "PasteController::remove deletes the open paste…" | +| bridges: open unknown id emits failed, not loaded | "An unknown id reports the model's message…" | +| smoke: Main.qml loads; the create form renders its Submit | `tests/smoke/test_pastebin_frontends.cpp` (TUI and Qt Quick) and `test_paste_view.cpp` ("Create paste" button) | + +- [ ] **Step 1: Write the failing test** + +Create `examples/pastebin/tests/smoke/test_pastebin_frontends.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "app/app_environment.hpp" +#include "pastebin_application.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/frontend_smoke.hpp" + +#if MORPH_EXAMPLE_HAS_TUI || MORPH_EXAMPLE_HAS_QT_QUICK + +namespace { + +/// @brief The application in local mode, `--db` naming the database `DbFixture` already prepared: the +/// application's own setup points at it again and finds every migration applied. +[[nodiscard]] morph::ui::ApplicationFactory pastebinFactory() { + return [](morph::ui::AppContext& ctx) { + morph::examples::AppEnvironment env; + env.db = morph::ladder::testkit::DbFixture::computeConnectionString(std::getenv("ODBC_CONNECTION_STRING")); + return pastebin::client::makeApplication(ctx, env); + }; +} + +} // namespace + +#endif + +#if MORPH_EXAMPLE_HAS_TUI +TEST_CASE("pastebin mounts and quits on the TUI", "[pastebin][frontend]") { + morph::ladder::testkit::DbFixture const fixture; + morph::examples::testing::runFrontendSmoke(pastebinFactory(), morph::examples::testing::SmokeFrontend::Tui); +} +#endif + +#if MORPH_EXAMPLE_HAS_QT_QUICK +TEST_CASE("pastebin mounts and quits on Qt Quick", "[pastebin][frontend]") { + morph::ladder::testkit::DbFixture const fixture; + morph::examples::testing::runFrontendSmoke(pastebinFactory(), morph::examples::testing::SmokeFrontend::QtQuick); +} +#endif +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_pastebin_smoke_tests` +Expected: FAIL — `'pastebin_application.hpp' file not found`. + +- [ ] **Step 3: Implement the application and the binary** + +Create `examples/pastebin/app/pastebin_application.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include "app/app_environment.hpp" +#include "app/transport.hpp" +#include "controllers/paste_controller.hpp" + +/// @file +/// pastebin as a `ui::Application`: one connection, one controller, one screen. + +namespace pastebin::client { + +/// @brief pastebin's application. Members die controller first, so every handler is gone before the +/// bridge it registered with. +class PastebinApplication final : public ::morph::ui::Application { +public: + /// @param ctx The frontend's context: the runtime, and the executor replies are delivered on. + /// @param env What the command line (or the browser build) asked for; without `--server` its `db` (else + /// `PASTEBIN_DB`, else `pastebin.db` in the working directory) is the database set up and hosted. + PastebinApplication(::morph::ui::AppContext& ctx, ::morph::examples::AppEnvironment const& env); + + ~PastebinApplication() override = default; + PastebinApplication(PastebinApplication const&) = delete; + PastebinApplication& operator=(PastebinApplication const&) = delete; + PastebinApplication(PastebinApplication&&) = delete; + PastebinApplication& operator=(PastebinApplication&&) = delete; + + /// @brief The screen. + /// @return `pasteScreen()` over the controller. + [[nodiscard]] ::morph::ui::Node view() override; + +private: + std::unique_ptr<::morph::examples::Connection> _connection; + PasteController _controller; +}; + +/// @brief The application factory `ui/main.cpp` and the smoke tests share. +/// @param ctx The frontend's context. +/// @param env The environment. +/// @return The application. +[[nodiscard]] std::unique_ptr<::morph::ui::Application> makeApplication(::morph::ui::AppContext& ctx, + ::morph::examples::AppEnvironment const& env); + +} // namespace pastebin::client +``` + +Create `examples/pastebin/app/pastebin_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "pastebin_application.hpp" + +#include +#include + +#include "views/paste_view.hpp" + +#ifndef __EMSCRIPTEN__ +#include "pastebin/db/database.hpp" +#endif + +namespace pastebin::client { + +namespace { + +/// @brief How a client without `--server` prepares its database: `--db`, else `PASTEBIN_DB`, else `pastebin.db` +/// in the working directory. A remote client never calls it, and the browser build is always remote. +[[nodiscard]] ::morph::examples::LocalSetup localSetup() { +#ifdef __EMSCRIPTEN__ + return ::morph::examples::LocalSetup{}; +#else + return ::morph::examples::LocalSetup{.setupDatabase = [](std::string const& database) { + if (!database.empty()) { + db::setup(database); + return; + } + char const* fromEnvironment = std::getenv("PASTEBIN_DB"); + db::setup(fromEnvironment != nullptr ? fromEnvironment : "DRIVER=SQLite3;Database=pastebin.db;Timeout=5000"); + }}; +#endif +} + +} // namespace + +PastebinApplication::PastebinApplication(::morph::ui::AppContext& ctx, ::morph::examples::AppEnvironment const& env) + : _connection{::morph::examples::connect(ctx, env, localSetup())}, + _controller{ctx.runtime(), _connection->bridge(), _connection->callbacks()} {} + +::morph::ui::Node PastebinApplication::view() { return pasteScreen(_controller); } + +std::unique_ptr<::morph::ui::Application> makeApplication(::morph::ui::AppContext& ctx, + ::morph::examples::AppEnvironment const& env) { + return std::make_unique(ctx, env); +} + +} // namespace pastebin::client +``` + +Create `examples/pastebin/ui/main.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// pastebin's one binary, native and WebAssembly: it reads the environment, offers every frontend this +/// build has, lets `ui::selectFrontend` pick one (`--ui=`, `MORPH_UI`, else the first usable), and hands the +/// frontend the application factory. +/// +/// @code +/// pastebin # in-process: hosts PasteModel over --db / PASTEBIN_DB +/// pastebin --server ws://127.0.0.1:8765 # against ladder_pastebin_server +/// pastebin --ui=tui # force the terminal UI +/// @endcode +/// +/// The browser build has no argv to read a server from: it is always remote, against the url baked in as +/// `MORPH_LADDER_PASTEBIN_WASM_SERVER_URL` (a `?server=` in the page url wins), and it never touches a database. + +#include +#include +#include +#include +#include + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#endif +#if MORPH_EXAMPLE_HAS_TUI +#include +#endif + +#include "app/app_environment.hpp" +#include "pastebin_application.hpp" + +int main(int argc, char** argv) { + try { + auto env = morph::examples::AppEnvironment::fromArgs(argc, argv); +#ifdef __EMSCRIPTEN__ + if (!env.server.has_value()) { + env.server = std::string{MORPH_LADDER_PASTEBIN_WASM_SERVER_URL}; + } +#endif + std::vector built; +#if MORPH_EXAMPLE_HAS_QT_QUICK + built.push_back(morph::qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_EXAMPLE_HAS_TUI + built.push_back(morph::tui::frontendOption()); +#endif + auto const frontend = morph::ui::selectFrontend(built, argc, argv); + return frontend->run( + [&env](morph::ui::AppContext& ctx) { return pastebin::client::makeApplication(ctx, env); }); + } catch (std::exception const& error) { + std::cerr << "pastebin: " << error.what() << '\n'; + } catch (...) { + std::cerr << "pastebin: unknown error\n"; + } + return 1; +} +``` + +Replace the WebAssembly block of `examples/pastebin/CMakeLists.txt` (everything after `morph_add_rung(NAME pastebin)`) +with: + +```cmake +# ── The browser build's server url ────────────────────────────────────────── +# A page served from a static bundle has no argv to read --server from, so the url the browser build of +# ui/main.cpp connects to is a build-time constant, per rung so several rungs' clients can point at their own +# servers in one Emscripten configure. +if(EMSCRIPTEN AND TARGET pastebin) + if(NOT DEFINED MORPH_LADDER_PASTEBIN_WASM_SERVER_URL) + set(MORPH_LADDER_PASTEBIN_WASM_SERVER_URL "ws://127.0.0.1:8765" CACHE STRING + "URL pastebin's browser build connects to; must be a reachable ladder_pastebin_server.") + endif() + target_compile_definitions(pastebin PRIVATE + MORPH_LADDER_PASTEBIN_WASM_SERVER_URL="${MORPH_LADDER_PASTEBIN_WASM_SERVER_URL}" + ) +endif() +``` + +- [ ] **Step 4: Delete the old client and its tests** + +```bash +git rm -r -q examples/pastebin/gui examples/pastebin/gui_lib examples/pastebin/gui_wasm \ + examples/pastebin/tests/test_paste_presenter.cpp examples/pastebin/tests/test_paste_qml_bridges.cpp \ + examples/pastebin/tests/test_gui_qml_smoke.cpp +``` + +- [ ] **Step 5: CI, coverage and the WebAssembly gate** + +`.github/workflows/wasm-ladder.yml`'s Configure step already carries `-DMORPH_BUILD_QT_QUICK=ON` (Part 4); check +with `grep -n 'MORPH_BUILD_QT_QUICK' .github/workflows/wasm-ladder.yml` and add nothing. In the step "Build the +WASM-remote spike and every rung's WASM client", replace the loop body + +```bash + if [ -d "examples/$rung/gui_wasm" ]; then + cmake --build build-wasm-ladder --target "ladder_${rung}_gui_wasm" + built=$((built + 1)) + else + echo "::notice::examples/$rung/gui_wasm not present in this checkout -- skipping ladder_${rung}_gui_wasm (see wasm-ladder.yml's comment)" + fi +``` + +with + +```bash + if [ -f "examples/$rung/ui/main.cpp" ]; then + cmake --build build-wasm-ladder --target "$rung" + built=$((built + 1)) + elif [ -d "examples/$rung/gui_wasm" ]; then + cmake --build build-wasm-ladder --target "ladder_${rung}_gui_wasm" + built=$((built + 1)) + else + echo "::notice::examples/$rung has neither ui/main.cpp nor gui_wasm/ -- no browser client to build (see wasm-ladder.yml's comment)" + fi +``` + +and the error line's text `no ladder__gui_wasm target was built` with `no rung's browser client was built`. +In that step's leading comment, replace "every rung that has a gui_wasm/ directory" with "every rung that has a +browser client — `ui/main.cpp` (target ``, built with Qt Quick only) or, before it migrates, `gui_wasm/` +(target `ladder__gui_wasm`)". + +In `.github/workflows/ci.yml`, first run `grep -n 'MORPH_BUILD_TUI\|MORPH_BUILD_QT_QUICK' .github/workflows/ci.yml`: +Part 3 adds `-DMORPH_BUILD_TUI=ON` to `linux-sanitizers`, `linux-all-features` and `clang-tidy`, and Part 4 adds +`-DMORPH_BUILD_QT_QUICK=ON` to `linux-coverage`, `ladder-tests`, `ladder-sanitizers`, `linux-all-features` and +`clang-tidy`. The one line missing for this plan is in the `ladder-tests` job's "Configure (gcc-debug, ladder + Qt + +bank on)" step: add `-DMORPH_BUILD_TUI=ON \` directly after `-DMORPH_BUILD_QT_QUICK=ON \` there. Without it a +rung's terminal smoke case is skipped in CI (spec 4 §6). Edit no other job. + +In `scripts/coverage.sh`, change `for _sub in include src gui_lib; do` to `for _sub in include src gui_lib app; do` +and replace the comment above that loop's `# Only rungs whose test binary…` paragraph — the lines from +`# include/ + src/ are each rung's DTOs` through `# exercised only by the offscreen QML smoke test and by hand.` +— with: + +```bash +# include/ + src/ are each rung's DTOs and models (rule 5's own 100% bar); +# app/ is a migrated rung's controllers and views, and gui_lib/ an unmigrated +# rung's presenters and QML adapters — both held to the same bar, since both +# are real coverage of morph's client stack. A rung's main() shells (ui/, and +# gui/ and gui_wasm/ before it migrates) are deliberately absent: they read +# argv and pick a frontend, with no unit-testable seam, and are exercised by +# the frontend smoke tests and by hand. +``` + +In `codecov.yml`, replace the two `ignore:` entries `"examples/pastebin/gui/**"` and +`"examples/pastebin/gui_wasm/**"` with the one entry `"examples/pastebin/ui/**"`, and replace the pastebin +component's comment — from ` # Rung 1, pastebin.` down to the line before ` - component_id: pastebin` — with: + +```yaml + # Rung 1, pastebin. + # + # What is actually measured, precisely — the `paths` glob below is + # `examples/pastebin/**`, but a component can only score files the + # uploaded report contains, and that report is whatever + # `scripts/coverage.sh` names in its `SOURCES` array. For this rung that + # is `include/`, `src/` and `app/`: the DTOs, the model, the server's + # App, and the client's controller and views. It is **not** `ui/` — the + # `main()` shell (argv, frontend selection) has no unit-testable seam, + # is exercised by the frontend smoke tests and by hand, and is named in + # `ignore:` below so its absence is a decision rather than an accident. + # `tests/` is excluded for the same reason examples/common's is: a suite + # scoring its own test code inflates the number it is supposed to police. + # + # Every line `llvm-cov report` finds missed in `include/` and `src/` is + # accounted for: + # * units.hpp (2) — the `default:` arm of `UnitTraits::meta`'s + # switch. `Unit` has exactly one enumerator, so that arm is + # unreachable without undefined behavior; it exists because the + # repo's warning policy requires a switch default. + # * src/server/app/app.cpp (4) — `sweepExpiredOnce()`'s `.onError` + # branch, which logs an `ExpirePaste` that failed to dispatch. + # Provoking a dispatch failure through a `SimulatedRemoteBackend` + # needs a fault-injection seam this rung's tests do not use. + # * src/models/paste_model.cpp (2 or 3) — the `rows.empty()` guard in + # `execute(GetPaste)`'s read-back, unreachable in practice, plus one + # line that depends on which classifier branch the two + # `DbBusyFixture` store-error cases land in: they race a real SQLite + # lock. + # `app/` is measured by `tests/test_paste_controller.cpp`, + # `tests/test_paste_view.cpp` and `tests/smoke/test_pastebin_frontends.cpp`. + # + # The target is informational and sits below the measured ceiling, so the + # run-to-run line above cannot flip it. +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +```bash +cmake --build build/all --target ladder_pastebin_tests ladder_pastebin_smoke_tests pastebin ladder_pastebin_server +QT_QPA_PLATFORM=offscreen ./build/all/examples/pastebin/ladder_pastebin_tests "[pastebin]" +ctest --test-dir build/all -R '^pastebin\.smoke\.' --output-on-failure +ls examples/pastebin # CMakeLists.txt README.md app include src tests ui +``` + +Expected: PASS; the `ctest` line runs both frontend cases (this configure builds both frontends). Mutation +check: change `PastebinApplication::view()`'s body to `throw std::logic_error{"no view"};` (add +`#include `). Expected FAIL in both `pastebin.smoke.` cases — the frontend cannot mount, so its run does +not end with exit code 0. Restore. + +- [ ] **Step 7: Commit** + +```bash +git add -A examples/pastebin .github/workflows/wasm-ladder.yml .github/workflows/ci.yml scripts/coverage.sh codecov.yml +git commit -m "wip(pastebin): the application, one binary for every frontend, the QML client removed + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 7: pastebin's README and the Getting Started walkthrough + +**Files:** +- Modify: `examples/pastebin/README.md` — the intro, "Running it", the `CallbackScope` bullet, the paragraph after + it, three Definition-of-done bullets, two Known-gaps bullets, the `../bank/gui_wasm` link +- Modify: `docs/GETTING-STARTED.md` — §3's commands and note, §9, §10's opening, §11's last paragraphs, §12's + "nothing changes" and "what differs" passages + +**Interfaces:** none. + +- [ ] **Step 1: Check every path the two documents name still exists** + +```bash +grep -n 'gui_lib\|gui_wasm\|ladder_pastebin_gui\|gui/main\.cpp\|gui/qml\|paste_qml_bridges\|paste_presenter\|paste_schemas\|test_gui_qml_smoke' \ + examples/pastebin/README.md docs/GETTING-STARTED.md +``` + +Expected before the edit: the references the steps below replace (README lines 30–35, 91, 212–238, 309–331, +400–406; GETTING-STARTED §3, §9, §10). After Step 3 the command prints nothing. + +- [ ] **Step 2: Rewrite pastebin's README** + +In the intro paragraph replace "one entity, one model, SQLite, Qt WASM client." with "one entity, one model, +SQLite, and one client that runs in a terminal or as a Qt Quick window, natively or in a browser." + +Replace the "Running it" code block and the paragraph after it (up to, not including, "**Scope note**") with: + +````markdown +```bash +# One-time configure (Qt 6.5+ and an ODBC SQLite3 driver; the client needs at +# least one frontend, the terminal UI or Qt Quick): +cmake -S . -B build -G Ninja \ + -DMORPH_BUILD_QT=ON -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT_QUICK=ON \ + -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=pastebin + +# Server (owns the database, the action journal and the expiry sweep): +PASTEBIN_DB="DRIVER=SQLite3;Database=pastebin.db;Timeout=5000" \ +PASTEBIN_PORT=8765 ./build/examples/pastebin/ladder_pastebin_server + +# The client, either deployment mode, on whichever frontend suits: +./build/examples/pastebin/pastebin # in-process (--db, else PASTEBIN_DB) +./build/examples/pastebin/pastebin --server ws://127.0.0.1:8765 +./build/examples/pastebin/pastebin --ui=tui # force the terminal UI +``` + +`--ui=` (or `MORPH_UI`) picks the frontend; without either, a Qt Quick window opens where there is a display +and the terminal UI otherwise. The browser client is the same `ui/main.cpp` built in an Emscripten configure +with Qt Quick only — which additionally needs `-DMORPH_CLIENT_ONLY=ON`, since it names its model type but must +not link the model's ODBC-backed bodies (`docs/spec/core/registry.md`). It is always remote, against the url +baked in via `-DMORPH_LADDER_PASTEBIN_WASM_SERVER_URL=ws://host:port`. The configure line CI uses is +`.github/workflows/wasm-ladder.yml`. +```` + +Replace "(follow [`../bank/gui_wasm`](../bank/gui_wasm))" with "(follow [`../bank`](../bank))". + +Replace the `**morph::async::CallbackScope**` bullet and the paragraph after it ("Pastebin's GUI renders +exclusively …" through "presenter rule requires.") with: + +```markdown +- **`morph::async::CallbackScope`**, through `morph::reactive`: every reply the client waits for is delivered + to a `Query` or `Mutation` (the create form's submission included) whose last-declared `CallbackScope` + refuses it once the node is gone. `tests/test_paste_controller.cpp`'s "a controller destroyed with a create + in flight drops its reply" drives the window (destroy the controller mid-submit, let the reply land) and + asserts the observable half — the create still happened. The suppression itself has no observable signature + outside a sanitized build, which the case states rather than dresses up. + +The client is toolkit-free (`app/`, target `ladder_pastebin_app`): `PasteController` owns the listing (a +`Query`), opening a paste (a `Mutation` — never a query, because `GetPaste` consumes one +of the paste's reads and must not be refetched behind the user's back), deleting it, and the create form, a +typed `forms::Form` rendered by `forms::formView` from `schemaJson()`. Opening and +deleting both invalidate the listing, so a burn shows without a manual refresh. `views/paste_view.cpp` binds a +view tree to it and decides nothing; `ui/main.cpp` picks the frontend. +``` + +In "Definition of done", replace the bullet "**Desktop client against local and remote backends.**" (through +"smoke test in the suite.") with: + +```markdown +- [x] **Client against local and remote backends.** `pastebin` in both modes, on the terminal UI and on Qt + Quick, driven manually against a real `ladder_pastebin_server` (create → list → open → burn → delete), and + by `tests/smoke/test_pastebin_frontends.cpp`, which mounts the application on each built frontend and quits. +``` + +replace the bullet "**WASM client, same client code.**" (through "the compile gate that will settle it.") with: + +```markdown +- [~] **WASM client, same client code.** The browser client is `ui/main.cpp` itself, built with Qt Quick only: + two `__EMSCRIPTEN__` branches — the server url in `ui/main.cpp`, no database setup in + `app/pastebin_application.cpp` — and nothing else differs: no shadow headers, no WASM variant of any controller, + view, model or DTO ([`../TESTING.md`](../TESTING.md)'s hard requirement). `.github/workflows/wasm-ladder.yml` builds target `pastebin` in its Emscripten configure; + no Emscripten toolchain was available where this client was written, so that job is the first compile. +``` + +and replace the bullet "**Presenter-shaped GUI.**" (two lines) with: + +```markdown +- [x] **Toolkit-free client.** `ladder_pastebin_app` links morph and `ladder_pastebin_lib` only, and the domain + library links no Qt (the server's `App` lives in `ladder_pastebin_server_app`); `PasteController` is tested + in all three backend modes, and the screen on `RecordingBackend`. +``` + +In "Known gaps", replace the bullet "**`ladder-tests` still builds no GUI.**" with: + +```markdown +- **The browser client is compile-gated, not run.** `wasm-ladder.yml` builds it; nothing in CI loads it in a + browser against a server. +``` + +and in the bullet "**Registration timing.**" replace "Both clients' `Main.qml` request the first listing on +`Component.onCompleted`." with "The controller requests the first listing when it is constructed." and "and the +list pane empty with no terminal error." with "and the listing on its busy indicator with no terminal error." + +Add, directly before "### Known gaps, stated rather than smoothed over", a section: + +```markdown +### What the client shows differently from the QML client it replaced + +- The listing is a table (paste, syntax, visibility, created) rather than a one-line list. Selecting a row only + highlights it; **activating** it (Enter, a double click) opens it — selecting used to open, and every open + consumes a read. +- The create form is the forms engine's rendering of `CreatePaste`'s schema: the burn budget is a number field, + the expiry a date-time field, visibility and editability drop-downs. Its Submit button reads "Create paste". +- The status line keeps the last open, delete or create outcome; it no longer clears on every listing. +- In a terminal the paste body is a scrolling text block; there is no text selection for copying. +``` + +- [ ] **Step 3: Rewrite the Getting Started walkthrough's pastebin sections** + +In the replacements below and in Step 2's, a `\n` inside a quoted original marks a line break in the file. + +In `docs/GETTING-STARTED.md`'s contents list replace `[9. A GUI on top](#9-a-gui-on-top)` with +`[9. A UI on top](#9-a-ui-on-top)`; in §3's first paragraph replace "a Qt/QML client" with "one client that runs +in a terminal or as a Qt Quick window"; in §4 replace "Nothing domain-shaped may live in a presenter, in QML, or +in `main()`." with "Nothing domain-shaped may live in a controller, in a view, or in `main()`." + +In §3, replace the configure/build block with: + +```sh +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DMORPH_BUILD_QT=ON -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT_QUICK=ON \ + -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=pastebin \ + -DMORPH_BUILD_TESTS=ON -DMORPH_BUILD_EXAMPLES=ON +cmake --build build --target ladder_pastebin_tests ladder_pastebin_server pastebin +``` + +replace "100% tests passed out of 55" with "100% tests passed", replace "The desktop client runs in either +deployment mode." and its two command blocks with: + +````markdown +The client runs in either deployment mode, in a terminal or as a Qt Quick window. Against the server: + +```sh +./build/examples/pastebin/pastebin --server ws://127.0.0.1:8765 +``` + +or entirely in-process, hosting `PasteModel` itself: + +```sh +PASTEBIN_DB="DRIVER=SQLite3;Database=local.db;Timeout=5000" \ + ./build/examples/pastebin/pastebin --ui=tui +``` +```` + +and in the note below them replace "[`gui/main.cpp:53-73`](../examples/pastebin/gui/main.cpp) explains that" with +"[`app/pastebin_application.cpp`](../examples/pastebin/app/pastebin_application.cpp)'s `localSetup()` is the only +place a client touches a database, and". + +Replace §9 ("## 9. A GUI on top" through the paragraph ending "calls `refresh()` straight away.") with: + +````markdown +## 9. A UI on top + +pastebin's client is three small layers, none of which names a toolkit. + +**The controller** says what the screen depends on and what each write invalidates +([`app/controllers/paste_controller.cpp`](../examples/pastebin/app/controllers/paste_controller.cpp)): + +```cpp +_list{runtime, _handler, [] { return std::optional{ListPastes{}}; }}, +_open{runtime, _handler, MutationOptions{.invalidates = {&_list}}}, +_delete{runtime, _handler, MutationOptions{.invalidates = {&_list}}}, +``` + +The listing is a `Query`: fetched on construction, kept while a refetch is in flight, its stale replies +dropped. Opening is a `Mutation`, not a query, and that is a domain decision: `GetPaste` consumes one of the +paste's reads, so it may only be issued when the user asks — never refetched. Each success invalidates the +listing, which is how a burned paste disappears without anyone calling `refresh()`. Formatting lives here too, +in `Computed`s (`rows()`, `facts()`), so it is tested without a screen. + +**The view** is bindings only +([`app/views/paste_view.cpp`](../examples/pastebin/app/views/paste_view.cpp)): + +```cpp +ui::button({.label = "Delete this paste", + .onClick = [&controller] { controller.remove(); }, + .common = {.enabled = [&controller] { return controller.canDelete(); }}}) +``` + +A lambda is a binding: the button re-enables itself when `canDelete()` changes, and nothing else is redrawn. + +**The composition root** picks the frontend and the transport +([`ui/main.cpp`](../examples/pastebin/ui/main.cpp)): + +```cpp +auto const frontend = morph::ui::selectFrontend(built, argc, argv); +return frontend->run( + [&env](morph::ui::AppContext& ctx) { return pastebin::client::makeApplication(ctx, env); }); +``` + +`makeApplication` builds the bridge inside the frontend's context (`examples::connect`: a `LocalBackend` over the +database `--db` names, or the frontend's socket transport for `--server`) and constructs the controller over it. A handler's +registration round trip settles asynchronously, and nothing has to wait for it: a call made through a handler +whose bind is still in flight is held and dispatched when the bind settles, so the listing query fires straight +away. +```` + +In §10 replace the two lines "pastebin's create form renders from one line" and +"([`gui_lib/paste_schemas.hpp:31`](…)):", and the code block after them, with: + +````markdown +pastebin's create form is one member of its controller: + +```cpp +::morph::forms::Form _create; +``` + +`Form` builds its field model from `schemaJson()`, and `forms::formView` renders it on whichever frontend +is running. +```` + +In §11 append, after the paragraph ending "settles. Section 12 shows what that combination is for.": + +```markdown +The client is tested the same way, one layer up: a `PasteController` over a `BackendRig` client, its reactive +runtime on the rig's executor, asserted through its accessors +([`tests/test_paste_controller.cpp`](../examples/pastebin/tests/test_paste_controller.cpp)); the screen is +mounted on `ui::testing::RecordingBackend`, a headless widget tree a test can click +([`tests/test_paste_view.cpp`](../examples/pastebin/tests/test_paste_view.cpp)). +``` + +In §12 replace "the same\n`PasteBridge`, the same `PastePresenter`, the same `BridgeHandler`," with +"the same\n`PasteController`, the same `BridgeHandler`," and replace "Build\n your handlers inside a +readiness callback (section 9); a first fetch made\n before the handler's bind settles is held until it does." +with "A first\n fetch made before the handler's bind settles is held until it does (section 9)." + +- [ ] **Step 4: Verify** + +Re-run Step 1's `grep`. Expected: no output. Then +`npx markdownlint-cli2 examples/pastebin/README.md docs/GETTING-STARTED.md` +(or `pre-commit run markdownlint --files …`). Expected: no findings. + +- [ ] **Step 5: Commit** + +```bash +git add examples/pastebin/README.md docs/GETTING-STARTED.md +git commit -m "wip(pastebin): README and Getting Started describe the new client + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 8: Verify pastebin, and squash its group + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: The rung's whole suite, and the shared testkit's** + +```bash +cmake --build build/all +ctest --test-dir build/all -L 'ladder-pastebin|ladder-0' --output-on-failure +``` + +Expected: every test passes. + +- [ ] **Step 2: Prove the client is toolkit-free from the build's own command lines** + +```bash +grep -rnE '#include .dir/`): `ninja -t +commands` also lists its dependencies' commands, and a dependency that compiles Qt glue is not the claim here. + +- [ ] **Step 3: The model tests are untouched** + +```bash +git diff --exit-code master -- examples/pastebin/tests/test_paste_model.cpp examples/pastebin/tests/.clang-tidy +``` + +Expected: exit 0. + +- [ ] **Step 4: Run the application by hand** + +```bash +PASTEBIN_PORT=8765 ./build/all/examples/pastebin/ladder_pastebin_server --seed & +./build/all/examples/pastebin/pastebin --server ws://127.0.0.1:8765 --ui=tui +./build/all/examples/pastebin/pastebin --server ws://127.0.0.1:8765 --ui=qt +PASTEBIN_DB="DRIVER=SQLite3;Database=/tmp/pastebin-local.db;Timeout=5000" ./build/all/examples/pastebin/pastebin --ui=tui +kill %1 +``` + +On each: create a paste, see it listed, open the seeded burn-after-1 paste and watch it leave the listing, +delete a paste. Record which frontend/mode combinations were exercised in the squash body; this is the step a +unit test cannot replace. + +- [ ] **Step 5: Sanitizer** (Linux; on macOS an ASan configure of the same tree) + +```bash +cmake --preset clang-asan -DMORPH_BUILD_QT=ON -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT_QUICK=ON \ + -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=pastebin +cmake --build build/clang-asan --target ladder_pastebin_tests +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-asan/examples/pastebin/ladder_pastebin_tests asan +QT_QPA_PLATFORM=offscreen ./build/clang-asan/examples/pastebin/ladder_pastebin_tests "[pastebin][controller],[pastebin][view]" +``` + +Expected: the instrumentation check passes and the run is clean; then perform Task 4's second mutation check +here and see it fail, and restore. + +- [ ] **Step 6: clang-tidy over the changed lines** — CONTRIBUTING's recipe with `origin/master...HEAD`, the file + count printed and asserted non-zero. Expected: no findings in `examples/pastebin/app/`, `ui/`, `tests/`, + `examples/common/app/form_success.hpp`. + +- [ ] **Step 7: Commit any fixes** + +```bash +git add -A examples/pastebin examples/common cmake +git commit -m "wip(pastebin): fixes from the verification gates + +Signed-off-by: Christian Parpart " +``` + +Skip the commit if nothing needed fixing, and say so in the hand-off. + +- [ ] **Step 8: Squash the group** + +Follow the master plan's "Squashing a part" procedure with `key=pastebin` and this message (add a line for any +deviation from this plan, per the master plan's "The docs commit"): + +```text +examples/pastebin: one app, any frontend + +pastebin's client is a toolkit-free app library — PasteController (a +ListPastes query; an explicit GetPaste mutation, never refetched because a +read consumes the paste's budget; a delete; a typed Form) and +its view — plus one binary, ui/main.cpp, on the TUI or Qt Quick, native or +in the browser. Its domain library no longer links Qt: morph_add_rung gains +ladder__server_app for a server's QObject App. examples/common gains +FormSuccess. The QML client and its tests are gone, their claims re-expressed. + +Signed-off-by: Christian Parpart +``` + +The last line of the procedure must show the history so far ending in `examples/pastebin: one app, any frontend`. + +--- +## Group 2 — bookmarks (key `bookmarks`) + +### Task 9: bookmarks' `App` into the server, display helpers, and the browser build's sources + +**Files:** +- Move: `examples/bookmarks/include/bookmarks/app/{app,metadata_fetcher}.hpp` → + `examples/bookmarks/src/server/include/bookmarks/app/` +- Move: `examples/bookmarks/src/app/app.cpp` → `examples/bookmarks/src/server/app/app.cpp` +- Modify: `examples/bookmarks/CMakeLists.txt` — whole file +- Create: `examples/bookmarks/app/controllers/bookmark_text.hpp`, + `examples/bookmarks/app/controllers/bookmark_text.cpp` +- Test: `examples/bookmarks/tests/test_bookmark_text.cpp`; the rung's existing `tests/test_app.cpp` (it constructs + `bookmarks::app::App`) + +**Interfaces:** +- Consumes: Task 1's `ladder__server_app` convention; bookmarks' DTOs (`bookmarks/dto/*.hpp`), `Count` + (`bookmarks/units.hpp`); `morph::units::toString`; `reactive::errorMessage`. +- Produces: `bookmarks::client::{StatusLine, isoOrEmpty, countText, visibilityText, readStateText, + archiveStateText, rowTitle, headline, joined, failureLine, infoLine}`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/bookmarks/tests/test_bookmark_text.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "bookmarks/units.hpp" +#include "controllers/bookmark_text.hpp" + +using namespace bookmarks::client; + +TEST_CASE("bookmark_text renders every enum arm and both title shapes", "[bookmarks][controller]") { + CHECK(visibilityText(bookmarks::Visibility::Private) == "Private"); + CHECK(visibilityText(bookmarks::Visibility::Shared) == "Shared"); + CHECK(readStateText(bookmarks::ReadState::Unread) == "Unread"); + CHECK(readStateText(bookmarks::ReadState::Read) == "Read"); + CHECK(archiveStateText(bookmarks::ArchiveState::Active) == "Active"); + CHECK(archiveStateText(bookmarks::ArchiveState::Archived) == "Archived"); + CHECK(rowTitle("", "https://a.example") == "https://a.example"); + CHECK(rowTitle("A", "https://a.example") == "A · https://a.example"); + CHECK(headline("", "https://a.example") == "https://a.example"); + CHECK(headline("A", "https://a.example") == "A"); + CHECK(joined({"work", "home"}, ", ") == "work, home"); + CHECK(joined({}, ", ").empty()); + CHECK(countText(bookmarks::Count::fromDouble(2.0)) == "2"); + CHECK(isoOrEmpty(::morph::time::Timestamp{}).empty()); + CHECK(failureLine(std::make_exception_ptr(std::runtime_error{"no"})) == StatusLine{.text = "no", .isError = true}); + CHECK(infoLine("ok") == StatusLine{.text = "ok", .isError = false}); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_bookmarks_tests` +Expected: FAIL — `'controllers/bookmark_text.hpp' file not found`. + +- [ ] **Step 3: Move the `App`, rewrite the rung's CMake, add the helpers** + +```bash +mkdir -p examples/bookmarks/src/server/include/bookmarks/app examples/bookmarks/src/server/app +git mv examples/bookmarks/include/bookmarks/app/app.hpp examples/bookmarks/src/server/include/bookmarks/app/app.hpp +git mv examples/bookmarks/include/bookmarks/app/metadata_fetcher.hpp \ + examples/bookmarks/src/server/include/bookmarks/app/metadata_fetcher.hpp +git mv examples/bookmarks/src/app/app.cpp examples/bookmarks/src/server/app/app.cpp +``` + +In the moved `app.hpp` replace + +```cpp +/// metadata-fetch worker, and the periodic outbox relay. Nothing here decides +/// deployment mode — that stays `examples/common/gui::AppContext`'s job on +/// the client side; this is exclusively the server side. +``` + +with + +```cpp +/// metadata-fetch worker, and the periodic outbox relay. Nothing here decides +/// deployment mode — that is the client's (`examples::connect`); this is +/// exclusively the server side, built into `ladder_bookmarks_server_app`. +``` + +Replace `examples/bookmarks/CMakeLists.txt` with: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# +# bookmarks — rung 2 of the application ladder (examples/bookmarks/README.md). +# morph_add_rung() wires the standard targets (cmake/morph_add_rung.cmake); this +# file adds the sources its directory conventions do not cover, then the +# browser build's server url. + +cmake_minimum_required(VERSION 3.25) + +morph_add_rung(NAME bookmarks) + +# The Netscape importer (src/import/) and Login's validation (src/dto/) sit +# outside the src/models, src/db and src/app globs. +if(TARGET ladder_bookmarks_lib) + target_sources(ladder_bookmarks_lib PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src/import/netscape_bookmarks.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/dto/auth_dto.cpp") +endif() + +# The browser build has no ladder_bookmarks_lib (no ODBC in a browser), yet its +# client still names Login, whose validate() lives in src/dto/auth_dto.cpp — a +# file with no persistence dependency, so the client library compiles it there. +if(TARGET ladder_bookmarks_app AND NOT TARGET ladder_bookmarks_lib) + target_sources(ladder_bookmarks_app PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/dto/auth_dto.cpp") +endif() + +# ── The browser build's server url ────────────────────────────────────────── +# A page served from a static bundle has no argv to read --server from. +if(EMSCRIPTEN AND TARGET bookmarks) + if(NOT DEFINED MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL) + set(MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL "ws://127.0.0.1:8766" CACHE STRING + "URL bookmarks' browser build connects to; must be a reachable ladder_bookmarks_server.") + endif() + target_compile_definitions(bookmarks PRIVATE + MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL="${MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL}" + ) +endif() +``` + +Create `examples/bookmarks/app/controllers/bookmark_text.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include "bookmarks/dto/bookmark_dto.hpp" +#include "bookmarks/units.hpp" + +/// @file +/// The text every bookmarks controller shows: one place for each word and format, so the panes agree. + +namespace bookmarks::client { + +/// @brief A pane's status line: the last outcome it reports. +struct StatusLine { + std::string text; ///< Empty when there is nothing to report. + bool isError = false; ///< Whether `text` reports a failure. + + /// @brief Field-wise equality. + bool operator==(StatusLine const&) const = default; +}; + +/// @brief An instant as ISO-8601, or empty when unset. +/// @param instant The instant. +/// @return Its text. +[[nodiscard]] std::string isoOrEmpty(::morph::time::Timestamp const& instant); + +/// @brief A count as text (`morph::units::toString`). +/// @param count The count. +/// @return Its text. +[[nodiscard]] std::string countText(Count const& count); + +/// @brief "Private" or "Shared". +/// @param visibility The visibility. +/// @return Its word. +[[nodiscard]] std::string visibilityText(Visibility visibility); + +/// @brief "Unread" or "Read". +/// @param state The read state. +/// @return Its word. +[[nodiscard]] std::string readStateText(ReadState state); + +/// @brief "Active" or "Archived". +/// @param state The archive state. +/// @return Its word. +[[nodiscard]] std::string archiveStateText(ArchiveState state); + +/// @brief A listing row's title: "title · url", or the url alone while the title is unknown. +/// @param title The bookmark's title. +/// @param url Its url. +/// @return The row title. +[[nodiscard]] std::string rowTitle(std::string const& title, std::string const& url); + +/// @brief A detail pane's headline: the title, or the url while the title is unknown. +/// @param title The bookmark's title. +/// @param url Its url. +/// @return The headline. +[[nodiscard]] std::string headline(std::string const& title, std::string const& url); + +/// @brief @p parts joined by @p separator. +/// @param parts The parts. +/// @param separator Between two parts. +/// @return The joined text. +[[nodiscard]] std::string joined(std::vector const& parts, std::string_view separator); + +/// @brief A failure as a status line. +/// @param error The failure; must not be null. +/// @return `errorMessage(error)`, marked as an error. +[[nodiscard]] StatusLine failureLine(std::exception_ptr const& error); + +/// @brief An outcome as a status line. +/// @param text What happened. +/// @return @p text, not an error. +[[nodiscard]] StatusLine infoLine(std::string text); + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/app/controllers/bookmark_text.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/bookmark_text.hpp" + +#include +#include +#include + +namespace bookmarks::client { + +std::string isoOrEmpty(::morph::time::Timestamp const& instant) { + return instant.hasValue() ? (*instant).toIso8601() : std::string{}; +} + +std::string countText(Count const& count) { return ::morph::units::toString(count); } + +std::string visibilityText(Visibility visibility) { return visibility == Visibility::Shared ? "Shared" : "Private"; } + +std::string readStateText(ReadState state) { return state == ReadState::Read ? "Read" : "Unread"; } + +std::string archiveStateText(ArchiveState state) { return state == ArchiveState::Archived ? "Archived" : "Active"; } + +std::string rowTitle(std::string const& title, std::string const& url) { + return title.empty() ? url : title + " · " + url; +} + +std::string headline(std::string const& title, std::string const& url) { return title.empty() ? url : title; } + +std::string joined(std::vector const& parts, std::string_view separator) { + std::string out; + for (std::size_t i = 0; i < parts.size(); ++i) { + if (i != 0) { + out += separator; + } + out += parts[i]; + } + return out; +} + +StatusLine failureLine(std::exception_ptr const& error) { + return StatusLine{.text = ::morph::reactive::errorMessage(error), .isError = true}; +} + +StatusLine infoLine(std::string text) { return StatusLine{.text = std::move(text), .isError = false}; } + +} // namespace bookmarks::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake --build build/all --target ladder_bookmarks_tests ladder_bookmarks_server +QT_QPA_PLATFORM=offscreen ./build/all/examples/bookmarks/ladder_bookmarks_tests "[bookmarks]" +``` + +Expected: PASS, including `test_app.cpp`'s cases (now linked through `ladder_bookmarks_server_app`). Mutation +check: in `rowTitle`, swap the ternary's arms. Expected FAIL in "bookmark_text renders every enum arm and both +title shapes". Restore. + +- [ ] **Step 5: Commit** + +```bash +git add -A examples/bookmarks/src examples/bookmarks/include examples/bookmarks/CMakeLists.txt \ + examples/bookmarks/app examples/bookmarks/tests/test_bookmark_text.cpp +git commit -m "wip(bookmarks): App moved into the server, the client's display helpers + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 10: The six forms' routing, and the sign-in controller + +**Files:** +- Create: `examples/bookmarks/app/controllers/forms_routing.{hpp,cpp}`, + `examples/bookmarks/app/controllers/session_controller.{hpp,cpp}` +- Create: `examples/bookmarks/tests/client_fixture.hpp` +- Test: `examples/bookmarks/tests/test_forms_routing.cpp`, `examples/bookmarks/tests/test_session_controller.cpp` + +**Interfaces:** +- Consumes: `forms::{FormModel::forAction, FormSession, Submitter, bridgeSubmitter, bridgeChoiceFetcher, + SubmitMode}` (Part 5; `bridgeSubmitter` is right here: every bookmarks model keeps its state in the database, so + its own handler per model sees what the screens' handlers see); `examples::mapCompletion` + (`examples/common/app/completion_map.hpp`, Part 6); `async::Completion::makeSettleable`; + `async::CallbackScope::token()`; `bridge::Bridge::setDefaultSession`; `session::Context`; + `bookmarks::{Login, LoginResult, AuthToken}`; `bookmarks::auth::setTokenIssuer`; `session::TokenIssuer(std::string, + …)`, `session::hmacSha256`; `glz::read_json`, `glz::write_json`; `BackendRig`, `awaitQt` (`testkit/pump.hpp`). +- Produces: `bookmarks::client::{kFormActions, formModelFor, decodeLoginResult, formsSubmitter, loginSubmitter, + Route, SessionController}`; `SessionController(reactive::Runtime&, bridge::Bridge&, exec::IExecutor&)` with + `loginForm()`, `principal()`, `signedIn()`, `principalText()`, `route()`; `bookmarks::testing::{kSecret, + authedRig, ScopedTokenIssuer, submitAndWait}`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/bookmarks/tests/client_fixture.hpp` (Tasks 11–14 extend it): + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bookmarks/auth/bookmarks_authorizer.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/pump.hpp" + +/// @file +/// The wiring bookmarks' client tests share: a rig whose bridge carries a signed session, and the waits. + +namespace bookmarks::testing { + +/// @brief The signing secret every test rig shares. +inline constexpr std::string_view kSecret = "bookmarks-client-test-secret"; + +/// @brief A rig with the rung's authorizer (Socket mode) and a signed session for @p principal installed. +/// @param mode The deployment shape. +/// @param principal Who the session belongs to. +/// @return The rig. +[[nodiscard]] inline std::unique_ptr<::morph::ladder::testkit::BackendRig> authedRig( + ::morph::ladder::testkit::Mode mode, std::string principal = "alice") { + auto const authorizer = + std::make_shared(std::string{kSecret}, ::morph::session::hmacSha256); + auto rig = std::make_unique<::morph::ladder::testkit::BackendRig>(mode, 1, authorizer); + ::morph::session::TokenIssuer const issuer{std::string{kSecret}, ::morph::session::hmacSha256}; + ::morph::session::Context ctx; + ctx.principal = std::move(principal); + ctx.token = issuer.issue( + ::morph::session::SessionToken{.principal = ctx.principal, .expiresAtMs = 4102444800000, .roles = {}}); + rig->bridge(0).setDefaultSession(ctx); + return rig; +} + +/// @brief Installs the process-global issuer `AuthModel` mints Login tokens from, for one test. +class ScopedTokenIssuer { +public: + ScopedTokenIssuer() { + auth::setTokenIssuer( + std::make_shared<::morph::session::TokenIssuer>(std::string{kSecret}, ::morph::session::hmacSha256)); + } + ~ScopedTokenIssuer() { auth::setTokenIssuer(nullptr); } + ScopedTokenIssuer(ScopedTokenIssuer const&) = delete; + ScopedTokenIssuer& operator=(ScopedTokenIssuer const&) = delete; + ScopedTokenIssuer(ScopedTokenIssuer&&) = delete; + ScopedTokenIssuer& operator=(ScopedTokenIssuer&&) = delete; +}; + +/// @brief Submits @p form and waits for its reply, success or failure. +/// @param form A form whose body is complete. +inline void submitAndWait(::morph::forms::FormSession& form) { + REQUIRE(form.ready()); + form.submit(); + REQUIRE(form.pending()); + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return !form.pending(); })); +} + +} // namespace bookmarks::testing +``` + +Create `examples/bookmarks/tests/test_forms_routing.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bookmarks/dto/bookmark_dto.hpp" +#include "bookmarks/models/tag_model.hpp" +#include "client_fixture.hpp" +#include "controllers/forms_routing.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +/// @brief The failure message a submission ends with. +[[nodiscard]] std::string refusalOf(morph::async::Completion reply) { + std::exception_ptr failure; + bool settled = false; + reply.thenDetached([&](std::string const&) { settled = true; }).onErrorDetached([&](std::exception_ptr error) { + failure = std::move(error); + settled = true; + }); + REQUIRE(pumpUntil([&] { return settled; })); + REQUIRE(failure != nullptr); + return morph::reactive::errorMessage(failure); +} + +[[nodiscard]] std::int64_t tagIdNamed(bookmarks::ListTagsResult const& tags, std::string const& name) { + for (auto const& tag : tags.tags) { + if (tag.name == name) { + return *tag.id; + } + } + return -1; +} + +} // namespace + +TEST_CASE("decodeLoginResult accepts a real Login reply and rejects anything that is not one", + "[bookmarks][controller]") { + auto const decoded = bookmarks::client::decodeLoginResult(R"({"token":"signed.token.value","principal":"alice"})"); + REQUIRE(decoded.has_value()); + REQUIRE(decoded->token.hasValue()); + CHECK(*decoded->token == "signed.token.value"); + CHECK(decoded->principal == "alice"); + for (char const* body : {"", "not json at all", "[1,2,3]", "null", R"({"token":123,"principal":"alice"})", + R"({"principal":"alice")"}) { + INFO("unexpectedly decoded: " << body); + CHECK_FALSE(bookmarks::client::decodeLoginResult(body).has_value()); + } +} + +TEST_CASE("Every one of the six forms reads its schema and submits explicitly", "[bookmarks][controller]") { + for (std::string_view const actionType : bookmarks::client::kFormActions) { + INFO(actionType); + auto const model = bookmarks::client::formModelFor(actionType); + CHECK(model.actionType() == actionType); + CHECK(model.submitMode() == morph::forms::SubmitMode::Explicit); + } + // A read-only action carries no explicit submit: the six are explicit by declaration, not by default. + auto const listing = morph::forms::FormModel::forAction(); + CHECK(listing.submitMode() == morph::forms::SubmitMode::Automatic); + CHECK_THROWS_AS(bookmarks::client::formModelFor("BulkEdit"), std::invalid_argument); +} + +TEST_CASE("formsSubmitter refuses an action outside the six, naming it, and relays a model's refusal", + "[bookmarks][controller]") { + DbFixture fixture; + auto rig = bookmarks::testing::authedRig(Mode::Local); + auto submit = bookmarks::client::formsSubmitter(rig->bridge(0), *rig->executor()); + for (std::string_view const actionType : {std::string_view{"CreateBookmarks"}, std::string_view{"ListSharedFeed"}}) { + std::string const message = refusalOf(submit(actionType, R"({"url":"https://typo.example"})")); + CHECK(message.find("no model in this client serves action") != std::string::npos); + CHECK(message.find(actionType) != std::string::npos); + } + std::string const refused = refusalOf(submit("CreateBookmark", R"({"url":""})")); + CHECK(refused.find("CreateBookmark") != std::string::npos); +} + +TEST_CASE("loginSubmitter routes each of the six form actions to the model that serves it", + "[bookmarks][controller]") { + DbFixture fixture; + bookmarks::testing::ScopedTokenIssuer const issuer; + BackendRig rig{Mode::Local, 1}; + morph::async::CallbackScope lifetime; + std::string announced; + auto submit = bookmarks::client::loginSubmitter(rig.bridge(0), *rig.executor(), lifetime, + [&](bookmarks::LoginResult const& result) { + announced = result.principal; + }); + + std::string const login = awaitQt(submit("Login", R"({"username":"alice"})")); + CHECK(announced == "alice"); + CHECK(login.find("\"principal\"") != std::string::npos); + + std::string const created = awaitQt(submit("CreateBookmark", R"({"url":"https://route.example","tags":["work","home"]})")); + auto const createdResult = glz::read_json(created); + REQUIRE(createdResult.has_value()); + std::int64_t const bookmarkId = *createdResult->id; + + static_cast(awaitQt(submit( + "EditBookmark", R"({"id":)" + std::to_string(bookmarkId) + R"(,"url":"https://edited.example","title":"Edited"})"))); + std::string const imported = awaitQt(submit( + "ImportBookmarks", + R"({"chunk":"
Imported","opId":"import-op-1"})")); + CHECK(imported.find("\"imported\"") != std::string::npos); + + auto tagHandler = rig.client(0); + auto const before = awaitQt(tagHandler.execute(bookmarks::ListTags{})); + std::int64_t const workId = tagIdNamed(before, "work"); + std::int64_t const homeId = tagIdNamed(before, "home"); + REQUIRE(workId > 0); + REQUIRE(homeId > 0); + static_cast(awaitQt(submit("RenameTag", R"({"id":)" + std::to_string(workId) + R"(,"name":"office"})"))); + static_cast(awaitQt(submit("MergeTags", R"({"sourceId":)" + std::to_string(homeId) + + R"(,"targetId":)" + std::to_string(workId) + "}"))); + auto const after = awaitQt(tagHandler.execute(bookmarks::ListTags{})); + CHECK(after.tags.size() == 1); + CHECK(tagIdNamed(after, "office") == workId); +} +``` + +Create `examples/bookmarks/tests/test_session_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "bookmarks/models/bookmark_model.hpp" +#include "client_fixture.hpp" +#include "controllers/forms_routing.hpp" +#include "controllers/session_controller.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +} // namespace + +TEST_CASE("A Login reply is redacted, and the session is installed before it shows", "[bookmarks][controller]") { + DbFixture fixture; + bookmarks::testing::ScopedTokenIssuer const issuer; + BackendRig rig{Mode::Local, 1}; + morph::reactive::Runtime runtime{*rig.executor()}; + bookmarks::client::SessionController session{runtime, rig.bridge(0), *rig.executor()}; + CHECK_FALSE(session.signedIn()); + CHECK(session.route() == bookmarks::client::Route::SignIn); + CHECK(session.principalText() == "not signed in"); + + // Records who was signed in at the moment the reply became visible to a view. + std::optional principalWhenShown; + morph::reactive::Effect const watch{runtime, [&] { + if (session.loginForm().lastReply().has_value()) { + principalWhenShown = session.principal(); + } + }}; + + session.loginForm().prefill(R"({"username":"alice"})"); + bookmarks::testing::submitAndWait(session.loginForm()); + REQUIRE(pumpUntil([&] { return principalWhenShown.has_value(); })); + CHECK(*principalWhenShown == "alice"); + CHECK(session.signedIn()); + CHECK(session.route() == bookmarks::client::Route::Library); + CHECK(session.principalText() == "signed in as alice"); + + auto const& reply = session.loginForm().lastReply(); + REQUIRE(reply.has_value()); + auto const decoded = bookmarks::client::decodeLoginResult(*reply); + REQUIRE(decoded.has_value()); + CHECK(decoded->principal == "alice"); + CHECK_FALSE(decoded->token.hasValue()); + + // The bridge now carries the session: an action that needs a principal succeeds through a handler + // the login never touched. + auto bookmarkHandler = rig.client(0); + auto const listed = morph::ladder::testkit::awaitQt(bookmarkHandler.execute(bookmarks::ListBookmarks{})); + CHECK(listed.bookmarks.empty()); +} + +TEST_CASE("A refused login shows the model's message and stays signed out", "[bookmarks][controller]") { + DbFixture fixture; + bookmarks::testing::ScopedTokenIssuer const issuer; + BackendRig rig{Mode::Local, 1}; + morph::reactive::Runtime runtime{*rig.executor()}; + bookmarks::client::SessionController session{runtime, rig.bridge(0), *rig.executor()}; + + session.loginForm().prefill(R"({"username":"not a principal!"})"); + bookmarks::testing::submitAndWait(session.loginForm()); + CHECK(session.loginForm().lastError() != nullptr); + CHECK_FALSE(session.signedIn()); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/all --target ladder_bookmarks_tests` +Expected: FAIL — `'controllers/forms_routing.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bookmarks/app/controllers/forms_routing.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bookmarks/dto/auth_dto.hpp" + +/// @file +/// How bookmarks' six forms reach their models: one submitter that routes Login, CreateBookmark, +/// EditBookmark, ImportBookmarks, RenameTag and MergeTags across AuthModel, BookmarkModel and TagModel, and +/// the Login wrapper that installs the session a successful login returns. + +namespace bookmarks::client { + +/// @brief The six action types a bookmarks form submits: the ones a user types. Every other action is +/// parameterised by a row the user picked, and goes through a controller's `Mutation`. +inline constexpr std::array kFormActions{"Login", "CreateBookmark", "EditBookmark", + "ImportBookmarks", "RenameTag", "MergeTags"}; + +/// @brief The runtime form model of one of `kFormActions`: `FormModel::forAction()` of its action type. +/// @param actionType One of `kFormActions`. +/// @return The model. +/// @throws std::invalid_argument for any other action type. +[[nodiscard]] ::morph::forms::FormModel formModelFor(std::string_view actionType); + +/// @brief Reads a `Login` reply. +/// @param resultJson The reply text. +/// @return The result, or `nullopt` when the text is not one. +[[nodiscard]] std::optional decodeLoginResult(std::string const& resultJson); + +/// @brief Submits `kFormActions` through the bridge's registry and refuses every other action type with +/// "no model in this client serves action ''", so a misrouted form fails loudly. +/// @param bridge The client's bridge. +/// @param callbacks Where replies are delivered. +/// @return The submitter. +[[nodiscard]] ::morph::forms::Submitter formsSubmitter(::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks); + +/// @brief `formsSubmitter`, plus what a successful `Login` needs: its token installed as the bridge's +/// default session and @p onLogin told, both **before** the reply resolves — so by the time a view +/// sees the reply, every later call already carries the session. The reply a form keeps is +/// re-encoded without the token: a bearer credential has no reason to reach anything displayable. +/// @param bridge The client's bridge. Borrowed: it must outlive the submitter. +/// @param callbacks Where replies are delivered. +/// @param lifetime Gates the Login continuation; its owner's last member. Borrowed. +/// @param onLogin Told the result of each successful login. +/// @return The submitter. +[[nodiscard]] ::morph::forms::Submitter loginSubmitter(::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks, + ::morph::async::CallbackScope const& lifetime, + std::function onLogin); + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/app/controllers/forms_routing.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/forms_routing.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "app/completion_map.hpp" +#include "bookmarks/models/auth_model.hpp" +#include "bookmarks/models/bookmark_model.hpp" +#include "bookmarks/models/tag_model.hpp" + +namespace bookmarks::client { + +namespace { + +using Reply = ::morph::async::Completion; +using ::morph::forms::FormModel; + +[[nodiscard]] Reply refused(::morph::exec::IExecutor* deliver, std::string message) { + auto [completion, promise] = Reply::makeSettleable(deliver); + promise.reject(std::make_exception_ptr(std::runtime_error{std::move(message)})); + return std::move(completion); +} + +} // namespace + +::morph::forms::FormModel formModelFor(std::string_view actionType) { + if (actionType == "Login") { + return FormModel::forAction(); + } + if (actionType == "CreateBookmark") { + return FormModel::forAction(); + } + if (actionType == "EditBookmark") { + return FormModel::forAction(); + } + if (actionType == "ImportBookmarks") { + return FormModel::forAction(); + } + if (actionType == "RenameTag") { + return FormModel::forAction(); + } + if (actionType == "MergeTags") { + return FormModel::forAction(); + } + throw std::invalid_argument{"bookmarks: no form for action '" + std::string{actionType} + "'"}; +} + +std::optional decodeLoginResult(std::string const& resultJson) { + LoginResult result; + if (glz::read_json(result, resultJson)) { + return std::nullopt; + } + return result; +} + +::morph::forms::Submitter formsSubmitter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor& callbacks) { + return [routed = ::morph::forms::bridgeSubmitter(bridge, callbacks), deliver = &callbacks]( + std::string_view actionType, std::string body) -> Reply { + if (std::ranges::find(kFormActions, actionType) == kFormActions.end()) { + return refused(deliver, "no model in this client serves action '" + std::string{actionType} + "'"); + } + return routed(actionType, std::move(body)); + }; +} + +::morph::forms::Submitter loginSubmitter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor& callbacks, + ::morph::async::CallbackScope const& lifetime, + std::function onLogin) { + return [routed = formsSubmitter(bridge, callbacks), bridgePtr = &bridge, deliver = &callbacks, + scope = &lifetime, onLogin = std::move(onLogin)](std::string_view actionType, std::string body) -> Reply { + if (actionType != "Login") { + return routed(actionType, std::move(body)); + } + // The session is installed inside the mapping, before the derived reply resolves; a throw fails it. + return ::morph::examples::mapCompletion( + *deliver, scope->token(), routed(actionType, std::move(body)), + [bridgePtr, onLogin](std::string const& reply) { + auto const result = decodeLoginResult(reply); + if (!result.has_value()) { + throw std::runtime_error{"login succeeded but its reply could not be decoded"}; + } + ::morph::session::Context session; + session.principal = result->principal; + session.token = result->token.hasValue() ? *result->token : std::string{}; + bridgePtr->setDefaultSession(session); + onLogin(*result); + LoginResult redacted = *result; + redacted.token = AuthToken{}; + return glz::write_json(redacted).value_or(std::string{"{}"}); + }, + [](std::exception_ptr const&) {}); + }; +} + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/app/controllers/session_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +/// @file +/// Signing in: the Login form, and who the bridge's session now belongs to. + +namespace bookmarks::client { + +/// @brief Which screen the application shows. +enum class Route : std::uint8_t { + SignIn, ///< Nobody is signed in: the Login form. + Library, ///< Signed in: the collection, its tags and the shared feed. +}; + +/// @brief The sign-in screen's controller. +/// +/// The Login form submits through `loginSubmitter`, which installs the returned token as the bridge's +/// default session before the reply resolves; the principal it announces is the server's, never what was +/// typed. Every library query is keyed on `signedIn()`, so none is issued before there is a session. +class SessionController { +public: + /// @param runtime The runtime; its owner must be @p callbacks. + /// @param bridge The client's bridge; the session is installed on it. + /// @param callbacks Where replies are delivered. + SessionController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks); + + ~SessionController() = default; + SessionController(SessionController const&) = delete; + SessionController& operator=(SessionController const&) = delete; + SessionController(SessionController&&) = delete; + SessionController& operator=(SessionController&&) = delete; + + /// @brief The Login form. + /// @return Its session. + [[nodiscard]] ::morph::forms::FormSession& loginForm() noexcept { return _login; } + + /// @brief Who is signed in, as the server returned it. Tracked. + /// @return The principal, or empty. + [[nodiscard]] std::string const& principal() const { return _principal.get(); } + + /// @brief Whether a session is installed. Tracked. + /// @return True after a successful login. + [[nodiscard]] bool signedIn() const { return _signedIn.get(); } + + /// @brief "signed in as " or "not signed in". Tracked. + /// @return The line. + [[nodiscard]] std::string const& principalText() const { return _principalText.get(); } + + /// @brief The screen to show. Tracked. + /// @return `Library` once signed in, else `SignIn`. + [[nodiscard]] Route route() const { return signedIn() ? Route::Library : Route::SignIn; } + +private: + ::morph::reactive::Signal _principal; + ::morph::forms::FormSession _login; + ::morph::reactive::Computed _signedIn; + ::morph::reactive::Computed _principalText; + // Last: it gates the Login continuation, which writes `_principal`. + ::morph::async::CallbackScope _lifetime; +}; + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/app/controllers/session_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/session_controller.hpp" + +#include + +#include "controllers/forms_routing.hpp" + +namespace bookmarks::client { + +SessionController::SessionController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks) + : _principal{runtime, std::string{}}, + _login{runtime, formModelFor("Login"), + loginSubmitter(bridge, callbacks, _lifetime, + [this](LoginResult const& result) { _principal.set(result.principal); }), + ::morph::forms::bridgeChoiceFetcher(bridge, callbacks)}, + _signedIn{runtime, [this] { return !_principal.get().empty(); }}, + _principalText{runtime, [this] { + return _signedIn.get() ? "signed in as " + _principal.get() : std::string{"not signed in"}; + }} {} + +} // namespace bookmarks::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_bookmarks_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/bookmarks/ladder_bookmarks_tests "[bookmarks][controller]" +``` + +Expected: PASS. Mutation check: in `loginSubmitter`, delete `bridgePtr->setDefaultSession(session);`. Expected +FAIL in "A Login reply is redacted, and the session is installed before it shows" (the closing `ListBookmarks` +is refused: no principal). Restore. Second: delete `redacted.token = AuthToken{};`. Expected FAIL in the same case +(`CHECK_FALSE(decoded->token.hasValue())`). Restore. The ordering half — the principal is set before the reply is +visible — holds by construction (`mapCompletion` resolves the derived reply with the mapping's return value, after +the mapping returns); the `principalWhenShown` check pins it against a submitter that resolves synchronously. + +- [ ] **Step 5: Commit** + +```bash +git add examples/bookmarks/app/controllers examples/bookmarks/tests/client_fixture.hpp \ + examples/bookmarks/tests/test_forms_routing.cpp examples/bookmarks/tests/test_session_controller.cpp +git commit -m "wip(bookmarks): the six forms' routing and the sign-in controller + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 11: The tags and shared-feed controllers + +**Files:** +- Create: `examples/bookmarks/app/controllers/shared_feed_controller.{hpp,cpp}`, + `examples/bookmarks/app/controllers/tag_controller.{hpp,cpp}` +- Modify: `examples/bookmarks/tests/client_fixture.hpp` — `seedBookmark()` +- Test: `examples/bookmarks/tests/test_tag_and_feed_controllers.cpp` + +**Interfaces:** +- Consumes: `Query`, `Computed`, `FormSession`, `examples::FormSuccess`; `formModelFor`, `formsSubmitter` + (Task 10); `bookmark_text` (Task 9); `bookmarks::{SharedFeedModel, ListSharedFeed, TagModel, ListTags, + TagSummary}`. +- Produces: `bookmarks::client::{FeedRow, SharedFeedController, TagRow, TagController}`; + `SharedFeedController(Runtime&, Bridge&, IExecutor&, std::function signedIn)` with `rows()`, + `heading()`, `loading()`, `errorText()`, `hasError()`, `refresh()`; `TagController(Runtime&, Bridge&, + IExecutor&, std::function signedIn, std::function onChanged)` with the same plus + `renameForm()`, `mergeForm()`; `bookmarks::testing::seedBookmark()`. + +- [ ] **Step 1: Write the failing test** + +Append to `examples/bookmarks/tests/client_fixture.hpp`, inside `namespace bookmarks::testing` (and add +`#include `, `#include "bookmarks/models/bookmark_model.hpp"`): + +```cpp +/// @brief Creates a bookmark through the rig's own handler, outside any controller. +/// @param rig The rig whose session owns the bookmark. +/// @param url The url. +/// @param tags Tag names. +/// @param visibility Private or Shared. +/// @return Its id. +inline std::int64_t seedBookmark(::morph::ladder::testkit::BackendRig& rig, std::string url, + std::vector tags = {}, Visibility visibility = Visibility::Private) { + auto handler = rig.client(0); + CreateBookmark create; + create.url = std::move(url); + create.tags = std::move(tags); + create.visibility = visibility; + BookmarkId const created = ::morph::ladder::testkit::awaitQt(handler.execute(create)).id; + REQUIRE(created.hasValue()); + return *created; +} +``` + +Create `examples/bookmarks/tests/test_tag_and_feed_controllers.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "client_fixture.hpp" +#include "controllers/shared_feed_controller.hpp" +#include "controllers/tag_controller.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using bookmarks::client::FeedRow; +using bookmarks::client::TagRow; +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +[[nodiscard]] TagRow const* tagNamed(std::vector const& rows, std::string const& name) { + auto const found = std::ranges::find_if(rows, [&](TagRow const& row) { return row.name == name; }); + return found == rows.end() ? nullptr : &*found; +} + +/// @brief Whether a row type carries a bookmark's private notes. +template +concept CarriesNotes = requires(Row const& row) { row.notes; }; + +} // namespace + +static_assert(!CarriesNotes); + +TEST_CASE("TagController lists every tag the caller owns, with its id and count, all three backend modes", + "[bookmarks][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = bookmarks::testing::authedRig(mode); + static_cast(bookmarks::testing::seedBookmark(*rig, "https://one.example", {"cpp", "rust"})); + morph::reactive::Runtime runtime{*rig->executor()}; + bookmarks::client::TagController tags{runtime, rig->bridge(0), *rig->executor(), [] { return true; }, [] {}}; + + REQUIRE(pumpUntil([&] { return tags.rows().size() == 2; })); + TagRow const* cpp = tagNamed(tags.rows(), "cpp"); + REQUIRE(cpp != nullptr); + CHECK(cpp->tagId > 0); + CHECK(cpp->idText == "#" + std::to_string(cpp->tagId)); + CHECK(cpp->bookmarkCount == "1"); + CHECK(tags.heading() == "Tags (2)"); + CHECK_FALSE(tags.hasError()); +} + +TEST_CASE("TagController renames a tag through its form and tells the other panes, all three backend modes", + "[bookmarks][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = bookmarks::testing::authedRig(mode); + static_cast(bookmarks::testing::seedBookmark(*rig, "https://one.example", {"work"})); + morph::reactive::Runtime runtime{*rig->executor()}; + int changes = 0; + bookmarks::client::TagController tags{runtime, rig->bridge(0), *rig->executor(), [] { return true; }, [&] { ++changes; }}; + REQUIRE(pumpUntil([&] { return tags.rows().size() == 1; })); + std::int64_t const workId = tags.rows().front().tagId; + + tags.renameForm().prefill(R"({"id":)" + std::to_string(workId) + R"(,"name":"office"})"); + bookmarks::testing::submitAndWait(tags.renameForm()); + REQUIRE(tags.renameForm().lastError() == nullptr); + REQUIRE(pumpUntil([&] { return tagNamed(tags.rows(), "office") != nullptr; })); + CHECK(tagNamed(tags.rows(), "office")->tagId == workId); + CHECK(changes == 1); + CHECK_FALSE(tags.renameForm().ready()); // reset after the success +} + +TEST_CASE("TagController merges two tags through its form, all three backend modes", "[bookmarks][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = bookmarks::testing::authedRig(mode); + static_cast(bookmarks::testing::seedBookmark(*rig, "https://one.example", {"cpp"})); + static_cast(bookmarks::testing::seedBookmark(*rig, "https://two.example", {"cxx"})); + morph::reactive::Runtime runtime{*rig->executor()}; + bookmarks::client::TagController tags{runtime, rig->bridge(0), *rig->executor(), [] { return true; }, [] {}}; + REQUIRE(pumpUntil([&] { return tags.rows().size() == 2; })); + std::int64_t const cppId = tagNamed(tags.rows(), "cpp")->tagId; + std::int64_t const cxxId = tagNamed(tags.rows(), "cxx")->tagId; + + tags.mergeForm().prefill(R"({"sourceId":)" + std::to_string(cppId) + R"(,"targetId":)" + std::to_string(cxxId) + "}"); + bookmarks::testing::submitAndWait(tags.mergeForm()); + REQUIRE(pumpUntil([&] { return tags.rows().size() == 1; })); + REQUIRE(tagNamed(tags.rows(), "cxx") != nullptr); + CHECK(tagNamed(tags.rows(), "cxx")->bookmarkCount == "2"); + CHECK(tagNamed(tags.rows(), "cpp") == nullptr); +} + +TEST_CASE("The tag forms show the model's refusal, and nothing changes", "[bookmarks][controller]") { + DbFixture fixture; + auto rig = bookmarks::testing::authedRig(Mode::Local); + static_cast(bookmarks::testing::seedBookmark(*rig, "https://one.example", {"cpp"})); + morph::reactive::Runtime runtime{*rig->executor()}; + int changes = 0; + bookmarks::client::TagController tags{runtime, rig->bridge(0), *rig->executor(), [] { return true; }, [&] { ++changes; }}; + REQUIRE(pumpUntil([&] { return tags.rows().size() == 1; })); + std::string const cppId = std::to_string(tags.rows().front().tagId); + + // MergeTags::validate() refuses a tag merged into itself; the schema cannot say so. + tags.mergeForm().prefill(R"({"sourceId":)" + cppId + R"(,"targetId":)" + cppId + "}"); + bookmarks::testing::submitAndWait(tags.mergeForm()); + CHECK(tags.mergeForm().lastError() != nullptr); + CHECK(changes == 0); + + tags.renameForm().prefill(R"({"id":)" + cppId + R"(,"name":""})"); + CHECK_FALSE(tags.renameForm().ready()); +} + +TEST_CASE("SharedFeedController lists every shared bookmark and never a private one, all three backend modes", + "[bookmarks][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = bookmarks::testing::authedRig(mode); + static_cast(bookmarks::testing::seedBookmark(*rig, "https://alice-private.example")); + std::int64_t const sharedId = bookmarks::testing::seedBookmark(*rig, "https://alice-shared.example", {}, + bookmarks::Visibility::Shared); + morph::reactive::Runtime runtime{*rig->executor()}; + bookmarks::client::SharedFeedController feed{runtime, rig->bridge(0), *rig->executor(), [] { return true; }}; + + REQUIRE(pumpUntil([&] { return feed.rows().size() == 1; })); + CHECK(feed.rows().front().bookmarkId == sharedId); + CHECK(feed.rows().front().line.starts_with("https://alice-shared.example · ")); + CHECK(feed.heading() == "Shared feed (1)"); +} + +TEST_CASE("Signed out, the tag and feed queries are idle; with no session at all they report, not crash", + "[bookmarks][controller]") { + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + morph::reactive::Runtime runtime{*rig.executor()}; + { + bookmarks::client::TagController tags{runtime, rig.bridge(0), *rig.executor(), [] { return false; }, [] {}}; + bookmarks::client::SharedFeedController feed{runtime, rig.bridge(0), *rig.executor(), [] { return false; }}; + CHECK_FALSE(tags.loading()); + CHECK_FALSE(feed.loading()); + } + bookmarks::client::TagController tags{runtime, rig.bridge(0), *rig.executor(), [] { return true; }, [] {}}; + bookmarks::client::SharedFeedController feed{runtime, rig.bridge(0), *rig.executor(), [] { return true; }}; + REQUIRE(pumpUntil([&] { return tags.hasError() && feed.hasError(); })); + CHECK_FALSE(tags.errorText().empty()); + CHECK_FALSE(feed.errorText().empty()); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_bookmarks_tests` +Expected: FAIL — `'controllers/shared_feed_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bookmarks/app/controllers/shared_feed_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bookmarks/models/shared_feed_model.hpp" + +/// @file +/// The cross-user shared feed. + +namespace bookmarks::client { + +/// @brief One shared bookmark, as the feed shows it. No notes: a feed row is a listing row. +struct FeedRow { + std::int64_t bookmarkId = 0; ///< The bookmark; also the row's key. + std::string line; ///< "title-or-url · created". + + /// @brief Field-wise equality. + bool operator==(FeedRow const&) const = default; +}; + +/// @brief The shared feed's controller: one `Query`, idle while signed out. +class SharedFeedController { +public: + /// @param runtime The runtime; its owner must be @p callbacks. + /// @param bridge The client's bridge. + /// @param callbacks Where replies are delivered. + /// @param signedIn Tracked: whether a session is installed. The query is idle while it is false. + SharedFeedController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks, std::function signedIn); + + ~SharedFeedController() = default; + SharedFeedController(SharedFeedController const&) = delete; + SharedFeedController& operator=(SharedFeedController const&) = delete; + SharedFeedController(SharedFeedController&&) = delete; + SharedFeedController& operator=(SharedFeedController&&) = delete; + + /// @brief The feed. Tracked. + /// @return Its rows, newest first. + [[nodiscard]] std::vector const& rows() const { return _rows.get(); } + + /// @brief "Shared feed (N)". Tracked. + /// @return The heading. + [[nodiscard]] std::string const& heading() const { return _heading.get(); } + + /// @brief Whether a request is in flight. Tracked. + /// @return True from issue to reply. + [[nodiscard]] bool loading() const { return _feed.pending(); } + + /// @brief The last failure's text. Tracked. + /// @return The text, or empty. + [[nodiscard]] std::string const& errorText() const { return _errorText.get(); } + + /// @brief Whether the last request failed. Tracked. + /// @return True after a failure, until the next success. + [[nodiscard]] bool hasError() const { return !_errorText.get().empty(); } + + /// @brief Fetches the feed again. + void refresh() { _feed.refetch(); } + +private: + ::morph::bridge::BridgeHandler _handler; + std::function _signedIn; + ::morph::reactive::Query _feed; + ::morph::reactive::Computed> _rows; + ::morph::reactive::Computed _heading; + ::morph::reactive::Computed _errorText; +}; + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/app/controllers/shared_feed_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/shared_feed_controller.hpp" + +#include +#include + +#include "controllers/bookmark_text.hpp" + +namespace bookmarks::client { + +SharedFeedController::SharedFeedController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks, std::function signedIn) + : _handler{bridge, &callbacks}, + _signedIn{std::move(signedIn)}, + _feed{runtime, _handler, + [this]() -> std::optional { + return _signedIn() ? std::optional{ListSharedFeed{}} : std::nullopt; + }}, + _rows{runtime, + [this] { + std::vector rows; + if (auto const& page = _feed.value()) { + for (auto const& summary : page->bookmarks) { + rows.push_back(FeedRow{.bookmarkId = summary.id.hasValue() ? *summary.id : 0, + .line = headline(summary.title, summary.url) + " · " + + isoOrEmpty(summary.createdAt)}); + } + } + return rows; + }}, + _heading{runtime, [this] { return "Shared feed (" + std::to_string(_rows.get().size()) + ")"; }}, + _errorText{runtime, [this] { + auto const error = _feed.error(); + return error != nullptr ? ::morph::reactive::errorMessage(error) : std::string{}; + }} {} + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/app/controllers/tag_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/form_success.hpp" +#include "bookmarks/models/tag_model.hpp" + +/// @file +/// The caller's tags, and the rename and merge forms. + +namespace bookmarks::client { + +/// @brief One tag, as the tags pane shows it. +struct TagRow { + std::int64_t tagId = 0; ///< The tag; also the row's key. + std::string idText; ///< "#": the number the rename and merge forms ask for. + std::string name; ///< The tag's name. + std::string bookmarkCount; ///< How many bookmarks carry it. + + /// @brief Field-wise equality. + bool operator==(TagRow const&) const = default; +}; + +/// @brief The tags pane's controller: a `Query` and the RenameTag and MergeTags forms. A +/// successful rename or merge refetches the tags, resets its form and calls `onChanged`, because +/// the bookmarks, the open bookmark and the feed show tag names too. +class TagController { +public: + /// @param runtime The runtime; its owner must be @p callbacks. + /// @param bridge The client's bridge. + /// @param callbacks Where replies are delivered. + /// @param signedIn Tracked: whether a session is installed. The query is idle while it is false. + /// @param onChanged Called after each successful rename or merge. + TagController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks, std::function signedIn, + std::function onChanged); + + ~TagController() = default; + TagController(TagController const&) = delete; + TagController& operator=(TagController const&) = delete; + TagController(TagController&&) = delete; + TagController& operator=(TagController&&) = delete; + + /// @brief The caller's tags. Tracked. + /// @return Their rows. + [[nodiscard]] std::vector const& rows() const { return _rows.get(); } + + /// @brief "Tags (N)". Tracked. + /// @return The heading. + [[nodiscard]] std::string const& heading() const { return _heading.get(); } + + /// @brief Whether a request is in flight. Tracked. + /// @return True from issue to reply. + [[nodiscard]] bool loading() const { return _tags.pending(); } + + /// @brief The last listing failure's text. Tracked. + /// @return The text, or empty. + [[nodiscard]] std::string const& errorText() const { return _errorText.get(); } + + /// @brief Whether the last listing failed. Tracked. + /// @return True after a failure, until the next success. + [[nodiscard]] bool hasError() const { return !_errorText.get().empty(); } + + /// @brief Fetches the tags again. + void refresh() { _tags.refetch(); } + + /// @brief The RenameTag form. + /// @return Its session. + [[nodiscard]] ::morph::forms::FormSession& renameForm() noexcept { return _rename; } + + /// @brief The MergeTags form. + /// @return Its session. + [[nodiscard]] ::morph::forms::FormSession& mergeForm() noexcept { return _merge; } + +private: + void onSubmitted(::morph::forms::FormSession& form); + + ::morph::bridge::BridgeHandler _handler; + std::function _signedIn; + std::function _onChanged; + ::morph::reactive::Query _tags; + ::morph::forms::FormSession _rename; + ::morph::forms::FormSession _merge; + ::morph::reactive::Computed> _rows; + ::morph::reactive::Computed _heading; + ::morph::reactive::Computed _errorText; + ::morph::examples::FormSuccess _onRenamed; + ::morph::examples::FormSuccess _onMerged; +}; + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/app/controllers/tag_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/tag_controller.hpp" + +#include +#include +#include + +#include "controllers/bookmark_text.hpp" +#include "controllers/forms_routing.hpp" + +namespace bookmarks::client { + +TagController::TagController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks, std::function signedIn, + std::function onChanged) + : _handler{bridge, &callbacks}, + _signedIn{std::move(signedIn)}, + _onChanged{std::move(onChanged)}, + _tags{runtime, _handler, + [this]() -> std::optional { + return _signedIn() ? std::optional{ListTags{}} : std::nullopt; + }}, + _rename{runtime, formModelFor("RenameTag"), formsSubmitter(bridge, callbacks), + ::morph::forms::bridgeChoiceFetcher(bridge, callbacks)}, + _merge{runtime, formModelFor("MergeTags"), formsSubmitter(bridge, callbacks), + ::morph::forms::bridgeChoiceFetcher(bridge, callbacks)}, + _rows{runtime, + [this] { + std::vector rows; + if (auto const& page = _tags.value()) { + for (auto const& tag : page->tags) { + std::int64_t const tagId = tag.id.hasValue() ? *tag.id : 0; + rows.push_back(TagRow{.tagId = tagId, + .idText = "#" + std::to_string(tagId), + .name = tag.name, + .bookmarkCount = countText(tag.bookmarkCount)}); + } + } + return rows; + }}, + _heading{runtime, [this] { return "Tags (" + std::to_string(_rows.get().size()) + ")"; }}, + _errorText{runtime, + [this] { + auto const error = _tags.error(); + return error != nullptr ? ::morph::reactive::errorMessage(error) : std::string{}; + }}, + _onRenamed{runtime, _rename, [this] { onSubmitted(_rename); }}, + _onMerged{runtime, _merge, [this] { onSubmitted(_merge); }} {} + +void TagController::onSubmitted(::morph::forms::FormSession& form) { + _tags.refetch(); + form.reset(); + _onChanged(); +} + +} // namespace bookmarks::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_bookmarks_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/bookmarks/ladder_bookmarks_tests "[bookmarks][controller]" +``` + +Expected: PASS. Mutation check: in `SharedFeedController`'s key, return `ListSharedFeed{}` unconditionally. +Expected FAIL in "Signed out, the tag and feed queries are idle…" (`CHECK_FALSE(feed.loading())`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/bookmarks/app/controllers examples/bookmarks/tests/client_fixture.hpp \ + examples/bookmarks/tests/test_tag_and_feed_controllers.cpp +git commit -m "wip(bookmarks): the tags and shared-feed controllers + +Signed-off-by: Christian Parpart " +``` + +--- +### Task 12: The bookmark list controller — create, archived toggle, multiple selection, bulk archive + +**Files:** +- Create: `examples/bookmarks/app/controllers/bookmark_list_controller.{hpp,cpp}` +- Modify: `examples/bookmarks/tests/client_fixture.hpp` — `createVia()` +- Test: `examples/bookmarks/tests/test_bookmark_list_controller.cpp` + +**Interfaces:** +- Consumes: `Query`, `Mutation`, `MutationOptions`, `Computed`, `Effect`, `FormSession`, `FormSuccess`; + `formModelFor`, `formsSubmitter` (Task 10); `bookmark_text` (Task 9); `bookmarks::{BookmarkModel, ListBookmarks, + ReadFilter, ArchiveFilter, BulkEdit, BulkArchiveOp, BulkEditResult, CreateBookmarkResult, ArchiveBookmark}`; + `glz::read_json`. +- Produces: `bookmarks::client::{BookmarkRow, rowOf, BookmarkListController}`; + `BookmarkListController(Runtime&, Bridge&, IExecutor&, std::function signedIn, + std::function onChanged)` with `rows()`, `countText()`, `loading()`, `refresh()`, `refreshAll()`, + `includeArchived()`, `setIncludeArchived()`, `selection()`, `select()`, `selectionText()`, `canBulkEdit()`, + `bulkArchive()`, `bulkUnarchive()`, `createForm()`, `lastCreatedId()`, `statusText()`, `statusIsError()`, + `hasStatus()`; `bookmarks::testing::createVia()`. + +- [ ] **Step 1: Write the failing test** + +Append to `examples/bookmarks/tests/client_fixture.hpp`, inside `namespace bookmarks::testing` (and add +`#include "controllers/bookmark_list_controller.hpp"` and `#include `): + +```cpp +/// @brief Prefills the create form with @p bodyJson, submits it and waits for the new id. +/// @param list The list controller whose form to use. +/// @param bodyJson A complete `CreateBookmark` body. +/// @return The new bookmark's id. +inline std::int64_t createVia(client::BookmarkListController& list, std::string const& bodyJson) { + auto& form = list.createForm(); + form.prefill(bodyJson); + std::optional const before = list.lastCreatedId(); + submitAndWait(form); + REQUIRE(form.lastError() == nullptr); + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return list.lastCreatedId() != before; })); + return list.lastCreatedId().value_or(0); +} +``` + +Create `examples/bookmarks/tests/test_bookmark_list_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bookmarks/models/bookmark_model.hpp" +#include "client_fixture.hpp" +#include "controllers/bookmark_list_controller.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using bookmarks::client::BookmarkListController; +using bookmarks::client::BookmarkRow; +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +/// @brief A signed-in client and its list controller; `changes` counts `onChanged` calls. +struct ListFixture { + explicit ListFixture(Mode mode) + : rig{bookmarks::testing::authedRig(mode)}, + runtime{*rig->executor()}, + list{runtime, rig->bridge(0), *rig->executor(), [] { return true; }, [this] { ++changes; }} {} + + std::unique_ptr rig; + morph::reactive::Runtime runtime; + int changes = 0; + BookmarkListController list; +}; + +[[nodiscard]] BookmarkRow const* rowFor(BookmarkListController const& list, std::int64_t bookmarkId) { + auto const found = + std::ranges::find_if(list.rows(), [&](BookmarkRow const& row) { return row.bookmarkId == bookmarkId; }); + return found == list.rows().end() ? nullptr : &*found; +} + +void reloadAndWait(BookmarkListController& list) { + list.refresh(); + REQUIRE(pumpUntil([&] { return !list.loading(); })); +} + +/// @brief Whether a row type carries a bookmark's private notes. +template +concept CarriesNotes = requires(Row const& row) { row.notes; }; + +} // namespace + +static_assert(!CarriesNotes); + +TEST_CASE("The create form stores a bookmark, and the list shows it in the narrower row shape, all three backend modes", + "[bookmarks][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + ListFixture client{mode}; + std::int64_t const bookmarkId = bookmarks::testing::createVia( + client.list, R"({"url":"https://row.example","title":"Row","notes":"must not leak"})"); + + REQUIRE(pumpUntil([&] { return rowFor(client.list, bookmarkId) != nullptr; })); + BookmarkRow const& row = *rowFor(client.list, bookmarkId); + CHECK(row.title == "Row · https://row.example"); + CHECK(row.state == "Private · Active"); + CHECK(row.title.find("must not leak") == std::string::npos); + CHECK(client.list.countText() == "1 bookmark(s)"); + CHECK(client.list.statusText() == "created"); + CHECK(client.changes == 1); + CHECK_FALSE(client.list.createForm().ready()); // reset after the success +} + +TEST_CASE("Archived bookmarks show only when asked for, all three backend modes", "[bookmarks][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + ListFixture client{mode}; + std::int64_t const bookmarkId = bookmarks::testing::seedBookmark(*client.rig, "https://arms.example"); + auto handler = client.rig->client(0); + static_cast(awaitQt(handler.execute(bookmarks::ArchiveBookmark{.id = bookmarks::BookmarkId{bookmarkId}}))); + + reloadAndWait(client.list); + CHECK(client.list.rows().empty()); + + client.list.setIncludeArchived(true); + REQUIRE(pumpUntil([&] { return rowFor(client.list, bookmarkId) != nullptr; })); + CHECK(rowFor(client.list, bookmarkId)->state == "Private · Archived"); +} + +TEST_CASE("Bulk archive and unarchive act on the selection and clear it, all three backend modes", + "[bookmarks][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + ListFixture client{mode}; + std::int64_t const first = bookmarks::testing::seedBookmark(*client.rig, "https://bulk-one.example"); + std::int64_t const second = bookmarks::testing::seedBookmark(*client.rig, "https://bulk-two.example"); + reloadAndWait(client.list); + REQUIRE(client.list.rows().size() == 2); + + client.list.select({first, second}); + CHECK(client.list.selectionText() == "2 selected"); + REQUIRE(client.list.canBulkEdit()); + client.list.bulkArchive(); + REQUIRE(pumpUntil([&] { return client.list.statusText() == "bulk edit affected 2 bookmark(s)"; })); + CHECK(client.list.selection().empty()); + REQUIRE(pumpUntil([&] { return client.list.rows().empty(); })); + + client.list.setIncludeArchived(true); + REQUIRE(pumpUntil([&] { return client.list.rows().size() == 2; })); + for (auto const& row : client.list.rows()) { + CHECK(row.state == "Private · Archived"); + } + + client.list.select({first, second}); + client.list.bulkUnarchive(); + REQUIRE(pumpUntil([&] { + return client.list.selection().empty() && client.list.rows().size() == 2 && + std::ranges::all_of(client.list.rows(), [](BookmarkRow const& row) { return row.state == "Private · Active"; }); + })); +} + +TEST_CASE("With nothing selected the bulk actions issue nothing", "[bookmarks][controller]") { + DbFixture fixture; + ListFixture client{Mode::Local}; + REQUIRE(pumpUntil([&] { return !client.list.loading(); })); + CHECK_FALSE(client.list.canBulkEdit()); + client.list.bulkArchive(); + client.list.bulkUnarchive(); + CHECK_FALSE(client.list.hasStatus()); + CHECK_FALSE(client.list.loading()); +} + +TEST_CASE("The create form refuses an empty url, and shows a refusal its schema cannot see", + "[bookmarks][controller]") { + DbFixture fixture; + ListFixture client{Mode::Local}; + auto& form = client.list.createForm(); + form.prefill(R"({"url":""})"); + CHECK_FALSE(form.ready()); + + form.prefill(R"({"url":"https://)" + std::string(bookmarks::kMaxUrlBytes, 'x') + R"("})"); + bookmarks::testing::submitAndWait(form); + REQUIRE(form.lastError() != nullptr); + CHECK(morph::reactive::errorMessage(form.lastError()).find("CreateBookmark") != std::string::npos); + CHECK(client.changes == 0); +} + +TEST_CASE("Signed out the list is idle; with no session at all it reports, not crashes", "[bookmarks][controller]") { + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + morph::reactive::Runtime runtime{*rig.executor()}; + { + BookmarkListController idle{runtime, rig.bridge(0), *rig.executor(), [] { return false; }, [] {}}; + CHECK_FALSE(idle.loading()); + } + BookmarkListController list{runtime, rig.bridge(0), *rig.executor(), [] { return true; }, [] {}}; + REQUIRE(pumpUntil([&] { return list.statusIsError(); })); + CHECK_FALSE(list.statusText().empty()); +} + +TEST_CASE("a bookmark list destroyed with a create in flight drops its reply", "[bookmarks][controller]") { + DbFixture fixture; + auto rig = bookmarks::testing::authedRig(Mode::LocalSingleThread); + morph::reactive::Runtime runtime{*rig->executor()}; + { + BookmarkListController abandoned{runtime, rig->bridge(0), *rig->executor(), [] { return true; }, [] {}}; + abandoned.createForm().prefill(R"({"url":"https://abandoned.example"})"); + abandoned.createForm().submit(); + // No pump before this brace: the reply cannot have been delivered yet. + } + BookmarkListController live{runtime, rig->bridge(0), *rig->executor(), [] { return true; }, [] {}}; + static_cast(bookmarks::testing::createVia(live, R"({"url":"https://live.example"})")); + REQUIRE(pumpUntil([&] { return live.rows().size() == 2; })); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_bookmarks_tests` +Expected: FAIL — `'controllers/bookmark_list_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bookmarks/app/controllers/bookmark_list_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/form_success.hpp" +#include "bookmarks/models/bookmark_model.hpp" +#include "controllers/bookmark_text.hpp" + +/// @file +/// The signed-in user's collection: the create form, the listing with its archived toggle, and bulk +/// archive/unarchive over a multiple selection. + +namespace bookmarks::client { + +/// @brief One bookmark in the listing. No notes and no description: a listing never shows them. +struct BookmarkRow { + std::int64_t bookmarkId = 0; ///< The bookmark; also the row's key. + std::string title; ///< `rowTitle(title, url)`. + std::string state; ///< "Private · Active" and the like. + + /// @brief Field-wise equality. + bool operator==(BookmarkRow const&) const = default; +}; + +/// @brief One listing row. +/// @param summary The row as `ListBookmarks` returned it. +/// @return The display row. +[[nodiscard]] BookmarkRow rowOf(BookmarkSummary const& summary); + +/// @brief The list pane's controller. +/// +/// The listing is a `Query` keyed on `signedIn` and the archived toggle, so flipping the +/// toggle refetches by itself and nothing is issued before sign-in. Bulk archive/unarchive is one +/// `Mutation` over the selection; a success clears the selection and refetches the listing. A +/// successful create resets the form, refetches the listing and calls `onChanged` — the tags and the feed +/// show the new bookmark too. +class BookmarkListController { +public: + /// @param runtime The runtime; its owner must be @p callbacks. + /// @param bridge The client's bridge. + /// @param callbacks Where replies are delivered. + /// @param signedIn Tracked: whether a session is installed. + /// @param onChanged Called after a create or a bulk edit, for the other panes. + BookmarkListController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks, std::function signedIn, + std::function onChanged); + + ~BookmarkListController() = default; + BookmarkListController(BookmarkListController const&) = delete; + BookmarkListController& operator=(BookmarkListController const&) = delete; + BookmarkListController(BookmarkListController&&) = delete; + BookmarkListController& operator=(BookmarkListController&&) = delete; + + /// @brief The listing. Tracked. + /// @return Its rows, newest first. + [[nodiscard]] std::vector const& rows() const { return _rows.get(); } + + /// @brief "N bookmark(s)". Tracked. + /// @return The count line. + [[nodiscard]] std::string const& countText() const { return _countText.get(); } + + /// @brief Whether a listing request is in flight. Tracked. + /// @return True from issue to reply. + [[nodiscard]] bool loading() const { return _list.pending(); } + + /// @brief Fetches the listing again. + void refresh() { _list.refetch(); } + + /// @brief Fetches the listing and asks the other panes to refresh too. + void refreshAll() { + _list.refetch(); + _onChanged(); + } + + /// @brief Whether archived bookmarks are listed. Tracked. + /// @return The toggle. + [[nodiscard]] bool includeArchived() const { return _includeArchived.get(); } + + /// @brief Sets the archived toggle; the listing refetches when it changes. + /// @param include Whether to list archived bookmarks. + void setIncludeArchived(bool include) { _includeArchived.set(include); } + + /// @brief The selected bookmarks. Tracked. + /// @return Their ids. + [[nodiscard]] std::vector const& selection() const { return _selection.get(); } + + /// @brief Replaces the selection. + /// @param bookmarkIds The selected bookmarks. + void select(std::vector bookmarkIds) { _selection.set(std::move(bookmarkIds)); } + + /// @brief "N selected". Tracked. + /// @return The line. + [[nodiscard]] std::string const& selectionText() const { return _selectionText.get(); } + + /// @brief Whether the bulk buttons act. Tracked. + /// @return True while something is selected and no bulk edit is in flight. + [[nodiscard]] bool canBulkEdit() const { return _canBulkEdit.get(); } + + /// @brief Archives every selected bookmark; does nothing with an empty selection. + void bulkArchive() { bulk(BulkArchiveOp::Archive); } + + /// @brief Unarchives every selected bookmark; does nothing with an empty selection. + void bulkUnarchive() { bulk(BulkArchiveOp::Unarchive); } + + /// @brief The CreateBookmark form. + /// @return Its session. + [[nodiscard]] ::morph::forms::FormSession& createForm() noexcept { return _create; } + + /// @brief The id the last successful create returned. Tracked. + /// @return It, or `nullopt` before the first. + [[nodiscard]] std::optional const& lastCreatedId() const { return _lastCreated.get(); } + + /// @brief The status line's text. Tracked. + /// @return The text, or empty. + [[nodiscard]] std::string const& statusText() const { return _status.get().text; } + + /// @brief Whether the status line reports a failure. Tracked. + /// @return True for a failure. + [[nodiscard]] bool statusIsError() const { return _status.get().isError; } + + /// @brief Whether there is a status to show. Tracked. + /// @return True when the text is non-empty. + [[nodiscard]] bool hasStatus() const { return !_status.get().text.empty(); } + +private: + void bulk(BulkArchiveOp operation); + void onCreated(); + + ::morph::reactive::Runtime* _rt; + ::morph::bridge::BridgeHandler _handler; + std::function _signedIn; + std::function _onChanged; + ::morph::reactive::Signal _includeArchived; + ::morph::reactive::Signal> _selection; + ::morph::reactive::Signal _status; + ::morph::reactive::Signal> _lastCreated; + ::morph::reactive::Query _list; + ::morph::reactive::Mutation _bulk; + ::morph::forms::FormSession _create; + ::morph::reactive::Computed> _rows; + ::morph::reactive::Computed _countText; + ::morph::reactive::Computed _selectionText; + ::morph::reactive::Computed _canBulkEdit; + ::morph::reactive::Effect _onListFailed; + ::morph::reactive::Effect _onBulkDone; + ::morph::reactive::Effect _onBulkFailed; + // Last: it calls into everything above. + ::morph::examples::FormSuccess _onCreated; +}; + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/app/controllers/bookmark_list_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/bookmark_list_controller.hpp" + +#include +#include + +#include "controllers/forms_routing.hpp" + +namespace bookmarks::client { + +BookmarkRow rowOf(BookmarkSummary const& summary) { + return BookmarkRow{.bookmarkId = summary.id.hasValue() ? *summary.id : 0, + .title = rowTitle(summary.title, summary.url), + .state = visibilityText(summary.visibility) + " · " + archiveStateText(summary.archiveState)}; +} + +BookmarkListController::BookmarkListController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks, std::function signedIn, + std::function onChanged) + : _rt{&runtime}, + _handler{bridge, &callbacks}, + _signedIn{std::move(signedIn)}, + _onChanged{std::move(onChanged)}, + _includeArchived{runtime, false}, + _selection{runtime, std::vector{}}, + _status{runtime, StatusLine{}}, + _lastCreated{runtime, std::nullopt}, + _list{runtime, _handler, + [this]() -> std::optional { + if (!_signedIn()) { + return std::nullopt; + } + return ListBookmarks{.cursor = {}, + .readFilter = ReadFilter::Any, + .archiveFilter = + _includeArchived.get() ? ArchiveFilter::Any : ArchiveFilter::ActiveOnly, + .tag = {}, + .searchText = {}}; + }}, + _bulk{runtime, _handler, ::morph::reactive::MutationOptions{.invalidates = {&_list}}}, + _create{runtime, formModelFor("CreateBookmark"), formsSubmitter(bridge, callbacks), + ::morph::forms::bridgeChoiceFetcher(bridge, callbacks)}, + _rows{runtime, + [this] { + std::vector rows; + if (auto const& page = _list.value()) { + for (auto const& summary : page->bookmarks) { + rows.push_back(rowOf(summary)); + } + } + return rows; + }}, + _countText{runtime, [this] { return std::to_string(_rows.get().size()) + " bookmark(s)"; }}, + _selectionText{runtime, [this] { return std::to_string(_selection.get().size()) + " selected"; }}, + _canBulkEdit{runtime, [this] { return !_selection.get().empty() && !_bulk.pending(); }}, + _onListFailed{runtime, + [this] { + if (auto const error = _list.error()) { + _status.set(failureLine(error)); + } + }}, + _onBulkDone{runtime, + [this] { + auto const& done = _bulk.lastResult(); + if (!done.has_value()) { + return; + } + _rt->untracked([&] { + _selection.set({}); + _status.set(infoLine("bulk edit affected " + countText(done->affected) + " bookmark(s)")); + _onChanged(); + }); + }}, + _onBulkFailed{runtime, + [this] { + if (auto const error = _bulk.error()) { + _status.set(failureLine(error)); + } + }}, + _onCreated{runtime, _create, [this] { onCreated(); }} {} + +void BookmarkListController::bulk(BulkArchiveOp operation) { + auto const& selected = _selection.peek(); + if (selected.empty()) { + return; + } + BulkEdit edit{.ids = {}, .addTags = {}, .removeTags = {}, .archive = operation}; + for (std::int64_t const bookmarkId : selected) { + edit.ids.emplace_back(bookmarkId); + } + _bulk.run(std::move(edit)); +} + +void BookmarkListController::onCreated() { + if (auto const& reply = _create.lastReply(); reply.has_value()) { + CreateBookmarkResult result; + if (!glz::read_json(result, *reply) && result.id.hasValue()) { + _lastCreated.set(*result.id); + } + } + _status.set(infoLine("created")); + _create.reset(); + _list.refetch(); + _onChanged(); +} + +} // namespace bookmarks::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_bookmarks_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/bookmarks/ladder_bookmarks_tests "[bookmarks][controller]" +``` + +Expected: PASS. Mutation check: in `_onBulkDone`, delete `_selection.set({});`. Expected FAIL in "Bulk archive +and unarchive act on the selection and clear it" (`CHECK(client.list.selection().empty())`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/bookmarks/app/controllers examples/bookmarks/tests/client_fixture.hpp \ + examples/bookmarks/tests/test_bookmark_list_controller.cpp +git commit -m "wip(bookmarks): the list controller — create, archived toggle, bulk archive + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 13: The bookmark detail controller — open, archive, delete, edit, import + +**Files:** +- Create: `examples/bookmarks/app/controllers/bookmark_detail_controller.{hpp,cpp}` +- Modify: `examples/bookmarks/tests/client_fixture.hpp` — `openAndWait()` +- Test: `examples/bookmarks/tests/test_bookmark_detail_controller.cpp` + +**Interfaces:** +- Consumes: as Task 12, plus `bookmarks::{GetBookmark, BookmarkView, ArchiveBookmark, UnarchiveBookmark, + DeleteBookmark, EditBookmark, ImportBookmarks, ImportBookmarksResult, ImportOpId}`; + `examples::newUuid()` (`examples/common/app/uuid.hpp`); `glz::write_json`. +- Produces: `bookmarks::client::{BookmarkFact, factsOf, editBodyOf, importBodyWith, BookmarkDetailController}`; + `BookmarkDetailController(Runtime&, Bridge&, IExecutor&, std::function signedIn, + std::function onChanged)` with `open()`, `close()`, `refresh()`, `current()`, `hasCurrent()`, + `detailTitle()`, `facts()`, `canAct()`, `archive()`, `unarchive()`, `remove()`, `editForm()`, `importForm()`, + `importOpId()`, `loading()`, `statusText()`, `statusIsError()`, `hasStatus()`; `bookmarks::testing::openAndWait()`. + +- [ ] **Step 1: Write the failing test** + +Append to `examples/bookmarks/tests/client_fixture.hpp`, inside `namespace bookmarks::testing` (and add +`#include "controllers/bookmark_detail_controller.hpp"`): + +```cpp +/// @brief Opens @p bookmarkId and waits until it is the open bookmark. +/// @param detail The detail controller. +/// @param bookmarkId The bookmark. +inline void openAndWait(client::BookmarkDetailController& detail, std::int64_t bookmarkId) { + detail.open(bookmarkId); + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { + auto const& current = detail.current(); + return current.has_value() && current->id == BookmarkId{bookmarkId}; + })); +} +``` + +Create `examples/bookmarks/tests/test_bookmark_detail_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "bookmarks/models/bookmark_model.hpp" +#include "client_fixture.hpp" +#include "controllers/bookmark_detail_controller.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using bookmarks::client::BookmarkDetailController; +using bookmarks::client::BookmarkFact; +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +/// @brief A signed-in client and its detail controller; `changes` counts `onChanged` calls. +struct DetailFixture { + explicit DetailFixture(Mode mode) + : rig{bookmarks::testing::authedRig(mode)}, + runtime{*rig->executor()}, + detail{runtime, rig->bridge(0), *rig->executor(), [] { return true; }, [this] { ++changes; }} {} + + std::unique_ptr rig; + morph::reactive::Runtime runtime; + int changes = 0; + BookmarkDetailController detail; +}; + +[[nodiscard]] std::int64_t createFull(BackendRig& rig, bookmarks::CreateBookmark create) { + auto handler = rig.client(0); + return *awaitQt(handler.execute(create)).id; +} + +[[nodiscard]] std::string lineFor(BookmarkDetailController const& detail, std::string const& key) { + for (BookmarkFact const& fact : detail.facts()) { + if (fact.key == key) { + return fact.line; + } + } + return {}; +} + +} // namespace + +TEST_CASE("Opening a bookmark shows every fact, all three backend modes", "[bookmarks][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + DetailFixture client{mode}; + CHECK_FALSE(client.detail.hasCurrent()); + CHECK_FALSE(client.detail.canAct()); + std::int64_t const bookmarkId = createFull(*client.rig, bookmarks::CreateBookmark{.url = "https://bag.example", + .title = "Bag", + .description = "desc", + .notes = "private note", + .tags = {"work", "home"}}); + bookmarks::testing::openAndWait(client.detail, bookmarkId); + + std::vector keys; + for (BookmarkFact const& fact : client.detail.facts()) { + keys.push_back(fact.key); + } + CHECK(keys == std::vector{"url", "description", "notes", "tags", "visibility", "read", "archive", + "created", "updated"}); + CHECK(client.detail.detailTitle() == "Bag"); + CHECK(lineFor(client.detail, "url") == "url: https://bag.example"); + CHECK(lineFor(client.detail, "notes") == "notes: private note"); + CHECK(lineFor(client.detail, "tags").find("work") != std::string::npos); + CHECK(lineFor(client.detail, "visibility") == "visibility: Private"); + CHECK(lineFor(client.detail, "read") == "read: Unread"); + CHECK(lineFor(client.detail, "archive") == "archive: Active"); + CHECK(lineFor(client.detail, "created").back() == 'Z'); + CHECK(client.detail.canAct()); +} + +TEST_CASE("The edit form is prefilled from the open bookmark, and a save shows on reload, all three backend modes", + "[bookmarks][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + DetailFixture client{mode}; + std::int64_t const bookmarkId = + createFull(*client.rig, bookmarks::CreateBookmark{.url = "https://before.example", .title = "Before"}); + bookmarks::testing::openAndWait(client.detail, bookmarkId); + + auto& edit = client.detail.editForm(); + REQUIRE(pumpUntil([&] { return edit.body().has_value(); })); + CHECK(edit.body()->find(R"("id":)" + std::to_string(bookmarkId)) != std::string::npos); + CHECK(edit.body()->find("https://before.example") != std::string::npos); + + edit.prefill(R"({"id":)" + std::to_string(bookmarkId) + R"(,"url":"https://after.example","title":"After"})"); + bookmarks::testing::submitAndWait(edit); + REQUIRE(edit.lastError() == nullptr); + REQUIRE(pumpUntil([&] { return client.detail.detailTitle() == "After"; })); + CHECK(lineFor(client.detail, "url") == "url: https://after.example"); + CHECK(client.detail.statusText() == "saved"); + CHECK(client.changes >= 1); +} + +TEST_CASE("A background refetch never overwrites what the user is typing into the edit form", + "[bookmarks][controller]") { + DbFixture fixture; + DetailFixture client{Mode::Local}; + std::int64_t const bookmarkId = bookmarks::testing::seedBookmark(*client.rig, "https://typed.example"); + bookmarks::testing::openAndWait(client.detail, bookmarkId); + auto& edit = client.detail.editForm(); + REQUIRE(pumpUntil([&] { return edit.body().has_value(); })); + + // Stands in for the user typing a title. + edit.prefill(R"({"id":)" + std::to_string(bookmarkId) + R"(,"url":"https://typed.example","title":"Typing"})"); + client.detail.refresh(); + REQUIRE(client.detail.loading()); + REQUIRE(pumpUntil([&] { return !client.detail.loading(); })); + REQUIRE(edit.body().has_value()); + CHECK(edit.body()->find("Typing") != std::string::npos); +} + +TEST_CASE("Archive then unarchive the open bookmark, all three backend modes", "[bookmarks][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + DetailFixture client{mode}; + std::int64_t const bookmarkId = bookmarks::testing::seedBookmark(*client.rig, "https://arms.example", {}, + bookmarks::Visibility::Shared); + bookmarks::testing::openAndWait(client.detail, bookmarkId); + CHECK(lineFor(client.detail, "visibility") == "visibility: Shared"); + + client.detail.archive(); + REQUIRE(pumpUntil([&] { return lineFor(client.detail, "archive") == "archive: Archived"; })); + CHECK(client.detail.statusText() == "archived"); + client.detail.unarchive(); + REQUIRE(pumpUntil([&] { return lineFor(client.detail, "archive") == "archive: Active"; })); + CHECK(client.detail.statusText() == "unarchived"); + CHECK(client.changes >= 2); +} + +TEST_CASE("Delete closes the detail, and opening it again fails, all three backend modes", "[bookmarks][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + DetailFixture client{mode}; + std::int64_t const bookmarkId = bookmarks::testing::seedBookmark(*client.rig, "https://doomed.example"); + bookmarks::testing::openAndWait(client.detail, bookmarkId); + + client.detail.remove(); + REQUIRE(pumpUntil([&] { return !client.detail.hasCurrent() && client.detail.statusText() == "deleted"; })); + client.detail.open(bookmarkId); + REQUIRE(pumpUntil([&] { return client.detail.statusIsError(); })); + CHECK_FALSE(client.detail.statusText().empty()); +} + +TEST_CASE("An unknown id reports the model's message, and with nothing open the actions issue nothing", + "[bookmarks][controller]") { + DbFixture fixture; + DetailFixture client{Mode::Local}; + client.detail.archive(); + client.detail.unarchive(); + client.detail.remove(); + CHECK_FALSE(client.detail.hasStatus()); + + client.detail.open(999999); + REQUIRE(pumpUntil([&] { return client.detail.statusIsError(); })); + CHECK_FALSE(client.detail.hasCurrent()); +} + +TEST_CASE("The import form starts with a fresh opId, and an import shows its counts and draws a new one", + "[bookmarks][controller]") { + DbFixture fixture; + DetailFixture client{Mode::Local}; + std::string const firstOpId = client.detail.importOpId(); + CHECK(firstOpId.size() == 36); // RFC 4122 text form + + client.detail.importForm().prefill( + R"({"chunk":"
Imported","opId":")" + firstOpId + R"("})"); + bookmarks::testing::submitAndWait(client.detail.importForm()); + REQUIRE(client.detail.importForm().lastError() == nullptr); + REQUIRE(pumpUntil([&] { return client.detail.statusText() == "imported 1, skipped 0"; })); + CHECK(client.detail.importOpId() != firstOpId); + CHECK(client.changes == 1); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_bookmarks_tests` +Expected: FAIL — `'controllers/bookmark_detail_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bookmarks/app/controllers/bookmark_detail_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/form_success.hpp" +#include "bookmarks/models/bookmark_model.hpp" +#include "controllers/bookmark_text.hpp" + +/// @file +/// The open bookmark: its facts, archive/unarchive/delete, the edit form and the import form. + +namespace bookmarks::client { + +/// @brief One labelled fact about the open bookmark, with the line the view shows. +struct BookmarkFact { + std::string key; ///< The fact's name; also its row key. + std::string line; ///< "key: value". + + /// @brief Field-wise equality. + bool operator==(BookmarkFact const&) const = default; +}; + +/// @brief The open bookmark's facts in display order: url, description, notes, tags, visibility, read, +/// archive, created, updated. +/// @param view The bookmark. +/// @return The facts. +[[nodiscard]] std::vector factsOf(BookmarkView const& view); + +/// @brief The `EditBookmark` body that leaves @p view as it is — what the edit form starts from. +/// @param view The bookmark. +/// @return The JSON body. +[[nodiscard]] std::string editBodyOf(BookmarkView const& view); + +/// @brief An `ImportBookmarks` body with no chunk yet and @p opId as its idempotency key. +/// @param opId The key. +/// @return The JSON body. +[[nodiscard]] std::string importBodyWith(std::string const& opId); + +/// @brief The detail pane's controller. +/// +/// The open bookmark is a `Query` keyed on the open id (and on `signedIn`). Archive and +/// unarchive invalidate it; delete closes it. The edit form is prefilled from each newly loaded bookmark — +/// once per bookmark, so a background refetch never overwrites what the user is typing — and the import +/// form carries a fresh UUID as its `opId`, drawn again after every successful import. +class BookmarkDetailController { +public: + /// @param runtime The runtime; its owner must be @p callbacks. + /// @param bridge The client's bridge. + /// @param callbacks Where replies are delivered. + /// @param signedIn Tracked: whether a session is installed. + /// @param onChanged Called after an archive, unarchive, delete, save or import, for the other panes. + BookmarkDetailController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks, std::function signedIn, + std::function onChanged); + + ~BookmarkDetailController() = default; + BookmarkDetailController(BookmarkDetailController const&) = delete; + BookmarkDetailController& operator=(BookmarkDetailController const&) = delete; + BookmarkDetailController(BookmarkDetailController&&) = delete; + BookmarkDetailController& operator=(BookmarkDetailController&&) = delete; + + /// @brief Opens a bookmark; opening the open one again refetches it. + /// @param bookmarkId The bookmark. + void open(std::int64_t bookmarkId); + + /// @brief Closes the open bookmark. + void close(); + + /// @brief Fetches the open bookmark again; does nothing while none is open. + void refresh() { _detail.refetch(); } + + /// @brief Whether the open bookmark is being fetched. Tracked. + /// @return True from issue to reply. + [[nodiscard]] bool loading() const { return _detail.pending(); } + + /// @brief The open bookmark. Tracked. + /// @return It, or `nullopt`. + [[nodiscard]] std::optional const& current() const { return _detail.value(); } + + /// @brief Whether a bookmark is open and loaded. Tracked. + /// @return True while `current()` is engaged. + [[nodiscard]] bool hasCurrent() const { return _detail.value().has_value(); } + + /// @brief The pane's title: the open bookmark's headline, or a hint. Tracked. + /// @return The title. + [[nodiscard]] std::string const& detailTitle() const { return _title.get(); } + + /// @brief The open bookmark's facts. Tracked. + /// @return `factsOf(*current())`, or empty. + [[nodiscard]] std::vector const& facts() const { return _facts.get(); } + + /// @brief Whether the archive/unarchive/delete buttons act. Tracked. + /// @return True while a bookmark is loaded and none of the three is in flight. + [[nodiscard]] bool canAct() const { return _canAct.get(); } + + /// @brief Archives the open bookmark; does nothing while none is loaded. + void archive(); + + /// @brief Unarchives the open bookmark; does nothing while none is loaded. + void unarchive(); + + /// @brief Deletes the open bookmark; does nothing while none is loaded. + void remove(); + + /// @brief The EditBookmark form. + /// @return Its session. + [[nodiscard]] ::morph::forms::FormSession& editForm() noexcept { return _edit; } + + /// @brief The ImportBookmarks form. + /// @return Its session. + [[nodiscard]] ::morph::forms::FormSession& importForm() noexcept { return _import; } + + /// @brief The idempotency key the import form currently carries. Tracked. + /// @return A lower-case RFC 4122 UUID. + [[nodiscard]] std::string const& importOpId() const { return _opId.get(); } + + /// @brief The status line's text. Tracked. + /// @return The text, or empty. + [[nodiscard]] std::string const& statusText() const { return _status.get().text; } + + /// @brief Whether the status line reports a failure. Tracked. + /// @return True for a failure. + [[nodiscard]] bool statusIsError() const { return _status.get().isError; } + + /// @brief Whether there is a status to show. Tracked. + /// @return True when the text is non-empty. + [[nodiscard]] bool hasStatus() const { return !_status.get().text.empty(); } + +private: + [[nodiscard]] std::optional loaded() const; + void reportError(std::exception_ptr const& error); + void onEdited(); + void onImported(); + void drawImportOpId(); + + ::morph::reactive::Runtime* _rt; + ::morph::bridge::BridgeHandler _handler; + std::function _signedIn; + std::function _onChanged; + ::morph::reactive::Signal> _openId; + ::morph::reactive::Signal _status; + ::morph::reactive::Signal _opId; + // The bookmark the edit form was last prefilled from; 0 for none. + std::int64_t _prefilledFor = 0; + ::morph::reactive::Query _detail; + ::morph::reactive::Mutation _archive; + ::morph::reactive::Mutation _unarchive; + ::morph::reactive::Mutation _delete; + ::morph::forms::FormSession _edit; + ::morph::forms::FormSession _import; + ::morph::reactive::Computed _title; + ::morph::reactive::Computed> _facts; + ::morph::reactive::Computed _canAct; + ::morph::reactive::Effect _onLoaded; + ::morph::reactive::Effect _onDetailFailed; + ::morph::reactive::Effect _onArchived; + ::morph::reactive::Effect _onUnarchived; + ::morph::reactive::Effect _onDeleted; + ::morph::reactive::Effect _onArchiveFailed; + ::morph::reactive::Effect _onUnarchiveFailed; + ::morph::reactive::Effect _onDeleteFailed; + ::morph::examples::FormSuccess _onEditSaved; + // Last: it calls into everything above. + ::morph::examples::FormSuccess _onImportDone; +}; + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/app/controllers/bookmark_detail_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/bookmark_detail_controller.hpp" + +#include +#include +#include +#include + +#include "app/uuid.hpp" +#include "controllers/forms_routing.hpp" + +namespace bookmarks::client { + +std::vector factsOf(BookmarkView const& view) { + auto const fact = [](std::string const& key, std::string const& value) { + return BookmarkFact{.key = key, .line = key + ": " + value}; + }; + return {fact("url", view.url), + fact("description", view.description), + fact("notes", view.notes), + fact("tags", joined(view.tags, ", ")), + fact("visibility", visibilityText(view.visibility)), + fact("read", readStateText(view.readState)), + fact("archive", archiveStateText(view.archiveState)), + fact("created", isoOrEmpty(view.createdAt)), + fact("updated", isoOrEmpty(view.updatedAt))}; +} + +std::string editBodyOf(BookmarkView const& view) { + EditBookmark const edit{.id = view.id, + .url = view.url, + .title = view.title, + .description = view.description, + .notes = view.notes, + .tags = view.tags, + .visibility = view.visibility}; + return glz::write_json(edit).value_or(std::string{"{}"}); +} + +std::string importBodyWith(std::string const& opId) { + ImportBookmarks const action{.chunk = {}, .opId = ImportOpId{opId}}; + return glz::write_json(action).value_or(std::string{"{}"}); +} + +BookmarkDetailController::BookmarkDetailController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks, + std::function signedIn, std::function onChanged) + : _rt{&runtime}, + _handler{bridge, &callbacks}, + _signedIn{std::move(signedIn)}, + _onChanged{std::move(onChanged)}, + _openId{runtime, std::nullopt}, + _status{runtime, StatusLine{}}, + _opId{runtime, std::string{}}, + _detail{runtime, _handler, + [this]() -> std::optional { + auto const& openId = _openId.get(); + if (!openId.has_value() || !_signedIn()) { + return std::nullopt; + } + return GetBookmark{.id = BookmarkId{*openId}}; + }}, + _archive{runtime, _handler, ::morph::reactive::MutationOptions{.invalidates = {&_detail}}}, + _unarchive{runtime, _handler, ::morph::reactive::MutationOptions{.invalidates = {&_detail}}}, + _delete{runtime, _handler}, + _edit{runtime, formModelFor("EditBookmark"), formsSubmitter(bridge, callbacks), + ::morph::forms::bridgeChoiceFetcher(bridge, callbacks)}, + _import{runtime, formModelFor("ImportBookmarks"), formsSubmitter(bridge, callbacks), + ::morph::forms::bridgeChoiceFetcher(bridge, callbacks)}, + _title{runtime, + [this] { + auto const& view = _detail.value(); + return view.has_value() ? headline(view->title, view->url) + : std::string{"no bookmark open: activate one in the list"}; + }}, + _facts{runtime, + [this] { + auto const& view = _detail.value(); + return view.has_value() ? factsOf(*view) : std::vector{}; + }}, + _canAct{runtime, + [this] { + return _detail.value().has_value() && !_archive.pending() && !_unarchive.pending() && + !_delete.pending(); + }}, + _onLoaded{runtime, + [this] { + auto const& view = _detail.value(); + if (!view.has_value() || !view->id.hasValue() || *view->id == _prefilledFor) { + return; + } + std::int64_t const loadedId = *view->id; + std::string const body = editBodyOf(*view); + _rt->untracked([&] { + _prefilledFor = loadedId; + _edit.prefill(body); + }); + }}, + _onDetailFailed{runtime, + [this] { + if (auto const error = _detail.error()) { + _status.set(failureLine(error)); + } + }}, + _onArchived{runtime, + [this] { + if (_archive.lastResult().has_value()) { + _rt->untracked([this] { + _status.set(infoLine("archived")); + _onChanged(); + }); + } + }}, + _onUnarchived{runtime, + [this] { + if (_unarchive.lastResult().has_value()) { + _rt->untracked([this] { + _status.set(infoLine("unarchived")); + _onChanged(); + }); + } + }}, + _onDeleted{runtime, + [this] { + if (_delete.lastResult().has_value()) { + _rt->untracked([this] { + close(); + _status.set(infoLine("deleted")); + _onChanged(); + }); + } + }}, + _onArchiveFailed{runtime, [this] { reportError(_archive.error()); }}, + _onUnarchiveFailed{runtime, [this] { reportError(_unarchive.error()); }}, + _onDeleteFailed{runtime, [this] { reportError(_delete.error()); }}, + _onEditSaved{runtime, _edit, [this] { onEdited(); }}, + _onImportDone{runtime, _import, [this] { onImported(); }} { + drawImportOpId(); +} + +void BookmarkDetailController::open(std::int64_t bookmarkId) { + if (_openId.peek() == std::optional{bookmarkId}) { + _detail.refetch(); + return; + } + _status.set(StatusLine{}); + _openId.set(bookmarkId); +} + +void BookmarkDetailController::close() { + _openId.set(std::nullopt); + _prefilledFor = 0; +} + +std::optional BookmarkDetailController::loaded() const { + auto const& view = _detail.value(); + if (!view.has_value() || !view->id.hasValue()) { + return std::nullopt; + } + return view->id; +} + +void BookmarkDetailController::reportError(std::exception_ptr const& error) { + if (error != nullptr) { + _status.set(failureLine(error)); + } +} + +void BookmarkDetailController::archive() { + if (auto const bookmark = loaded()) { + _archive.run(ArchiveBookmark{.id = *bookmark}); + } +} + +void BookmarkDetailController::unarchive() { + if (auto const bookmark = loaded()) { + _unarchive.run(UnarchiveBookmark{.id = *bookmark}); + } +} + +void BookmarkDetailController::remove() { + if (auto const bookmark = loaded()) { + _delete.run(DeleteBookmark{.id = *bookmark}); + } +} + +void BookmarkDetailController::onEdited() { + _prefilledFor = 0; + _status.set(infoLine("saved")); + _detail.refetch(); + _onChanged(); +} + +void BookmarkDetailController::onImported() { + ImportBookmarksResult result; + auto const& reply = _import.lastReply(); + if (reply.has_value() && !glz::read_json(result, *reply)) { + _status.set(infoLine("imported " + countText(result.imported) + ", skipped " + countText(result.skipped))); + } else { + _status.set(infoLine("imported")); + } + drawImportOpId(); + _onChanged(); +} + +void BookmarkDetailController::drawImportOpId() { + std::string opId = ::morph::examples::newUuid(); + _import.prefill(importBodyWith(opId)); + _opId.set(std::move(opId)); +} + +} // namespace bookmarks::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_bookmarks_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/bookmarks/ladder_bookmarks_tests "[bookmarks][controller]" +``` + +Expected: PASS. Mutation check: in `_onLoaded`, delete `|| *view->id == _prefilledFor`. Expected FAIL in "A +background refetch never overwrites what the user is typing into the edit form" (the refetch prefills again and +"Typing" is gone). Restore. Second: in `onImported()`, delete `drawImportOpId();`. Expected FAIL in "The import +form starts with a fresh opId…" (`importOpId() != firstOpId`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/bookmarks/app/controllers examples/bookmarks/tests/client_fixture.hpp \ + examples/bookmarks/tests/test_bookmark_detail_controller.cpp +git commit -m "wip(bookmarks): the detail controller — open, archive, delete, edit, import + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 14: bookmarks' views + +**Files:** +- Create: `examples/bookmarks/app/views/bookmark_views.{hpp,cpp}` +- Test: `examples/bookmarks/tests/test_bookmark_views.cpp` + +**Interfaces:** +- Consumes: Part 2's `ui` builders (incl. `checkbox`, `spacer`, `switchOn`), `ui::Mounted`, `RecordingBackend` + (`find`, `all`, `prop`, `click`, `toggle`, `selectRows`, `activateRow`); `forms::formView`; the five + controllers (Tasks 10–13). +- Produces: `bookmarks::client::{loginScreen, listPane, detailPane, tagsPane, feedPane, bookmarksScreen}`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/bookmarks/tests/test_bookmark_views.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "client_fixture.hpp" +#include "controllers/session_controller.hpp" +#include "controllers/shared_feed_controller.hpp" +#include "controllers/tag_controller.hpp" +#include "testkit/db_fixture.hpp" +#include "views/bookmark_views.hpp" + +namespace { + +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; +using morph::ui::Key; +using morph::ui::Mounted; +using morph::ui::testing::RecordingBackend; +namespace client = bookmarks::client; + +/// @brief A signed-in client with the four library controllers, wired as the application wires them. +struct LibraryFixture { + LibraryFixture() + : rig{bookmarks::testing::authedRig(Mode::Local)}, + runtime{*rig->executor()}, + feed{runtime, rig->bridge(0), *rig->executor(), [] { return true; }}, + tags{runtime, rig->bridge(0), *rig->executor(), [] { return true; }, [this] { list.refresh(); feed.refresh(); }}, + list{runtime, rig->bridge(0), *rig->executor(), [] { return true; }, [this] { tags.refresh(); feed.refresh(); }}, + detail{runtime, rig->bridge(0), *rig->executor(), [] { return true; }, [this] { list.refresh(); tags.refresh(); }} {} + + std::unique_ptr rig; + morph::reactive::Runtime runtime; + client::SharedFeedController feed; + client::TagController tags; + client::BookmarkListController list; + client::BookmarkDetailController detail; +}; + +} // namespace + +TEST_CASE("loginScreen: the tree, and its form signs in", "[bookmarks][view]") { + DbFixture fixture; + bookmarks::testing::ScopedTokenIssuer const issuer; + BackendRig rig{Mode::Local, 1}; + morph::reactive::Runtime runtime{*rig.executor()}; + client::SessionController session{runtime, rig.bridge(0), *rig.executor()}; + RecordingBackend backend; + Mounted const mounted{runtime, backend, client::loginScreen(session)}; + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", "Sign in").has_value(); })); + auto const submit = backend.find("Button", "label", "Sign in"); + REQUIRE(submit.has_value()); + + session.loginForm().prefill(R"({"username":"alice"})"); + REQUIRE(pumpUntil([&] { return backend.prop(*submit, "enabled") == "true"; })); + backend.click(*submit); + REQUIRE(pumpUntil([&] { return session.signedIn(); })); +} + +TEST_CASE("listPane: the tree, the archived toggle, the selection and bulk archive", "[bookmarks][view]") { + DbFixture fixture; + LibraryFixture library; + std::int64_t const first = bookmarks::testing::seedBookmark(*library.rig, "https://one.example"); + std::int64_t const second = bookmarks::testing::seedBookmark(*library.rig, "https://two.example"); + library.list.refresh(); + RecordingBackend backend; + Mounted const mounted{library.runtime, backend, client::listPane(library.list, library.detail)}; + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", "2 bookmark(s)").has_value(); })); + CHECK(backend.find("Button", "label", "Create bookmark").has_value()); + auto const bulk = backend.find("Button", "label", "Bulk archive"); + REQUIRE(bulk.has_value()); + CHECK(backend.prop(*bulk, "enabled") == "false"); + + auto const tables = backend.all("Table"); + REQUIRE(tables.size() == 1); + backend.selectRows(tables.front(), {Key{first}, Key{second}}); + REQUIRE(pumpUntil([&] { return backend.prop(*bulk, "enabled") == "true"; })); + CHECK(backend.find("Text", "text", "2 selected").has_value()); + backend.click(*bulk); + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", "0 bookmark(s)").has_value(); })); + + auto const archived = backend.find("Checkbox", "label", "show archived"); + REQUIRE(archived.has_value()); + backend.toggle(*archived); + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", "2 bookmark(s)").has_value(); })); + + backend.activateRow(tables.front(), Key{first}); + REQUIRE(pumpUntil([&] { return library.detail.hasCurrent(); })); +} + +TEST_CASE("detailPane: the tree, and its buttons drive the open bookmark", "[bookmarks][view]") { + DbFixture fixture; + LibraryFixture library; + std::int64_t const bookmarkId = bookmarks::testing::seedBookmark(*library.rig, "https://open.example"); + RecordingBackend backend; + Mounted const mounted{library.runtime, backend, client::detailPane(library.detail)}; + REQUIRE(pumpUntil([&] { return backend.find("Panel", "title", "no bookmark open: activate one in the list").has_value(); })); + auto const archive = backend.find("Button", "label", "Archive"); + REQUIRE(archive.has_value()); + CHECK(backend.prop(*archive, "enabled") == "false"); + CHECK(backend.find("Button", "label", "Import").has_value()); + + bookmarks::testing::openAndWait(library.detail, bookmarkId); + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", "url: https://open.example").has_value(); })); + CHECK(backend.find("Button", "label", "Save changes").has_value()); + backend.click(*archive); + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", "archive: Archived").has_value(); })); + CHECK(backend.find("Text", "text", "archived").has_value()); +} + +TEST_CASE("tagsPane: the tree, and its rename form renames", "[bookmarks][view]") { + DbFixture fixture; + LibraryFixture library; + static_cast(bookmarks::testing::seedBookmark(*library.rig, "https://one.example", {"work"})); + library.tags.refresh(); + RecordingBackend backend; + Mounted const mounted{library.runtime, backend, client::tagsPane(library.tags)}; + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", "work").has_value(); })); + CHECK(backend.find("Panel", "title", "Tags (1)").has_value()); + auto const rename = backend.find("Button", "label", "Rename"); + REQUIRE(rename.has_value()); + CHECK(backend.find("Button", "label", "Merge").has_value()); + + std::int64_t const tagId = library.tags.rows().front().tagId; + library.tags.renameForm().prefill(R"({"id":)" + std::to_string(tagId) + R"(,"name":"office"})"); + REQUIRE(pumpUntil([&] { return backend.prop(*rename, "enabled") == "true"; })); + backend.click(*rename); + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", "office").has_value(); })); +} + +TEST_CASE("feedPane: the tree shows shared bookmarks only", "[bookmarks][view]") { + DbFixture fixture; + LibraryFixture library; + static_cast(bookmarks::testing::seedBookmark(*library.rig, "https://private.example")); + static_cast(bookmarks::testing::seedBookmark(*library.rig, "https://shared.example", {}, + bookmarks::Visibility::Shared)); + library.feed.refresh(); + RecordingBackend backend; + Mounted const mounted{library.runtime, backend, client::feedPane(library.feed)}; + REQUIRE(pumpUntil([&] { return backend.find("Panel", "title", "Shared feed (1)").has_value(); })); + CHECK(library.feed.rows().front().line.starts_with("https://shared.example · ")); + CHECK(backend.find("Text", "text", library.feed.rows().front().line).has_value()); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_bookmarks_tests` +Expected: FAIL — `'views/bookmark_views.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/bookmarks/app/views/bookmark_views.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "controllers/bookmark_detail_controller.hpp" +#include "controllers/bookmark_list_controller.hpp" +#include "controllers/session_controller.hpp" +#include "controllers/shared_feed_controller.hpp" +#include "controllers/tag_controller.hpp" + +/// @file +/// bookmarks' screens as view trees: bindings over the controllers, and nothing else. Every controller +/// passed in is borrowed and must outlive the mounted tree. + +namespace bookmarks::client { + +/// @brief The sign-in screen: the Login form. +/// @param session The sign-in controller. +/// @return The screen. +[[nodiscard]] ::morph::ui::Node loginScreen(SessionController& session); + +/// @brief The collection: the create form, the archived toggle, the multi-select table and bulk archive. +/// Activating a row opens it in @p detail. +/// @param list The list controller. +/// @param detail The detail controller rows open into. +/// @return The pane. +[[nodiscard]] ::morph::ui::Node listPane(BookmarkListController& list, BookmarkDetailController& detail); + +/// @brief The open bookmark: its facts, archive/unarchive/delete, the edit and import forms. +/// @param detail The detail controller. +/// @return The pane. +[[nodiscard]] ::morph::ui::Node detailPane(BookmarkDetailController& detail); + +/// @brief The caller's tags, with the rename and merge forms. +/// @param tags The tags controller. +/// @return The pane. +[[nodiscard]] ::morph::ui::Node tagsPane(TagController& tags); + +/// @brief The cross-user shared feed. +/// @param feed The feed controller. +/// @return The pane. +[[nodiscard]] ::morph::ui::Node feedPane(SharedFeedController& feed); + +/// @brief The whole client: a header naming who is signed in, over the sign-in screen or the library. +/// @param session The sign-in controller; its `route()` picks the screen. +/// @param list The list controller. +/// @param detail The detail controller. +/// @param tags The tags controller. +/// @param feed The feed controller. +/// @return The root node. +[[nodiscard]] ::morph::ui::Node bookmarksScreen(SessionController& session, BookmarkListController& list, + BookmarkDetailController& detail, TagController& tags, + SharedFeedController& feed); + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/app/views/bookmark_views.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "views/bookmark_views.hpp" + +#include +#include +#include +#include +#include +#include + +namespace bookmarks::client { + +namespace { + +namespace ui = ::morph::ui; + +[[nodiscard]] std::vector keysOf(std::vector const& bookmarkIds) { + std::vector keys; + keys.reserve(bookmarkIds.size()); + for (std::int64_t const bookmarkId : bookmarkIds) { + keys.emplace_back(bookmarkId); + } + return keys; +} + +[[nodiscard]] std::vector idsOf(std::vector const& keys) { + std::vector bookmarkIds; + for (ui::Key const& key : keys) { + if (auto const* bookmarkId = std::get_if(&key)) { + bookmarkIds.push_back(*bookmarkId); + } + } + return bookmarkIds; +} + +[[nodiscard]] ui::Node form(::morph::forms::FormSession& session, std::string submitLabel) { + return ::morph::forms::formView(session, {.overrides = {}, .gridColumns = 1, .submitLabel = std::move(submitLabel)}); +} + +template +[[nodiscard]] ui::Node statusOf(Controller& controller) { + return ui::text({.text = [&controller] { return controller.statusText(); }, + .role = [&controller] { + return controller.statusIsError() ? ui::TextRole::Error : ui::TextRole::Success; + }, + .common = {.visible = [&controller] { return controller.hasStatus(); }}}); +} + +template +[[nodiscard]] ui::Node errorOf(Controller& controller) { + return ui::text({.text = [&controller] { return controller.errorText(); }, + .role = ui::TextRole::Error, + .common = {.visible = [&controller] { return controller.hasError(); }}}); +} + +[[nodiscard]] ui::Node bookmarkTable(BookmarkListController& list, BookmarkDetailController& detail) { + return ui::table( + {ui::TableColumn{.label = "Bookmark", .width = ui::Sizing::stretch()}, ui::TableColumn{.label = "State"}}, + [&list] { return list.rows(); }, [](BookmarkRow const& row) { return ui::Key{row.bookmarkId}; }, + [](::morph::reactive::Signal const& row) { + return std::vector{ + ui::text({.text = [&row] { return row.get().title; }}), + ui::text({.text = [&row] { return row.get().state; }, .role = ui::TextRole::Muted})}; + }, + ui::TableOptions{ + .selectionMode = ui::SelectionMode::Multiple, + .selection = [&list] { return keysOf(list.selection()); }, + .onSelectionChange = [&list](std::vector const& keys) { list.select(idsOf(keys)); }, + .onActivate = + [&detail](ui::Key const& key) { + if (auto const* bookmarkId = std::get_if(&key)) { + detail.open(*bookmarkId); + } + }, + .common = {.layout = {.height = ui::Sizing::stretch()}}}); +} + +[[nodiscard]] ui::Node actionButton(BookmarkDetailController& detail, std::string label, + void (BookmarkDetailController::*act)()) { + return ui::button({.label = std::move(label), + .onClick = [&detail, act] { (detail.*act)(); }, + .common = {.enabled = [&detail] { return detail.canAct(); }}}); +} + +} // namespace + +::morph::ui::Node loginScreen(SessionController& session) { + return ui::column( + {.children = {ui::text({.text = "Sign in", .role = ui::TextRole::Heading}), + ui::text({.text = "Dev-mode login: a username, no password. The token the server mints for " + "it is real, server-signed and checked on every later action.", + .role = ui::TextRole::Muted}), + form(session.loginForm(), "Sign in")}, + .gap = 1}); +} + +::morph::ui::Node listPane(BookmarkListController& list, BookmarkDetailController& detail) { + return ui::column( + {.children = {statusOf(list), + ui::panel({.title = "New bookmark", .padding = 1, .child = form(list.createForm(), "Create bookmark")}), + ui::row({.children = {ui::button({.label = "Refresh", .onClick = [&list] { list.refreshAll(); }}), + ui::checkbox({.label = "show archived", + .checked = [&list] { return list.includeArchived(); }, + .onToggle = [&list](bool include) { + list.setIncludeArchived(include); + }}), + ui::busy({.active = [&list] { return list.loading(); }, .label = "loading"}), + ui::text({.text = [&list] { return list.countText(); }, + .role = ui::TextRole::Muted})}, + .gap = 2}), + bookmarkTable(list, detail), + ui::row({.children = {ui::text({.text = [&list] { return list.selectionText(); }, + .role = ui::TextRole::Muted}), + ui::button({.label = "Bulk archive", + .onClick = [&list] { list.bulkArchive(); }, + .common = {.enabled = [&list] { return list.canBulkEdit(); }}}), + ui::button({.label = "Bulk unarchive", + .onClick = [&list] { list.bulkUnarchive(); }, + .common = {.enabled = [&list] { return list.canBulkEdit(); }}})}, + .gap = 2})}, + .gap = 1, + .common = {.layout = {.width = ui::Sizing::stretch()}}}); +} + +::morph::ui::Node detailPane(BookmarkDetailController& detail) { + return ui::panel( + {.title = [&detail] { return detail.detailTitle(); }, + .padding = 1, + .child = ui::column( + {.children = + {statusOf(detail), + ui::forEach( + [&detail] { return detail.facts(); }, [](BookmarkFact const& fact) { return ui::Key{fact.key}; }, + [](::morph::reactive::Signal const& fact) { + return ui::text({.text = [&fact] { return fact.get().line; }}); + }), + ui::row({.children = {actionButton(detail, "Archive", &BookmarkDetailController::archive), + actionButton(detail, "Unarchive", &BookmarkDetailController::unarchive), + actionButton(detail, "Delete", &BookmarkDetailController::remove)}, + .gap = 2}), + ui::scroll( + {.child = ui::column( + {.children = {ui::panel({.title = "Edit", + .padding = 1, + .child = form(detail.editForm(), "Save changes"), + .common = {.visible = [&detail] { return detail.hasCurrent(); }}}), + ui::panel({.title = "Import a Netscape bookmark file", + .padding = 1, + .child = form(detail.importForm(), "Import")})}, + .gap = 1}), + .common = {.layout = {.height = ui::Sizing::stretch()}}})}, + .gap = 1}), + .common = {.layout = {.width = ui::Sizing::stretch()}}}); +} + +::morph::ui::Node tagsPane(TagController& tags) { + return ui::panel( + {.title = [&tags] { return tags.heading(); }, + .padding = 1, + .child = ui::column( + {.children = {errorOf(tags), + ui::table( + {ui::TableColumn{.label = "#"}, ui::TableColumn{.label = "Tag", .width = ui::Sizing::stretch()}, + ui::TableColumn{.label = "Bookmarks"}}, + [&tags] { return tags.rows(); }, [](TagRow const& row) { return ui::Key{row.tagId}; }, + [](::morph::reactive::Signal const& row) { + return std::vector{ + ui::text({.text = [&row] { return row.get().idText; }, .role = ui::TextRole::Muted}), + ui::text({.text = [&row] { return row.get().name; }}), + ui::text({.text = [&row] { return row.get().bookmarkCount; }})}; + }, + ui::TableOptions{}), + ui::panel({.title = "Rename a tag", .padding = 1, .child = form(tags.renameForm(), "Rename")}), + ui::panel({.title = "Merge two tags", .padding = 1, .child = form(tags.mergeForm(), "Merge")})}, + .gap = 1})}); +} + +::morph::ui::Node feedPane(SharedFeedController& feed) { + return ui::panel({.title = [&feed] { return feed.heading(); }, + .padding = 1, + .child = ui::column({.children = {errorOf(feed), + ui::forEach( + [&feed] { return feed.rows(); }, + [](FeedRow const& row) { return ui::Key{row.bookmarkId}; }, + [](::morph::reactive::Signal const& row) { + return ui::text({.text = [&row] { return row.get().line; }}); + })}, + .gap = 1})}); +} + +::morph::ui::Node bookmarksScreen(SessionController& session, BookmarkListController& list, + BookmarkDetailController& detail, TagController& tags, SharedFeedController& feed) { + ui::Node const library = ui::row( + {.children = {listPane(list, detail), detailPane(detail), + ui::column({.children = {tagsPane(tags), feedPane(feed)}, + .gap = 1, + .common = {.layout = {.width = ui::Sizing::stretch()}}})}, + .gap = 2, + .common = {.layout = {.height = ui::Sizing::stretch()}}}); + return ui::column( + {.children = {ui::row({.children = {ui::text({.text = "bookmarks", .role = ui::TextRole::Heading}), + ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::text({.text = [&session] { return session.principalText(); }, + .role = ui::TextRole::Muted})}, + .gap = 2}), + ui::switchOn([&session] { return session.route(); }, + {{Route::SignIn, loginScreen(session)}, {Route::Library, library}})}, + .gap = 1}); +} + +} // namespace bookmarks::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_bookmarks_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/bookmarks/ladder_bookmarks_tests "[bookmarks][view]" +``` + +Expected: PASS. Mutation check: in `bookmarkTable`, change `.selectionMode = ui::SelectionMode::Multiple` to +`ui::SelectionMode::Single`. Expected FAIL in "listPane: the tree, the archived toggle, the selection and bulk +archive" (a single-selection table keeps one key, so "2 selected" never appears). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/bookmarks/app/views examples/bookmarks/tests/test_bookmark_views.cpp +git commit -m "wip(bookmarks): the sign-in, list, detail, tags and feed views + +Signed-off-by: Christian Parpart " +``` + +--- +### Task 15: bookmarks' application, its one binary, and the old client deleted + +**Files:** +- Create: `examples/bookmarks/app/bookmarks_application.{hpp,cpp}`, `examples/bookmarks/ui/main.cpp` +- Modify: `codecov.yml` — bookmarks' two `ignore:` entries +- Delete: `examples/bookmarks/gui/`, `examples/bookmarks/gui_lib/`, `examples/bookmarks/gui_wasm/`, + `examples/bookmarks/tests/`: `test_bookmark_presenter.cpp`, `test_bookmark_qml_bridges.cpp`, + `test_shared_feed_presenter.cpp`, `test_tag_presenter.cpp`, `test_gui_qml_smoke.cpp` +- Test: `examples/bookmarks/tests/smoke/test_bookmarks_frontends.cpp` (binary `ladder_bookmarks_smoke_tests`, C3) + +**Interfaces:** +- Consumes: as Task 6, plus `bookmarks::db::setup`, `bookmarks::auth::setTokenIssuer`, `session::TokenIssuer`; + the five controllers and `bookmarksScreen` (Tasks 10–14). +- Produces: `bookmarks::client::BookmarksApplication(ui::AppContext&, examples::AppEnvironment const&)`, + `bookmarks::client::makeApplication(ui::AppContext&, examples::AppEnvironment const&)` (the contract's convention: + the local setup — the database from `env.db`, else `BOOKMARKS_DB`, else `bookmarks.db`, and the in-process + token issuer — is built inside); the `bookmarks` binary. + +Claims of the deleted test files, and where each one now lives: + +| Deleted case | Re-expressed as | +|---|---| +| presenter: create then get (3 modes) | `test_bookmark_list_controller.cpp` "The create form stores a bookmark…" + `test_bookmark_detail_controller.cpp` "Opening a bookmark shows every fact" | +| presenter: edit (3 modes) | "The edit form is prefilled from the open bookmark, and a save shows on reload" | +| presenter: archive then unarchive (3 modes) | "Archive then unarchive the open bookmark" | +| presenter: remove, then get fails (3 modes) | "Delete closes the detail, and opening it again fails" | +| presenter: list returns what was created (3 modes) | "The create form stores a bookmark…" | +| presenter: getChangesSince (3 modes) | dropped — no screen asks for changes (README, client gaps); `test_bookmark_model.cpp` "GetChangesSince returns only bookmarks touched after the given instant" and its two neighbours | +| presenter: bulkEdit (3 modes) | "Bulk archive and unarchive act on the selection and clear it" | +| presenter: importChunk then exportAll (3 modes) | import: "The import form starts with a fresh opId…"; export: dropped — no screen exports; `test_bookmark_model.cpp` "ExportBookmarks emits every owned bookmark as a Netscape file, and it re-imports" | +| presenter: every validation-driven action routes its failure | create: "The create form refuses an empty url…"; edit/import: their forms are not ready without an id/chunk (the detail case's `edit.body()` wait); archive/unarchive/delete/get with no id: "…with nothing open the actions issue nothing"; bulk with no ids: "With nothing selected the bulk actions issue nothing" | +| presenter: get unknown id | "An unknown id reports the model's message…" | +| presenter: list/changes/export with no session | "Signed out the list is idle; with no session at all it reports, not crashes" | +| tag presenter: list (3 modes) | `test_tag_and_feed_controllers.cpp` "TagController lists every tag the caller owns…" | +| tag presenter: rename (3 modes) | "TagController renames a tag through its form…" | +| tag presenter: merge (3 modes) | "TagController merges two tags through its form" | +| tag presenter: every action routes its failure | "The tag forms show the model's refusal, and nothing changes" | +| tag presenter: list with no session | "Signed out, the tag and feed queries are idle…" | +| feed presenter: shared only (3 modes) | "SharedFeedController lists every shared bookmark and never a private one" | +| feed presenter: no session | "Signed out, the tag and feed queries are idle…" | +| bridges: QML surface audit | dropped with the QML (spec 4 §7) | +| bridges: metaobject facts (CONSTANT schemas, six schemas) | `test_forms_routing.cpp` "Every one of the six forms reads its schema and submits explicitly" | +| bridges: every form opts out of auto-submit | same case | +| bridges: a read-only action carries no x-submitMode | same case (`ListBookmarks` is `Automatic`) | +| bridges: the open bag carries every key | "Opening a bookmark shows every fact" | +| bridges: rows in the narrower shape, no notes | "The create form stores a bookmark…" (+ `static_assert(!CarriesNotes)`) | +| bridges: tag rows {id, name, count} | "TagController lists every tag…" | +| bridges: feed rows, Shared only | "SharedFeedController lists every shared bookmark…" (+ `static_assert(!CarriesNotes)`) | +| bridges: second arm of visibility/archive renderers | "Archive then unarchive the open bookmark" (Shared, Archived) + `test_bookmark_text.cpp` | +| bridges: bulkArchive true/false → Archive/Unarchive | "Bulk archive and unarchive act on the selection and clear it" | +| bridges: the six actions route to their models | "loginSubmitter routes each of the six form actions to the model that serves it" | +| bridges: an unrouted action type is reported | "formsSubmitter refuses an action outside the six, naming it…" | +| bridges: destroyed with a submit in flight | "a bookmark list destroyed with a create in flight drops its reply" | +| bridges: token installed, loggedIn before replyReceived | "A Login reply is redacted, and the session is installed before it shows" | +| bridges: decodeLoginResult accepts/rejects | "decodeLoginResult accepts a real Login reply and rejects anything that is not one" | +| bridges: decodeLoginResult reads back a redacted reply | "A Login reply is redacted…" | +| smoke: Main.qml loads; the post-login screen loads | `tests/smoke/test_bookmarks_frontends.cpp` (TUI and Qt Quick) and `test_bookmark_views.cpp` (each screen) | + +- [ ] **Step 1: Write the failing test** + +Create `examples/bookmarks/tests/smoke/test_bookmarks_frontends.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "app/app_environment.hpp" +#include "bookmarks_application.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/frontend_smoke.hpp" + +#if MORPH_EXAMPLE_HAS_TUI || MORPH_EXAMPLE_HAS_QT_QUICK + +namespace { + +/// @brief The application in local mode, `--db` naming the database `DbFixture` already prepared. +[[nodiscard]] morph::ui::ApplicationFactory bookmarksFactory() { + return [](morph::ui::AppContext& ctx) { + morph::examples::AppEnvironment env; + env.db = morph::ladder::testkit::DbFixture::computeConnectionString(std::getenv("ODBC_CONNECTION_STRING")); + return bookmarks::client::makeApplication(ctx, env); + }; +} + +} // namespace + +#endif + +#if MORPH_EXAMPLE_HAS_TUI +TEST_CASE("bookmarks mounts and quits on the TUI", "[bookmarks][frontend]") { + morph::ladder::testkit::DbFixture const fixture; + morph::examples::testing::runFrontendSmoke(bookmarksFactory(), morph::examples::testing::SmokeFrontend::Tui); +} +#endif + +#if MORPH_EXAMPLE_HAS_QT_QUICK +TEST_CASE("bookmarks mounts and quits on Qt Quick", "[bookmarks][frontend]") { + morph::ladder::testkit::DbFixture const fixture; + morph::examples::testing::runFrontendSmoke(bookmarksFactory(), + morph::examples::testing::SmokeFrontend::QtQuick); +} +#endif +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_bookmarks_smoke_tests` +Expected: FAIL — `'bookmarks_application.hpp' file not found`. + +- [ ] **Step 3: Implement the application and the binary** + +Create `examples/bookmarks/app/bookmarks_application.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include "app/app_environment.hpp" +#include "app/transport.hpp" +#include "controllers/bookmark_detail_controller.hpp" +#include "controllers/bookmark_list_controller.hpp" +#include "controllers/session_controller.hpp" +#include "controllers/shared_feed_controller.hpp" +#include "controllers/tag_controller.hpp" + +/// @file +/// bookmarks as a `ui::Application`: one connection, the sign-in controller, and the four library panes. + +namespace bookmarks::client { + +/// @brief bookmarks' application. +/// +/// Every library query is keyed on the session controller's `signedIn()`, so the sign-in screen issues +/// nothing but the Login it submits. A change one pane makes refreshes the panes that show it too: a +/// rename shows in the list, the open bookmark and the feed; a create or a bulk edit in the tags and the +/// feed. Members die detail first and the connection last. +class BookmarksApplication final : public ::morph::ui::Application { +public: + /// @param ctx The frontend's context. + /// @param env What the command line (or the browser build) asked for; without `--server` its `db` (else + /// `BOOKMARKS_DB`, else `bookmarks.db` in the working directory) is the database set up and hosted. + BookmarksApplication(::morph::ui::AppContext& ctx, ::morph::examples::AppEnvironment const& env); + + ~BookmarksApplication() override = default; + BookmarksApplication(BookmarksApplication const&) = delete; + BookmarksApplication& operator=(BookmarksApplication const&) = delete; + BookmarksApplication(BookmarksApplication&&) = delete; + BookmarksApplication& operator=(BookmarksApplication&&) = delete; + + /// @brief The client's root view. + /// @return `bookmarksScreen()` over the controllers. + [[nodiscard]] ::morph::ui::Node view() override; + +private: + std::unique_ptr<::morph::examples::Connection> _connection; + SessionController _session; + SharedFeedController _feed; + TagController _tags; + BookmarkListController _list; + BookmarkDetailController _detail; +}; + +/// @brief The application factory `ui/main.cpp` and the smoke tests share. +/// @param ctx The frontend's context. +/// @param env The environment. +/// @return The application. +[[nodiscard]] std::unique_ptr<::morph::ui::Application> makeApplication(::morph::ui::AppContext& ctx, + ::morph::examples::AppEnvironment const& env); + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/app/bookmarks_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "bookmarks_application.hpp" + +#include +#include +#include + +#include "views/bookmark_views.hpp" + +#ifndef __EMSCRIPTEN__ +#include + +#include "bookmarks/auth/bookmarks_authorizer.hpp" +#include "bookmarks/db/database.hpp" +#endif + +namespace bookmarks::client { + +namespace { + +/// @brief How a client without `--server` prepares itself: the database from `--db`, else `BOOKMARKS_DB`, else +/// `bookmarks.db`; and a token issuer, because `AuthModel` mints Login tokens in-process and a local +/// deployment has no server secret. A local client has no authorizer either, so it is single-user by +/// construction. The browser build is always remote and never calls it. +[[nodiscard]] ::morph::examples::LocalSetup localSetup() { +#ifdef __EMSCRIPTEN__ + return ::morph::examples::LocalSetup{}; +#else + return ::morph::examples::LocalSetup{.setupDatabase = [](std::string const& database) { + std::string connection = database; + if (connection.empty()) { + char const* fromEnvironment = std::getenv("BOOKMARKS_DB"); + connection = fromEnvironment != nullptr ? fromEnvironment + : "DRIVER=SQLite3;Database=bookmarks.db;Timeout=5000"; + } + db::setup(connection); + auth::setTokenIssuer(std::make_shared<::morph::session::TokenIssuer>( + std::string{"local-mode-development-secret"}, ::morph::session::hmacSha256)); + }}; +#endif +} + +} // namespace + +BookmarksApplication::BookmarksApplication(::morph::ui::AppContext& ctx, ::morph::examples::AppEnvironment const& env) + : _connection{::morph::examples::connect(ctx, env, localSetup())}, + _session{ctx.runtime(), _connection->bridge(), _connection->callbacks()}, + _feed{ctx.runtime(), _connection->bridge(), _connection->callbacks(), [this] { return _session.signedIn(); }}, + _tags{ctx.runtime(), _connection->bridge(), _connection->callbacks(), [this] { return _session.signedIn(); }, + [this] { + _list.refresh(); + _detail.refresh(); + _feed.refresh(); + }}, + _list{ctx.runtime(), _connection->bridge(), _connection->callbacks(), [this] { return _session.signedIn(); }, + [this] { + _tags.refresh(); + _detail.refresh(); + _feed.refresh(); + }}, + _detail{ctx.runtime(), _connection->bridge(), _connection->callbacks(), [this] { return _session.signedIn(); }, + [this] { + _list.refresh(); + _tags.refresh(); + _feed.refresh(); + }} {} + +::morph::ui::Node BookmarksApplication::view() { return bookmarksScreen(_session, _list, _detail, _tags, _feed); } + +std::unique_ptr<::morph::ui::Application> makeApplication(::morph::ui::AppContext& ctx, + ::morph::examples::AppEnvironment const& env) { + return std::make_unique(ctx, env); +} + +} // namespace bookmarks::client +``` + +Create `examples/bookmarks/ui/main.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// bookmarks' one binary, native and WebAssembly: it reads the environment, offers every frontend this +/// build has, lets `ui::selectFrontend` pick one, and hands the frontend the application factory. +/// +/// @code +/// bookmarks # in-process: hosts the models over --db / BOOKMARKS_DB +/// bookmarks --server ws://127.0.0.1:8766 # against ladder_bookmarks_server +/// bookmarks --ui=tui # force the terminal UI +/// @endcode +/// +/// The browser build is always remote, against the url baked in as `MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL` (a +/// `?server=` in the page url wins). + +#include +#include +#include +#include +#include + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#endif +#if MORPH_EXAMPLE_HAS_TUI +#include +#endif + +#include "app/app_environment.hpp" +#include "bookmarks_application.hpp" + +int main(int argc, char** argv) { + try { + auto env = morph::examples::AppEnvironment::fromArgs(argc, argv); +#ifdef __EMSCRIPTEN__ + if (!env.server.has_value()) { + env.server = std::string{MORPH_LADDER_BOOKMARKS_WASM_SERVER_URL}; + } +#endif + std::vector built; +#if MORPH_EXAMPLE_HAS_QT_QUICK + built.push_back(morph::qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_EXAMPLE_HAS_TUI + built.push_back(morph::tui::frontendOption()); +#endif + auto const frontend = morph::ui::selectFrontend(built, argc, argv); + return frontend->run( + [&env](morph::ui::AppContext& ctx) { return bookmarks::client::makeApplication(ctx, env); }); + } catch (std::exception const& error) { + std::cerr << "bookmarks: " << error.what() << '\n'; + } catch (...) { + std::cerr << "bookmarks: unknown error\n"; + } + return 1; +} +``` + +- [ ] **Step 4: Delete the old client, its tests, and its coverage exclusions** + +```bash +git rm -r -q examples/bookmarks/gui examples/bookmarks/gui_lib examples/bookmarks/gui_wasm \ + examples/bookmarks/tests/test_bookmark_presenter.cpp examples/bookmarks/tests/test_bookmark_qml_bridges.cpp \ + examples/bookmarks/tests/test_shared_feed_presenter.cpp examples/bookmarks/tests/test_tag_presenter.cpp \ + examples/bookmarks/tests/test_gui_qml_smoke.cpp +``` + +In `codecov.yml`, replace the `ignore:` entries `"examples/bookmarks/gui/**"` and `"examples/bookmarks/gui_wasm/**"` +with the one entry `"examples/bookmarks/ui/**"`. + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cmake --build build/all --target ladder_bookmarks_tests ladder_bookmarks_smoke_tests bookmarks ladder_bookmarks_server +QT_QPA_PLATFORM=offscreen ./build/all/examples/bookmarks/ladder_bookmarks_tests "[bookmarks]" +ctest --test-dir build/all -R '^bookmarks\.smoke\.' --output-on-failure +ls examples/bookmarks # CMakeLists.txt README.md app include src tests ui +``` + +Expected: PASS; the `ctest` line runs both frontend cases. Mutation check: change +`BookmarksApplication::view()`'s body to `throw std::logic_error{"no view"};` (with `#include `). +Expected FAIL in both `bookmarks.smoke.` cases. Restore. + +- [ ] **Step 6: Commit** + +```bash +git add -A examples/bookmarks codecov.yml +git commit -m "wip(bookmarks): the application, one binary for every frontend, the QML client removed + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 16: bookmarks' README + +**Files:** +- Modify: `examples/bookmarks/README.md` — "Running it", one Definition-of-done bullet, the first-page gap, and + the section "The client, and its known gaps — stated rather than smoothed over" up to its subsection + "Two bugs the first real client run found" (kept as it is) + +**Interfaces:** none. + +- [ ] **Step 1: List the stale references** + +```bash +grep -n 'gui_lib\|gui_wasm\|ladder_bookmarks_gui\|gui/main\.cpp\|gui/qml\|DynamicForm\|BookmarkBridge\|FormsBridge\|QML binding\|presenter level' examples/bookmarks/README.md +``` + +Expected: the lines the next step replaces (31–33, 386, 433–434, 442–533). + +- [ ] **Step 2: Rewrite** + +Replace the "Running it" code block with: + +````markdown +```bash +# One-time configure (Qt 6.5+ and an ODBC SQLite3 driver; the client needs at +# least one frontend, the terminal UI or Qt Quick): +cmake -S . -B build -G Ninja \ + -DMORPH_BUILD_QT=ON -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT_QUICK=ON \ + -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=bookmarks + +# Server (owns the database, the signing secret, the action journal, the +# metadata-fetch worker and the outbox relay). The secret is required and has +# no default: it signs every token the server mints and verifies every token +# it is shown, so a built-in fallback would be a published signing key. +BOOKMARKS_TOKEN_SECRET="pick-something-real" \ +BOOKMARKS_DB="DRIVER=SQLite3;Database=bookmarks.db;Timeout=5000" \ +BOOKMARKS_PORT=8766 ./build/examples/bookmarks/ladder_bookmarks_server + +# The client, either deployment mode, on whichever frontend suits: +./build/examples/bookmarks/bookmarks # in-process +./build/examples/bookmarks/bookmarks --server ws://127.0.0.1:8766 +./build/examples/bookmarks/bookmarks --ui=tui # force the terminal UI +``` +```` + +In "Definition of done" (a `\n` in a quoted original marks a line break in the file), replace "This criterion is +met at the model and presenter level, where\n `GetChangesSince` +is implemented, tested and exposed." with "This criterion is met at the model level, where\n `GetChangesSince` is +implemented and tested." + +Replace the Known-gaps bullet "**The GUI never leaves the first page.**" with: + +```markdown +- **The client never leaves the first page.** `BookmarkListController` and `SharedFeedController` query the + first page and nothing asks for a further one, so the client shows at most the first ~20 bookmarks (and the + first ~20 shared-feed entries). Pagination is fully implemented and tested at the model level — the keyset + cursor works — it is only the client that does not use it. +``` + +Replace the section body from "## The client, and its known gaps — stated rather than smoothed over" down to (not +including) "### Two bugs the first real client run found" with: + +```markdown +## The client, and its known gaps — stated rather than smoothed over + +The client (`app/`, target `ladder_bookmarks_app`) is toolkit-free and schema-driven throughout +(`../IMPLEMENTATION.md` rule 2). `Login`, `CreateBookmark`, `EditBookmark`, `ImportBookmarks`, `RenameTag` and +`MergeTags` are runtime forms (`forms::FormSession` over the schema each action emits), rendered by +`forms::formView` on whichever frontend runs — including the sign-in screen: there is **no hand-built username +field**. Each of the six declares `explicitSubmit = true`, so each form has its own gated Submit button. +`controllers/forms_routing.cpp` routes the six across `AuthModel`, `BookmarkModel` and `TagModel` through the +bridge's registry and refuses any other action type by name. The one non-form input on the screen is the +table's multiple selection, which types nothing; it drives `BulkEdit` through `BookmarkListController`. + +One piece of glue carries its own written justification, per rule 2's "(b) pure glue with no domain logic" +clause: + +- `loginSubmitter` installs the token the server returned as the bridge's default session — before the Login + reply resolves, so every later call carries it — and hands the form a copy of the reply with the token + removed: a bearer credential has no reason to reach anything a view can show. It decides nothing; the token + and the principal are the server's. + +What the client shows differently from the QML client it replaced: + +- The collection is a table with multiple selection (bookmark, state) instead of a list with a checkbox per + row; activating a row (Enter, a double click) opens it in the detail pane. +- The edit form starts from the open bookmark — its id and fields prefilled — instead of empty; it is hidden + while nothing is open. A background refresh does not overwrite what is being typed. +- The import form carries a fresh UUID as its `opId`, drawn again after every import, instead of asking for one. +- Each pane reports its own outcome (the list, the detail pane) rather than one status line for the window; a + form shows its own reply or error. +- A tag's id is its own table column, so the rename and merge forms' ids can be read off it. + +Known gaps: + +- **Ids are typed into the rename and merge forms.** `RenameTag`/`MergeTags` carry plain `TagId`s, not + `morph::forms::Choice` fields, so the forms ask for numbers rather than offering the tags to pick from. +- **The client never polls for background-job results.** The metadata worker fills a title in some seconds + after a bookmark is created, and `GetChangesSince` is the action a client asks for that with — implemented + and tested in `BookmarkModel`, dispatched by no controller. A fetched title appears on the next **Refresh**. +- **At least four model instances per client — counted from the code, not measured against a server.** Each + querying controller owns a handler: one `SharedFeedModel`, one `TagModel`, two `BookmarkModel` (list and + detail); the forms' routing may register more for `AuthModel`, `BookmarkModel` and `TagModel`. `app.cpp`'s + `kMaxLiveModels` comment budgets "roughly one instance per model type it uses (four in this rung)", so the + 256 cap may cover fewer concurrent clients than that comment assumes. +- **Registration timing.** The library queries fire as soon as the session is installed. A call made before + its handler's registration round trip lands waits for it, or is rejected if registration fails. Remote mode + has no connect timeout, so a server that never answers leaves those calls pending. +- **No `--seed`.** `LADDER.md` asks every rung for one; this rung's server ships none, deliberately — see + `src/server/main.cpp`'s file comment for the argument. Demo data is created through the client. +- **The frontend smoke tests prove mounting, not behaviour** — the behavioural half is the controller and view + suites plus a manual end-to-end run. +``` + +- [ ] **Step 3: Verify** + +Re-run Step 1's `grep`. Expected: no output. Then markdownlint on the file. Expected: no findings. + +- [ ] **Step 4: Commit** + +```bash +git add examples/bookmarks/README.md +git commit -m "wip(bookmarks): README describes the new client + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 17: Verify bookmarks, and squash its group + +**Files:** none new. + +- [ ] **Step 1: The rung's suite** + +```bash +cmake --build build/all +ctest --test-dir build/all -L 'ladder-bookmarks|ladder-pastebin|ladder-0' --output-on-failure +``` + +Expected: every test passes. + +- [ ] **Step 2: The client is toolkit-free** + +```bash +grep -rnE '#include " +``` + +- [ ] **Step 8: Squash the group** + +Follow the master plan's "Squashing a part" procedure with `key=bookmarks` and this message: + +```text +examples/bookmarks: one app, any frontend + +bookmarks' client is a toolkit-free app library: a sign-in controller whose +Login installs the session before its (token-free) reply shows, list, +detail, tags and shared-feed controllers keyed on that session, and six +runtime forms routed across AuthModel, BookmarkModel and TagModel; the list +is a multi-select table with bulk archive. One binary, ui/main.cpp, runs it +on the TUI or Qt Quick, native or in the browser. The server's App moved to +ladder_bookmarks_server_app; the QML client and its tests are gone. + +Signed-off-by: Christian Parpart +``` + +The last line of the procedure must show the history ending in `examples/pastebin: …`, `examples/bookmarks: …`. + +--- +## Group 3 — polls (key `polls`) + +polls' `App` is already Qt-free (`include/polls/app/app.hpp` names no Qt type), so it stays in +`ladder_polls_lib`; the domain library loses its `Qt6::Core` link through Task 1's `app/` rule the moment +Task 18 creates `examples/polls/app/`. + +### Task 18: The landing and create-poll controllers — option drafts as a signal + +**Files:** +- Modify: `examples/polls/CMakeLists.txt` — whole file +- Create: `examples/polls/app/controllers/poll_text.{hpp,cpp}`, + `examples/polls/app/controllers/landing_controller.{hpp,cpp}`, + `examples/polls/app/controllers/create_poll_controller.{hpp,cpp}` +- Create: `examples/polls/tests/client_fixture.hpp` +- Test: `examples/polls/tests/test_create_poll_controller.cpp` + +**Interfaces:** +- Consumes: `reactive::{Signal::mutate, Mutation, Computed, Effect, errorMessage}`; + `polls::{PollModel, CreatePoll, CreatePollOption, CreatePollResult, VoteChoice, Count, kMinOptions, kMaxOptions}` + (`polls/dto/poll_dto.hpp`, `polls/core/types.hpp`); `BackendRig(Mode, std::size_t, std::shared_ptr)`; + `polls::auth::PollsAuthorizer`. +- Produces: `polls::client::{StatusLine, trimmed, countText, choiceName, choiceNamed, choiceLabel, infoLine, + failureLine, CreateAccess, LandingController, OptionDraft, CreatePollController}`; + `LandingController(reactive::Runtime&)` with `pollIdDraft()`, `setPollIdDraft()`, `canOpen()`, + `trimmedPollId()`; `CreatePollController(Runtime&, Bridge&, IExecutor&)` with `title()`, `setTitle()`, + `options() -> reactive::Signal> const&`, `addOption()`, `removeOption()`, + `setOptionLabel()`, `canAdd()`, `canRemove()`, `canSubmit()`, `submit()`, `created()`, `editing()`, `pollId()`, + `adminToken()`, `participantToken()`, `startOver()`, `statusText()`, `statusIsError()`, `hasStatus()`; + `polls::testing::{pollsRig, seedPoll}`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/polls/tests/client_fixture.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include "polls/auth/polls_authorizer.hpp" +#include "polls/models/poll_model.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/pump.hpp" + +/// @file +/// The wiring polls' client tests share. + +namespace polls::testing { + +/// @brief A rig with the rung's authorizer installed (it matters in Socket mode). +/// @param mode The deployment shape. +/// @return The rig. +[[nodiscard]] inline std::unique_ptr<::morph::ladder::testkit::BackendRig> pollsRig(::morph::ladder::testkit::Mode mode) { + return std::make_unique<::morph::ladder::testkit::BackendRig>(mode, 1, std::make_shared()); +} + +/// @brief Creates a poll through a plain handler of the rig's own, outside any controller. +/// @param rig The rig. +/// @param title The poll's title. +/// @param labels Its option labels. +/// @return The new poll's ids and tokens. +[[nodiscard]] inline CreatePollResult seedPoll(::morph::ladder::testkit::BackendRig& rig, std::string title, + std::vector labels) { + auto creator = rig.client(0); + CreatePoll create{.title = std::move(title), .options = {}}; + for (auto& label : labels) { + create.options.push_back(CreatePollOption{.label = std::move(label)}); + } + return ::morph::ladder::testkit::awaitQt(creator.execute(create)); +} + +} // namespace polls::testing +``` + +Create `examples/polls/tests/test_create_poll_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "client_fixture.hpp" +#include "controllers/create_poll_controller.hpp" +#include "controllers/landing_controller.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; +using polls::client::CreatePollController; + +[[nodiscard]] std::vector keysOf(CreatePollController const& create) { + std::vector keys; + for (auto const& draft : create.options().peek()) { + keys.push_back(draft.key); + } + return keys; +} + +[[nodiscard]] std::string labelOf(CreatePollController const& create, std::int64_t key) { + for (auto const& draft : create.options().peek()) { + if (draft.key == key) { + return draft.label; + } + } + return ""; +} + +} // namespace + +TEST_CASE("option drafts: the bounds hold and a removal keeps the other drafts' keys", "[polls][controller]") { + DbFixture fixture; + auto rig = polls::testing::pollsRig(Mode::Local); + morph::reactive::Runtime runtime{*rig->executor()}; + CreatePollController create{runtime, rig->bridge(0), *rig->executor()}; + + auto const initial = keysOf(create); + REQUIRE(initial.size() == polls::kMinOptions); + CHECK_FALSE(create.canRemove()); + create.removeOption(initial.front()); + CHECK(keysOf(create) == initial); // refused at the minimum + + while (create.canAdd()) { + create.addOption(); + } + REQUIRE(create.options().peek().size() == polls::kMaxOptions); + create.addOption(); + CHECK(create.options().peek().size() == polls::kMaxOptions); // refused at the maximum + + auto const full = keysOf(create); + create.setOptionLabel(full[5], "kept"); + create.removeOption(full[3]); + auto expected = full; + expected.erase(expected.begin() + 3); + CHECK(keysOf(create) == expected); // every other draft keeps its key, so its row keeps its widgets + CHECK(labelOf(create, full[5]) == "kept"); + + create.addOption(); + CHECK(keysOf(create).back() > *std::ranges::max_element(full)); // a key is never reused +} + +TEST_CASE("canSubmit needs a title and every option label, and submit() refuses otherwise", "[polls][controller]") { + DbFixture fixture; + auto rig = polls::testing::pollsRig(Mode::Local); + morph::reactive::Runtime runtime{*rig->executor()}; + CreatePollController create{runtime, rig->bridge(0), *rig->executor()}; + auto const keys = keysOf(create); + + CHECK_FALSE(create.canSubmit()); + create.setTitle(" "); + create.setOptionLabel(keys[0], "2026-09-01"); + create.setOptionLabel(keys[1], "2026-09-02"); + CHECK_FALSE(create.canSubmit()); // a blank title + create.setTitle("Team offsite"); + create.setOptionLabel(keys[1], " "); + CHECK_FALSE(create.canSubmit()); // a blank option + create.submit(); + CHECK_FALSE(create.created()); +} + +TEST_CASE("The create screen creates a poll and shows its three ids, all three backend modes", "[polls][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = polls::testing::pollsRig(mode); + morph::reactive::Runtime runtime{*rig->executor()}; + CreatePollController create{runtime, rig->bridge(0), *rig->executor()}; + auto const keys = keysOf(create); + create.setTitle("Team offsite"); + create.setOptionLabel(keys[0], "2026-09-01"); + create.setOptionLabel(keys[1], "2026-09-02"); + REQUIRE(create.canSubmit()); + + create.submit(); + REQUIRE(pumpUntil([&] { return create.created(); })); + CHECK_FALSE(create.pollId().empty()); + CHECK_FALSE(create.adminToken().empty()); + CHECK_FALSE(create.participantToken().empty()); + CHECK(create.adminToken() != create.participantToken()); + CHECK(create.adminToken() != create.pollId()); + CHECK_FALSE(create.statusIsError()); + + create.startOver(); + CHECK_FALSE(create.created()); + CHECK(create.title().empty()); + CHECK(create.options().peek().size() == polls::kMinOptions); +} + +TEST_CASE("LandingController opens only a non-blank id, trimmed", "[polls][controller]") { + DbFixture fixture; + auto rig = polls::testing::pollsRig(Mode::Local); + morph::reactive::Runtime runtime{*rig->executor()}; + polls::client::LandingController landing{runtime}; + CHECK_FALSE(landing.canOpen()); + landing.setPollIdDraft(" "); + CHECK_FALSE(landing.canOpen()); + landing.setPollIdDraft(" abc123 "); + CHECK(landing.canOpen()); + CHECK(landing.trimmedPollId() == "abc123"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_polls_tests` +Expected: FAIL — `'controllers/create_poll_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Replace `examples/polls/CMakeLists.txt` with: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# +# polls — rung 3 of the application ladder (examples/polls/README.md). +# morph_add_rung() wires the standard targets (cmake/morph_add_rung.cmake); this +# file adds the sources its directory conventions do not cover, then the +# browser build's server url. + +cmake_minimum_required(VERSION 3.25) + +morph_add_rung(NAME polls) + +# The authorizer (src/auth/) sits outside the src/models, src/db and src/app globs. +if(TARGET ladder_polls_lib) + target_sources(ladder_polls_lib PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/auth/polls_authorizer.cpp") +endif() + +# The browser build has no ladder_polls_lib (no ODBC in a browser). The authorizer has no persistence +# dependency, so a client that names it compiles it into the client library instead. +if(TARGET ladder_polls_app AND NOT TARGET ladder_polls_lib) + target_sources(ladder_polls_app PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/auth/polls_authorizer.cpp") +endif() + +# ── The browser build's server url ────────────────────────────────────────── +# A page served from a static bundle has no argv to read --server from. Port 8767 is +# ladder_polls_server's own default. +if(EMSCRIPTEN AND TARGET polls) + if(NOT DEFINED MORPH_LADDER_POLLS_WASM_SERVER_URL) + set(MORPH_LADDER_POLLS_WASM_SERVER_URL "ws://127.0.0.1:8767" CACHE STRING + "URL polls' browser build connects to; must be a reachable ladder_polls_server.") + endif() + target_compile_definitions(polls PRIVATE + MORPH_LADDER_POLLS_WASM_SERVER_URL="${MORPH_LADDER_POLLS_WASM_SERVER_URL}" + ) +endif() +``` + +Create `examples/polls/app/controllers/poll_text.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include "polls/core/types.hpp" +#include "polls/units.hpp" + +/// @file +/// The text every polls controller shows, in one place. + +namespace polls::client { + +/// @brief A screen's status line. +struct StatusLine { + std::string text; ///< Empty when there is nothing to report. + bool isError = false; ///< Whether `text` reports a failure. + + /// @brief Field-wise equality. + bool operator==(StatusLine const&) const = default; +}; + +/// @brief @p text without leading and trailing blanks. +/// @param text The text. +/// @return The trimmed copy. +[[nodiscard]] std::string trimmed(std::string_view text); + +/// @brief A count as text (`morph::units::toString`). +/// @param count The count. +/// @return Its text. +[[nodiscard]] std::string countText(Count const& count); + +/// @brief A choice's wire name: "Yes", "IfNeedBe" or "No". +/// @param choice The choice. +/// @return Its name. +[[nodiscard]] std::string choiceName(VoteChoice choice); + +/// @brief The choice a wire name stands for; anything unknown is `No`, the safe default for a vote row. +/// @param name A wire name. +/// @return The choice. +[[nodiscard]] VoteChoice choiceNamed(std::string_view name); + +/// @brief A choice as a person reads it: "Yes", "If need be" or "No". +/// @param choice The choice. +/// @return Its label. +[[nodiscard]] std::string choiceLabel(VoteChoice choice); + +/// @brief An outcome as a status line. +/// @param text What happened. +/// @return @p text, not an error. +[[nodiscard]] StatusLine infoLine(std::string text); + +/// @brief A failure as a status line. +/// @param error The failure; must not be null. +/// @return `errorMessage(error)`, marked as an error. +[[nodiscard]] StatusLine failureLine(std::exception_ptr const& error); + +} // namespace polls::client +``` + +Create `examples/polls/app/controllers/poll_text.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/poll_text.hpp" + +#include +#include +#include + +namespace polls::client { + +std::string trimmed(std::string_view text) { + auto const first = text.find_first_not_of(" \t\r\n"); + if (first == std::string_view::npos) { + return {}; + } + auto const last = text.find_last_not_of(" \t\r\n"); + return std::string{text.substr(first, last - first + 1)}; +} + +std::string countText(Count const& count) { return ::morph::units::toString(count); } + +std::string choiceName(VoteChoice choice) { + switch (choice) { + case VoteChoice::Yes: + return "Yes"; + case VoteChoice::IfNeedBe: + return "IfNeedBe"; + case VoteChoice::No: + return "No"; + default: + return "No"; + } +} + +VoteChoice choiceNamed(std::string_view name) { + if (name == "Yes") { + return VoteChoice::Yes; + } + if (name == "IfNeedBe") { + return VoteChoice::IfNeedBe; + } + return VoteChoice::No; +} + +std::string choiceLabel(VoteChoice choice) { return choice == VoteChoice::IfNeedBe ? "If need be" : choiceName(choice); } + +StatusLine infoLine(std::string text) { return StatusLine{.text = std::move(text), .isError = false}; } + +StatusLine failureLine(std::exception_ptr const& error) { + return StatusLine{.text = ::morph::reactive::errorMessage(error), .isError = true}; +} + +} // namespace polls::client +``` + +Create `examples/polls/app/controllers/landing_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +/// @file +/// The landing screen: the poll id a participant pastes. + +namespace polls::client { + +/// @brief Whether this build may create polls. Creating is the organizer's, from a native client; a +/// participant in a browser follows a shared link (the README's design decision 6). +enum class CreateAccess : std::uint8_t { + Offered, ///< The landing screen offers "Create a new poll". + Hidden, ///< It does not, and the create screen is unreachable. +}; + +/// @brief The landing screen's controller: the pasted poll id. +class LandingController { +public: + /// @param runtime The runtime. + explicit LandingController(::morph::reactive::Runtime& runtime); + + ~LandingController() = default; + LandingController(LandingController const&) = delete; + LandingController& operator=(LandingController const&) = delete; + LandingController(LandingController&&) = delete; + LandingController& operator=(LandingController&&) = delete; + + /// @brief What has been typed. Tracked. + /// @return The draft. + [[nodiscard]] std::string const& pollIdDraft() const { return _draft.get(); } + + /// @brief Replaces the draft. + /// @param text The typed text. + void setPollIdDraft(std::string text) { _draft.set(std::move(text)); } + + /// @brief Whether "Open" acts. Tracked. + /// @return True when the trimmed draft is non-empty. + [[nodiscard]] bool canOpen() const { return _canOpen.get(); } + + /// @brief The draft, trimmed: the id "Open" opens. Tracked. + /// @return The id. + [[nodiscard]] std::string const& trimmedPollId() const { return _trimmed.get(); } + +private: + ::morph::reactive::Signal _draft; + ::morph::reactive::Computed _trimmed; + ::morph::reactive::Computed _canOpen; +}; + +} // namespace polls::client +``` + +Create `examples/polls/app/controllers/landing_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/landing_controller.hpp" + +#include "controllers/poll_text.hpp" + +namespace polls::client { + +LandingController::LandingController(::morph::reactive::Runtime& runtime) + : _draft{runtime, std::string{}}, + _trimmed{runtime, [this] { return trimmed(_draft.get()); }}, + _canOpen{runtime, [this] { return !_trimmed.get().empty(); }} {} + +} // namespace polls::client +``` + +Create `examples/polls/app/controllers/create_poll_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "controllers/poll_text.hpp" +#include "polls/models/poll_model.hpp" + +/// @file +/// The organizer's create-poll screen. + +namespace polls::client { + +/// @brief One candidate option as it is being typed. +struct OptionDraft { + std::int64_t key = 0; ///< Stable for the draft's life and never reused; the row's key. + std::string label; ///< What has been typed. + + /// @brief Field-wise equality. + bool operator==(OptionDraft const&) const = default; +}; + +/// @brief The create-poll screen's controller. +/// +/// `CreatePoll::options` is a list of objects, which a schema form does not edit, so the option list is a +/// signal of drafts the view renders with a keyed `forEach`: between `kMinOptions` and `kMaxOptions` +/// drafts, each with a key that survives every add and remove, so a removal leaves the other rows' widgets +/// (and what is typed into them) alone. `CreatePoll` is dispatched through a plain handler — it carries no +/// key, so it never attaches to a shared instance. +class CreatePollController { +public: + /// @param runtime The runtime; its owner must be @p callbacks. + /// @param bridge The client's bridge. + /// @param callbacks Where replies are delivered. + CreatePollController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks); + + ~CreatePollController() = default; + CreatePollController(CreatePollController const&) = delete; + CreatePollController& operator=(CreatePollController const&) = delete; + CreatePollController(CreatePollController&&) = delete; + CreatePollController& operator=(CreatePollController&&) = delete; + + /// @brief The typed title. Tracked. + /// @return It. + [[nodiscard]] std::string const& title() const { return _title.get(); } + + /// @brief Replaces the title. + /// @param text The typed text. + void setTitle(std::string text) { _title.set(std::move(text)); } + + /// @brief The option drafts — the signal the view's `forEach` renders. + /// @return The signal. + [[nodiscard]] ::morph::reactive::Signal> const& options() const noexcept { + return _options; + } + + /// @brief Appends an empty draft; refused at `kMaxOptions`. + void addOption(); + + /// @brief Removes a draft; refused at `kMinOptions`. + /// @param key The draft. + void removeOption(std::int64_t key); + + /// @brief Replaces a draft's label. + /// @param key The draft. + /// @param label The typed text. + void setOptionLabel(std::int64_t key, std::string label); + + /// @brief Whether "+ add option" acts. Tracked. + /// @return True below `kMaxOptions`. + [[nodiscard]] bool canAdd() const { return _canAdd.get(); } + + /// @brief Whether a row's "remove" acts. Tracked. + /// @return True above `kMinOptions`. + [[nodiscard]] bool canRemove() const { return _canRemove.get(); } + + /// @brief Whether "Create poll" acts. Tracked. + /// @return True with a non-blank title, every label non-blank, and nothing in flight. + [[nodiscard]] bool canSubmit() const { return _canSubmit.get(); } + + /// @brief Creates the poll; does nothing unless `canSubmit()`. + void submit(); + + /// @brief Whether a poll was created and its ids are showing. Tracked. + /// @return True after a successful create, until `startOver()`. + [[nodiscard]] bool created() const { return _result.get().has_value(); } + + /// @brief Whether the editor shows (no poll created yet). Tracked. + /// @return `!created()`. + [[nodiscard]] bool editing() const { return !created(); } + + /// @brief The created poll's shareable id. Tracked. + /// @return It, or empty. + [[nodiscard]] std::string const& pollId() const { return _pollId.get(); } + + /// @brief The created poll's admin token, shown once. Tracked. + /// @return It, or empty. + [[nodiscard]] std::string const& adminToken() const { return _adminToken.get(); } + + /// @brief The created poll's participant token. Tracked. + /// @return It, or empty. + [[nodiscard]] std::string const& participantToken() const { return _participantToken.get(); } + + /// @brief Back to an empty editor. + void startOver(); + + /// @brief The status line's text. Tracked. + /// @return The text, or empty. + [[nodiscard]] std::string const& statusText() const { return _status.get().text; } + + /// @brief Whether the status line reports a failure. Tracked. + /// @return True for a failure. + [[nodiscard]] bool statusIsError() const { return _status.get().isError; } + + /// @brief Whether there is a status to show. Tracked. + /// @return True when the text is non-empty. + [[nodiscard]] bool hasStatus() const { return !_status.get().text.empty(); } + +private: + [[nodiscard]] std::vector freshDrafts(); + + ::morph::reactive::Runtime* _rt; + ::morph::bridge::BridgeHandler _creator; + std::int64_t _nextKey = 1; + ::morph::reactive::Signal _title; + ::morph::reactive::Signal> _options; + ::morph::reactive::Signal _status; + ::morph::reactive::Signal> _result; + ::morph::reactive::Mutation _create; + ::morph::reactive::Computed _canAdd; + ::morph::reactive::Computed _canRemove; + ::morph::reactive::Computed _canSubmit; + ::morph::reactive::Computed _pollId; + ::morph::reactive::Computed _adminToken; + ::morph::reactive::Computed _participantToken; + ::morph::reactive::Effect _onCreated; + ::morph::reactive::Effect _onFailed; +}; + +} // namespace polls::client +``` + +Create `examples/polls/app/controllers/create_poll_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/create_poll_controller.hpp" + +#include +#include + +namespace polls::client { + +CreatePollController::CreatePollController(::morph::reactive::Runtime& runtime, ::morph::bridge::Bridge& bridge, + ::morph::exec::IExecutor& callbacks) + : _rt{&runtime}, + _creator{bridge, &callbacks}, + _title{runtime, std::string{}}, + _options{runtime, freshDrafts()}, + _status{runtime, StatusLine{}}, + _result{runtime, std::nullopt}, + _create{runtime, _creator}, + _canAdd{runtime, [this] { return _options.get().size() < kMaxOptions; }}, + _canRemove{runtime, [this] { return _options.get().size() > kMinOptions; }}, + _canSubmit{runtime, + [this] { + auto const& drafts = _options.get(); + return !trimmed(_title.get()).empty() && drafts.size() >= kMinOptions && + drafts.size() <= kMaxOptions && + std::ranges::all_of(drafts, + [](OptionDraft const& draft) { return !trimmed(draft.label).empty(); }) && + !_create.pending(); + }}, + _pollId{runtime, + [this] { + auto const& result = _result.get(); + return result.has_value() ? result->pollId : std::string{}; + }}, + _adminToken{runtime, + [this] { + auto const& result = _result.get(); + return result.has_value() && result->adminToken.hasValue() ? *result->adminToken : std::string{}; + }}, + _participantToken{runtime, + [this] { + auto const& result = _result.get(); + return result.has_value() && result->participantToken.hasValue() ? *result->participantToken + : std::string{}; + }}, + _onCreated{runtime, + [this] { + auto const& created = _create.lastResult(); + if (!created.has_value()) { + return; + } + _rt->untracked([&] { + _result.set(*created); + _status.set(infoLine("poll created: copy the admin token before leaving this screen")); + }); + }}, + _onFailed{runtime, + [this] { + if (auto const error = _create.error()) { + _status.set(failureLine(error)); + } + }} {} + +std::vector CreatePollController::freshDrafts() { + std::vector drafts; + for (std::size_t i = 0; i < kMinOptions; ++i) { + drafts.push_back(OptionDraft{.key = _nextKey++, .label = {}}); + } + return drafts; +} + +void CreatePollController::addOption() { + if (_options.peek().size() >= kMaxOptions) { + return; + } + _options.mutate([this](std::vector& drafts) { + drafts.push_back(OptionDraft{.key = _nextKey++, .label = {}}); + }); +} + +void CreatePollController::removeOption(std::int64_t key) { + if (_options.peek().size() <= kMinOptions) { + return; + } + _options.mutate([key](std::vector& drafts) { + std::erase_if(drafts, [key](OptionDraft const& draft) { return draft.key == key; }); + }); +} + +void CreatePollController::setOptionLabel(std::int64_t key, std::string label) { + _options.mutate([key, &label](std::vector& drafts) { + for (auto& draft : drafts) { + if (draft.key == key) { + draft.label = std::move(label); + return; + } + } + }); +} + +void CreatePollController::submit() { + if (!_canSubmit.get()) { + return; + } + CreatePoll action{.title = _title.peek(), .options = {}}; + for (auto const& draft : _options.peek()) { + action.options.push_back(CreatePollOption{.label = draft.label}); + } + _create.run(std::move(action)); +} + +void CreatePollController::startOver() { + _rt->batch([this] { + _result.set(std::nullopt); + _title.set(std::string{}); + _options.set(freshDrafts()); + _status.set(StatusLine{}); + }); +} + +} // namespace polls::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_polls_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/polls/ladder_polls_tests "[polls][controller]" +``` + +Expected: PASS. Mutation check: in `removeOption`, replace the `erase_if` with rebuilding the list under fresh keys +(`drafts = …` with `_nextKey++` per remaining draft). Expected FAIL in "option drafts: the bounds hold and a +removal keeps the other drafts' keys" (`keysOf(create) == expected`). Restore. Second: delete the +`size() <= kMinOptions` guard. Expected FAIL in the same case (`keysOf(create) == initial`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/polls/CMakeLists.txt examples/polls/app examples/polls/tests/client_fixture.hpp \ + examples/polls/tests/test_create_poll_controller.cpp +git commit -m "wip(polls): the landing and create-poll controllers, option drafts as a signal + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 19: The vote controller — open when connected, state, votes, admin token + +A keyed attach (`OpenPoll`) is the one call a remote transport refuses outright while its socket is still +connecting (`QtWebSocketBackend::bindModel`, "disconnected"): it may re-point a live instance, so it is never +queued. The vote controller therefore issues `OpenPoll` only while the connection is ready +(`examples::Connection::ready()`, handed in as a tracked predicate): an Effect over "the poll the user asked for" +and that readiness, so a browser following a `?poll=` link opens it the moment its socket connects. + +**Files:** +- Create: `examples/polls/app/controllers/vote_controller.{hpp,cpp}` +- Modify: `examples/polls/tests/client_fixture.hpp` — `VoteFixture`, `openAndWait()` +- Test: `examples/polls/tests/test_vote_controller.cpp` + +**Interfaces:** +- Consumes: `Query`, `Mutation`, `Computed`, `Effect`, `Signal::mutate`, `Runtime::{batch, untracked, + isFlushRequested}`; `bridge::BridgeHandler`; `Bridge::setDefaultSession`, + `session::Context`; `polls::{OpenPoll, GetPollState, GetPollStateResult, SubmitVotes, UpdateVotes, OneVote, + OptionId, Finalized}`; `poll_text` (Task 18); `examples::Wiring{runtime, scheduler, bridge, callbacks}` + (`examples/common/app/wiring.hpp`, Part 6 — the controller needs all four, the scheduler from Task 21 on); + `reactive::testing::ManualScheduler` (the fixture's). +- Produces: `polls::client::{OptionRow, CommentRow, VoteController}`; `VoteController(examples::Wiring, + std::function connected)` with + `open()`, `close()`, `refresh()`, `attached()`, `opening()`, `title()`, `finalized()`, `participantName()`, + `setParticipantName()`, `options()`, `pickFor()`, `setPick()`, `canChangeVotes()`, `canVote()`, `voteLabel()`, + `vote()`, `comments()`, `commentsHeading()`, `adminTokenDraft()`, `setAdminTokenDraft()`, `canUseAdminToken()`, + `useAdminToken()`, `statusText()`, `statusIsError()`, `hasStatus()`; `polls::testing::{VoteFixture, + openAndWait}`. + +- [ ] **Step 1: Write the failing tests** + +Append to `examples/polls/tests/client_fixture.hpp`, inside `namespace polls::testing` (add +`#include `, `#include `, +`#include `, `#include "app/wiring.hpp"`, +`#include "controllers/vote_controller.hpp"`): + +```cpp +/// @brief One client of one rig with a vote controller. Time advances only through `scheduler`; whether the +/// transport is connected is `connected`, true unless a test says otherwise. +struct VoteFixture { + /// @param mode The deployment shape. + explicit VoteFixture(::morph::ladder::testkit::Mode mode) + : rig{pollsRig(mode)}, + runtime{*rig->executor()}, + connected{runtime, true}, + vote{::morph::examples::Wiring{.runtime = runtime, + .scheduler = scheduler, + .bridge = rig->bridge(0), + .callbacks = *rig->executor()}, + [this] { return connected.get(); }} {} + + std::unique_ptr<::morph::ladder::testkit::BackendRig> rig; ///< Backend and executors. + ::morph::reactive::Runtime runtime; ///< Owned by the rig's client executor. + ::morph::reactive::testing::ManualScheduler scheduler; ///< The vote screen's timers. + ::morph::reactive::Signal connected; ///< Stands in for `Connection::ready()`. + client::VoteController vote; ///< The controller under test. +}; + +/// @brief Opens @p pollId and waits until its state shows. +/// @param vote The controller. +/// @param pollId The poll. +inline void openAndWait(client::VoteController& vote, std::string const& pollId) { + vote.open(pollId); + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return vote.attached() && !vote.options().empty(); })); +} +``` + +Create `examples/polls/tests/test_vote_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +#include "client_fixture.hpp" +#include "controllers/create_poll_controller.hpp" +#include "controllers/vote_controller.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; +using polls::testing::VoteFixture; + +} // namespace + +TEST_CASE("The vote screen opens a poll the create screen made, all three backend modes", "[polls][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + VoteFixture client{mode}; + polls::client::CreatePollController create{client.runtime, client.rig->bridge(0), *client.rig->executor()}; + auto const drafts = create.options().peek(); + create.setTitle("Team offsite"); + create.setOptionLabel(drafts[0].key, "2026-09-01"); + create.setOptionLabel(drafts[1].key, "2026-09-02"); + create.submit(); + REQUIRE(pumpUntil([&] { return create.created(); })); + + polls::testing::openAndWait(client.vote, create.pollId()); + CHECK(client.vote.title() == "Team offsite"); + CHECK_FALSE(client.vote.finalized()); + REQUIRE(client.vote.options().size() == 2); + auto const& first = client.vote.options()[0]; + CHECK(first.label == "2026-09-01 (#" + std::to_string(first.optionId) + ")"); + CHECK(first.tally == "yes 0 · if need be 0 · no 0"); +} + +TEST_CASE("refresh re-reads the same poll's state, all three backend modes", "[polls][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + VoteFixture client{mode}; + auto const poll = polls::testing::seedPoll(*client.rig, "T", {"1", "2"}); + polls::testing::openAndWait(client.vote, poll.pollId); + client.vote.refresh(); + REQUIRE(pumpUntil([&] { return client.vote.title() == "T"; })); + CHECK(client.vote.options().size() == 2); +} + +TEST_CASE("A vote is tallied, and a second one replaces it, all three backend modes", "[polls][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + VoteFixture client{mode}; + auto const poll = polls::testing::seedPoll(*client.rig, "T", {"1", "2"}); + polls::testing::openAndWait(client.vote, poll.pollId); + std::int64_t const optionId = client.vote.options()[0].optionId; + + CHECK_FALSE(client.vote.canVote()); // no name yet + client.vote.vote(); + CHECK(client.vote.voteLabel() == "Submit my votes"); + + client.vote.setParticipantName("alice"); + CHECK(client.vote.pickFor(optionId) == polls::VoteChoice::No); + client.vote.setPick(optionId, polls::VoteChoice::Yes); + REQUIRE(client.vote.canVote()); + client.vote.vote(); + REQUIRE(pumpUntil([&] { return client.vote.options()[0].tally == "yes 1 · if need be 0 · no 0"; })); + CHECK(client.vote.voteLabel() == "Update my votes"); + + client.vote.setPick(optionId, polls::VoteChoice::No); + client.vote.vote(); + REQUIRE(pumpUntil([&] { return client.vote.options()[0].tally == "yes 0 · if need be 0 · no 1"; })); +} + +TEST_CASE("Opening an unknown poll reports the model's message and attaches nothing", "[polls][controller]") { + DbFixture fixture; + VoteFixture client{Mode::Local}; + client.vote.open("no-such-poll"); + REQUIRE(pumpUntil([&] { return client.vote.statusIsError(); })); + CHECK_FALSE(client.vote.attached()); + CHECK(client.vote.options().empty()); +} + +TEST_CASE("OpenPoll waits for the connection, and goes out the moment it is ready", "[polls][controller]") { + DbFixture fixture; + VoteFixture client{Mode::Local}; + auto const poll = polls::testing::seedPoll(*client.rig, "T", {"1", "2"}); + client.connected.set(false); + client.vote.open(poll.pollId); + REQUIRE(pumpUntil([&] { return !client.runtime.isFlushRequested(); })); + CHECK_FALSE(client.vote.opening()); // a keyed attach on a connecting socket would be refused, not queued + CHECK_FALSE(client.vote.attached()); + + client.connected.set(true); + REQUIRE(pumpUntil([&] { return client.vote.attached(); })); + CHECK(client.vote.title() == "T"); +} + +TEST_CASE("a reply for a poll the user has left never attaches it", "[polls][controller]") { + DbFixture fixture; + VoteFixture client{Mode::Local}; + auto const poll = polls::testing::seedPoll(*client.rig, "Left behind", {"1", "2"}); + // Leaves in the very flush that issues the OpenPoll, before any reply can arrive. + bool issued = false; + morph::reactive::Effect const leaveAtOnce{client.runtime, [&] { + if (client.vote.opening() && !issued) { + issued = true; + client.runtime.untracked([&] { client.vote.close(); }); + } + }}; + client.vote.open(poll.pollId); + // The OpenPoll went out, its reply has landed, and every flush it caused has run. + REQUIRE(pumpUntil([&] { return issued && !client.vote.opening() && !client.runtime.isFlushRequested(); })); + CHECK_FALSE(client.vote.attached()); + CHECK(client.vote.title().empty()); + CHECK(client.vote.options().empty()); +} + +TEST_CASE("Before a poll is open nothing is issued, and the admin token is installed on demand", + "[polls][controller]") { + DbFixture fixture; + VoteFixture client{Mode::Local}; + CHECK_FALSE(client.vote.attached()); + CHECK_FALSE(client.vote.opening()); + CHECK(client.vote.options().empty()); + CHECK_FALSE(client.vote.canVote()); + CHECK_FALSE(client.vote.canUseAdminToken()); + client.vote.setAdminTokenDraft("secret"); + REQUIRE(client.vote.canUseAdminToken()); + client.vote.useAdminToken(); + CHECK(client.vote.statusText() == "admin token in use"); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/all --target ladder_polls_tests` +Expected: FAIL — `'controllers/vote_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/polls/app/controllers/vote_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/poll_text.hpp" +#include "polls/models/poll_model.hpp" + +/// @file +/// The vote screen: one open poll, its tallies, the participant's votes, comments, the admin's finalize, +/// and the live activity feed. + +namespace polls::client { + +/// @brief One option, with its tally, as the vote grid shows it. +struct OptionRow { + std::int64_t optionId = 0; ///< The option; also the row's key. + std::string label; ///< "2026-09-01 (#12)": the id is what the finalize form asks for. + std::string tally; ///< "yes 1 · if need be 0 · no 2". + + /// @brief Field-wise equality. + bool operator==(OptionRow const&) const = default; +}; + +/// @brief One comment. +struct CommentRow { + std::int64_t position = 0; ///< Its place in the poll's comment list; comments are append-only. + std::string line; ///< "alice: works for me". + + /// @brief Field-wise equality. + bool operator==(CommentRow const&) const = default; +}; + +/// @brief The vote screen's controller. +/// +/// Every action on an open poll goes through **one** `BridgeHandler`: `OpenPoll` +/// attaches it to the poll's shared instance, and every later action must run on that same handler or it +/// has no instance to run against. `open()` only records which poll the user asked for; an Effect issues +/// `OpenPoll` once that request exists and the connection is ready, because a remote transport refuses a +/// keyed attach while it connects. A reply is accepted only for the poll the user is still on. The poll's +/// state is a `Query` keyed on the attachment, which votes and form submissions invalidate. +class VoteController { +public: + /// @param wiring The runtime (its owner must be the callbacks executor), the scheduler the activity feed + /// runs on, the bridge (the admin token is installed on it) and the executor replies are + /// delivered on. All borrowed: they must outlive this controller. + /// @param connected Tracked: whether the transport can carry a keyed attach now (`Connection::ready()`). + VoteController(::morph::examples::Wiring wiring, std::function connected); + + ~VoteController() = default; + VoteController(VoteController const&) = delete; + VoteController& operator=(VoteController const&) = delete; + VoteController(VoteController&&) = delete; + VoteController& operator=(VoteController&&) = delete; + + /// @brief Asks for a poll: `OpenPoll` goes out as soon as the connection is ready. + /// @param pollId The poll's shareable id. + void open(std::string pollId); + + /// @brief Leaves the poll: its state goes idle, and a reply still on its way is ignored. + void close(); + + /// @brief Re-reads the open poll's state. + void refresh() { _state.refetch(); } + + /// @brief Whether a poll is open. Tracked. + /// @return True once `OpenPoll` succeeded for the poll the user is on. + [[nodiscard]] bool attached() const { return _attached.get().has_value(); } + + /// @brief Whether an `OpenPoll` is in flight. Tracked. + /// @return True from issue to reply. + [[nodiscard]] bool opening() const { return _open.pending(); } + + /// @brief The poll's title, " (finalized)" appended once finalized; "opening…" until it loads. Tracked. + /// @return The title, or empty while no poll is asked for. + [[nodiscard]] std::string const& title() const { return _title.get(); } + + /// @brief Whether the open poll is finalized. Tracked. + /// @return True once finalized. + [[nodiscard]] bool finalized() const { return _finalized.get(); } + + /// @brief The participant's typed name. Tracked. + /// @return It. + [[nodiscard]] std::string const& participantName() const { return _participant.get(); } + + /// @brief Replaces the participant's name. + /// @param name The typed name. + void setParticipantName(std::string name) { _participant.set(std::move(name)); } + + /// @brief The open poll's options with their tallies. Tracked. + /// @return The rows. + [[nodiscard]] std::vector const& options() const { return _options.get(); } + + /// @brief The participant's pick for an option. Tracked. + /// @param optionId The option. + /// @return The pick; `No` until one is made. + [[nodiscard]] VoteChoice pickFor(std::int64_t optionId) const; + + /// @brief Records the participant's pick for an option. + /// @param optionId The option. + /// @param choice The pick. + void setPick(std::int64_t optionId, VoteChoice choice); + + /// @brief Whether the picks may change. Tracked. + /// @return True while a poll is open and not finalized. + [[nodiscard]] bool canChangeVotes() const { return _canChangeVotes.get(); } + + /// @brief Whether the vote button acts. Tracked. + /// @return True with a poll open and not finalized, a non-blank name, and no vote in flight. + [[nodiscard]] bool canVote() const { return _canVote.get(); } + + /// @brief "Submit my votes" before the first vote, "Update my votes" after. Tracked. + /// @return The label. + [[nodiscard]] std::string const& voteLabel() const { return _voteLabel.get(); } + + /// @brief Submits the picks (first time) or replaces them; does nothing unless `canVote()`. + void vote(); + + /// @brief The open poll's comments. Tracked. + /// @return The rows, oldest first. + [[nodiscard]] std::vector const& comments() const { return _comments.get(); } + + /// @brief "Comments (N)". Tracked. + /// @return The heading. + [[nodiscard]] std::string const& commentsHeading() const { return _commentsHeading.get(); } + + /// @brief The typed admin token. Tracked. + /// @return It. + [[nodiscard]] std::string const& adminTokenDraft() const { return _adminDraft.get(); } + + /// @brief Replaces the typed admin token. + /// @param token The typed text. + void setAdminTokenDraft(std::string token) { _adminDraft.set(std::move(token)); } + + /// @brief Whether "use" acts. Tracked. + /// @return True when a token is typed. + [[nodiscard]] bool canUseAdminToken() const { return _canUseAdminToken.get(); } + + /// @brief Installs the typed token as the bridge's default session — the poll's whole admin identity. + void useAdminToken(); + + /// @brief The status line's text. Tracked. + /// @return The text, or empty. + [[nodiscard]] std::string const& statusText() const { return _status.get().text; } + + /// @brief Whether the status line reports a failure. Tracked. + /// @return True for a failure. + [[nodiscard]] bool statusIsError() const { return _status.get().isError; } + + /// @brief Whether there is a status to show. Tracked. + /// @return True when the text is non-empty. + [[nodiscard]] bool hasStatus() const { return !_status.get().text.empty(); } + +private: + /// @brief One request to open a poll; the serial makes a repeated request for the same poll a change. + struct OpenRequest { + std::string pollId; ///< The poll. + std::uint64_t serial = 0; ///< Distinguishes repeated requests. + + /// @brief Field-wise equality. + bool operator==(OpenRequest const&) const = default; + }; + + /// @brief The poll the handler is attached to, and where its event log stood then. + struct Attachment { + std::string pollId; ///< The poll. + PollEventId cursor; ///< `GetPollStateResult::lastEventId` at the open. + + /// @brief Field-wise equality. + bool operator==(Attachment const&) const = default; + }; + + void reportError(std::exception_ptr const& error); + + ::morph::reactive::Runtime* _rt; + ::morph::bridge::Bridge* _bridge; + std::function _connected; + ::morph::bridge::BridgeHandler _handler; + std::uint64_t _serial = 0; + ::morph::reactive::Signal> _requested; + ::morph::reactive::Signal> _attached; + ::morph::reactive::Signal _participant; + ::morph::reactive::Signal> _picks; + ::morph::reactive::Signal _hasVoted; + ::morph::reactive::Signal _adminDraft; + ::morph::reactive::Signal _status; + ::morph::reactive::Query _state; + ::morph::reactive::Mutation _open; + ::morph::reactive::Mutation _submit; + ::morph::reactive::Mutation _update; + ::morph::reactive::Computed _title; + ::morph::reactive::Computed _finalized; + ::morph::reactive::Computed> _options; + ::morph::reactive::Computed> _comments; + ::morph::reactive::Computed _commentsHeading; + ::morph::reactive::Computed _canChangeVotes; + ::morph::reactive::Computed _canVote; + ::morph::reactive::Computed _voteLabel; + ::morph::reactive::Computed _canUseAdminToken; + ::morph::reactive::Effect _onOpened; + ::morph::reactive::Effect _onOpenFailed; + ::morph::reactive::Effect _onStateFailed; + ::morph::reactive::Effect _onSubmitFailed; + ::morph::reactive::Effect _onUpdateFailed; + // Last: it issues OpenPoll, so everything a reply touches exists before the first can go out. + ::morph::reactive::Effect _issueOpen; +}; + +} // namespace polls::client +``` + +Create `examples/polls/app/controllers/vote_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/vote_controller.hpp" + +#include +#include + +namespace polls::client { + +namespace { + +[[nodiscard]] OptionRow optionRowOf(PollOptionView const& option) { + std::int64_t const optionId = *option.id; + return OptionRow{.optionId = optionId, + .label = option.label + " (#" + std::to_string(optionId) + ")", + .tally = "yes " + countText(option.yesCount) + " · if need be " + countText(option.ifNeedBeCount) + + " · no " + countText(option.noCount)}; +} + +} // namespace + +VoteController::VoteController(::morph::examples::Wiring wiring, std::function connected) + : _rt{&wiring.runtime}, + _bridge{&wiring.bridge}, + _connected{std::move(connected)}, + _handler{wiring.bridge, &wiring.callbacks}, + _requested{wiring.runtime, std::nullopt}, + _attached{wiring.runtime, std::nullopt}, + _participant{wiring.runtime, std::string{}}, + _picks{wiring.runtime, std::map{}}, + _hasVoted{wiring.runtime, false}, + _adminDraft{wiring.runtime, std::string{}}, + _status{wiring.runtime, StatusLine{}}, + _state{wiring.runtime, _handler, + [this]() -> std::optional { + return _attached.get().has_value() ? std::optional{GetPollState{}} : std::nullopt; + }}, + _open{wiring.runtime, _handler}, + _submit{wiring.runtime, _handler, ::morph::reactive::MutationOptions{.invalidates = {&_state}}}, + _update{wiring.runtime, _handler, ::morph::reactive::MutationOptions{.invalidates = {&_state}}}, + _title{wiring.runtime, + [this] { + auto const& state = _state.value(); + if (!state.has_value()) { + return _requested.get().has_value() ? std::string{"opening…"} : std::string{}; + } + return state->finalized == Finalized::Yes ? state->title + " (finalized)" : state->title; + }}, + _finalized{wiring.runtime, + [this] { + auto const& state = _state.value(); + return state.has_value() && state->finalized == Finalized::Yes; + }}, + _options{wiring.runtime, + [this] { + std::vector rows; + if (auto const& state = _state.value()) { + for (auto const& option : state->options) { + rows.push_back(optionRowOf(option)); + } + } + return rows; + }}, + _comments{wiring.runtime, + [this] { + std::vector rows; + if (auto const& state = _state.value()) { + std::int64_t position = 0; + for (auto const& comment : state->comments) { + rows.push_back(CommentRow{.position = position++, + .line = comment.participantName + ": " + comment.body}); + } + } + return rows; + }}, + _commentsHeading{wiring.runtime, [this] { return "Comments (" + std::to_string(_comments.get().size()) + ")"; }}, + _canChangeVotes{wiring.runtime, + [this] { return _attached.get().has_value() && _state.value().has_value() && !_finalized.get(); }}, + _canVote{wiring.runtime, + [this] { + return _canChangeVotes.get() && !trimmed(_participant.get()).empty() && !_submit.pending() && + !_update.pending(); + }}, + _voteLabel{wiring.runtime, + [this] { return _hasVoted.get() ? std::string{"Update my votes"} : std::string{"Submit my votes"}; }}, + _canUseAdminToken{wiring.runtime, [this] { return !_adminDraft.get().empty(); }}, + _onOpened{wiring.runtime, + [this] { + auto const& opened = _open.lastResult(); + if (!opened.has_value()) { + return; + } + _rt->untracked([&] { + auto const& requested = _requested.peek(); + if (!requested.has_value() || requested->pollId != opened->pollId) { + return; // a reply for a poll the user has left + } + _rt->batch([&] { + _picks.set({}); + _hasVoted.set(false); + _status.set(StatusLine{}); + _attached.set(Attachment{.pollId = opened->pollId, .cursor = opened->lastEventId}); + }); + }); + }}, + _onOpenFailed{wiring.runtime, + [this] { + auto const error = _open.error(); + if (error != nullptr && _rt->untracked([this] { return _requested.peek().has_value(); })) { + _status.set(failureLine(error)); + } + }}, + _onStateFailed{wiring.runtime, [this] { reportError(_state.error()); }}, + _onSubmitFailed{wiring.runtime, [this] { reportError(_submit.error()); }}, + _onUpdateFailed{wiring.runtime, [this] { reportError(_update.error()); }}, + _issueOpen{wiring.runtime, [this] { + auto const& requested = _requested.get(); + if (!requested.has_value() || !_connected()) { + return; + } + OpenPoll action{.pollId = requested->pollId}; + _rt->untracked([&] { _open.run(std::move(action)); }); + }} {} + +void VoteController::open(std::string pollId) { + std::string const opening = "opening " + pollId; + _rt->batch([&] { + _attached.set(std::nullopt); + _status.set(infoLine(opening)); + _requested.set(OpenRequest{.pollId = std::move(pollId), .serial = ++_serial}); + }); +} + +void VoteController::close() { + _rt->batch([this] { + _requested.set(std::nullopt); + _attached.set(std::nullopt); + _status.set(StatusLine{}); + }); +} + +VoteChoice VoteController::pickFor(std::int64_t optionId) const { + auto const& picks = _picks.get(); + auto const found = picks.find(optionId); + return found == picks.end() ? VoteChoice::No : found->second; +} + +void VoteController::setPick(std::int64_t optionId, VoteChoice choice) { + _picks.mutate([optionId, choice](std::map& picks) { picks[optionId] = choice; }); +} + +void VoteController::vote() { + if (!_canVote.get()) { + return; + } + std::vector votes; + auto const& picks = _picks.peek(); + for (auto const& option : _state.value()->options) { + auto const found = picks.find(*option.id); + votes.push_back(OneVote{.optionId = option.id, .choice = found == picks.end() ? VoteChoice::No : found->second}); + } + std::string const name = _participant.peek(); + if (_hasVoted.peek()) { + _update.run(UpdateVotes{.participantName = name, .votes = std::move(votes)}); + } else { + _submit.run(SubmitVotes{.participantName = name, .votes = std::move(votes)}); + } + _hasVoted.set(true); +} + +void VoteController::useAdminToken() { + ::morph::session::Context session; + session.token = _adminDraft.peek(); + _bridge->setDefaultSession(session); + _status.set(infoLine("admin token in use")); +} + +void VoteController::reportError(std::exception_ptr const& error) { + if (error != nullptr) { + _status.set(failureLine(error)); + } +} + +} // namespace polls::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_polls_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/polls/ladder_polls_tests "[polls][controller]" +``` + +Expected: PASS. Mutation check: in `_onOpened`, delete the `if (!requested.has_value() || requested->pollId != +opened->pollId) { return; }` guard. Expected FAIL in "a reply for a poll the user has left never attaches it" +(`CHECK_FALSE(client.vote.attached())`). Restore. Second: in `_issueOpen`, delete `|| !_connected()`. Expected +FAIL in "OpenPoll waits for the connection, and goes out the moment it is ready" +(`CHECK_FALSE(client.vote.opening())`). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/polls/app/controllers examples/polls/tests/client_fixture.hpp examples/polls/tests/test_vote_controller.cpp +git commit -m "wip(polls): the vote controller — open when connected, state, votes, admin token + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 20: The vote screen's three forms, typed, on the attached handler + +`AddComment`, `FinalizePoll` and `UndoLastVoteChange` must run on the **same** `AllowShared` handler `OpenPoll` +attached — a second handler would have no instance. A typed `forms::Form` over that +handler does exactly that, and `forms::handlerChoiceFetcher(callbacks, _handler)` sends any Choice lookup there +too; `forms::bridgeSubmitter`/`bridgeChoiceFetcher` would not (they make handlers of their own, attached to +nothing). A runtime form would take `forms::handlerSubmitter(callbacks, _handler)` for the same reason. + +**Files:** +- Modify: `examples/polls/app/controllers/vote_controller.hpp` — includes, the three form aliases and accessors, + `canFinalize()`, `onFormSucceeded()`, three form members after `_update`, three `FormSuccess` members before + `_issueOpen` +- Modify: `examples/polls/app/controllers/vote_controller.cpp` — six constructor initialisers, `onFormSucceeded()` +- Test: `examples/polls/tests/test_vote_forms.cpp` + +**Interfaces:** +- Consumes: `forms::Form` with `S = bridge::AllowShared`, `session()`, `set()`, `ready()` + (`typed_form.hpp`); `forms::handlerChoiceFetcher(exec::IExecutor&, Handlers&...)` (`handler_submitter.hpp`, Part 5 + Task 10b); `FormSession::model()`, `FormModel::actionType()`, `submitMode()`, `reset()`; `examples::FormSuccess`. +- Produces: `VoteController::{CommentForm, FinalizeForm, UndoForm, commentForm(), finalizeForm(), undoForm(), + canFinalize()}`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/polls/tests/test_vote_forms.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "client_fixture.hpp" +#include "controllers/vote_controller.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; +using polls::testing::VoteFixture; + +void voteFor(polls::client::VoteController& vote, std::int64_t optionId, polls::VoteChoice choice, + std::string const& expectedTally) { + vote.setPick(optionId, choice); + REQUIRE(vote.canVote()); + vote.vote(); + REQUIRE(pumpUntil([&] { return vote.options()[0].tally == expectedTally; })); +} + +} // namespace + +TEST_CASE("The vote screen's three forms are AddComment, FinalizePoll and UndoLastVoteChange, each explicit", + "[polls][controller]") { + DbFixture fixture; + VoteFixture client{Mode::Local}; + CHECK(client.vote.commentForm().session().model().actionType() == "AddComment"); + CHECK(client.vote.finalizeForm().session().model().actionType() == "FinalizePoll"); + CHECK(client.vote.undoForm().session().model().actionType() == "UndoLastVoteChange"); + for (auto* session : {&client.vote.commentForm().session(), &client.vote.finalizeForm().session(), + &client.vote.undoForm().session()}) { + CHECK(session->model().submitMode() == morph::forms::SubmitMode::Explicit); + } + // What validate() refuses, the typed forms refuse to call ready. + client.vote.commentForm().set<&polls::AddComment::participantName>(std::string{"alice"}); + CHECK_FALSE(client.vote.commentForm().ready()); // an empty body + CHECK_FALSE(client.vote.finalizeForm().ready()); // no option id +} + +TEST_CASE("A comment through its form shows in the comments, all three backend modes", "[polls][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + VoteFixture client{mode}; + auto const poll = polls::testing::seedPoll(*client.rig, "T", {"1", "2"}); + polls::testing::openAndWait(client.vote, poll.pollId); + CHECK(client.vote.commentsHeading() == "Comments (0)"); + + auto& form = client.vote.commentForm(); + form.set<&polls::AddComment::participantName>(std::string{"alice"}); + form.set<&polls::AddComment::body>(std::string{"works for me"}); + REQUIRE(form.ready()); + form.session().submit(); + REQUIRE(pumpUntil([&] { return client.vote.comments().size() == 1; })); + CHECK(client.vote.comments().front().line == "alice: works for me"); + CHECK(client.vote.commentsHeading() == "Comments (1)"); + CHECK(client.vote.statusText() == "comment added"); + CHECK_FALSE(form.ready()); // reset after the success +} + +TEST_CASE("FinalizePoll is refused without the admin token and finalizes with it, all three backend modes", + "[polls][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + VoteFixture client{mode}; + auto const poll = polls::testing::seedPoll(*client.rig, "T", {"1", "2"}); + polls::testing::openAndWait(client.vote, poll.pollId); + std::int64_t const optionId = client.vote.options()[0].optionId; + REQUIRE(client.vote.canFinalize()); + + auto& form = client.vote.finalizeForm(); + form.set<&polls::FinalizePoll::optionId>(polls::OptionId{.value = optionId}); + REQUIRE(form.ready()); + form.session().submit(); + REQUIRE(pumpUntil([&] { return form.session().lastError() != nullptr; })); + CHECK_FALSE(client.vote.finalized()); + + client.vote.setAdminTokenDraft(*poll.adminToken); + client.vote.useAdminToken(); + form.set<&polls::FinalizePoll::optionId>(polls::OptionId{.value = optionId}); + form.session().submit(); + REQUIRE(pumpUntil([&] { return client.vote.finalized(); })); + CHECK(client.vote.title() == "T (finalized)"); + CHECK_FALSE(client.vote.canChangeVotes()); + CHECK_FALSE(client.vote.canFinalize()); + CHECK(client.vote.statusText() == "poll finalized"); +} + +TEST_CASE("UndoLastVoteChange puts the participant's previous votes back, all three backend modes", + "[polls][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + VoteFixture client{mode}; + auto const poll = polls::testing::seedPoll(*client.rig, "T", {"1", "2"}); + polls::testing::openAndWait(client.vote, poll.pollId); + std::int64_t const optionId = client.vote.options()[0].optionId; + client.vote.setParticipantName("alice"); + voteFor(client.vote, optionId, polls::VoteChoice::Yes, "yes 1 · if need be 0 · no 0"); + voteFor(client.vote, optionId, polls::VoteChoice::No, "yes 0 · if need be 0 · no 1"); + + auto& form = client.vote.undoForm(); + form.set<&polls::UndoLastVoteChange::participantName>(std::string{"alice"}); + REQUIRE(form.ready()); + form.session().submit(); + REQUIRE(pumpUntil([&] { return client.vote.options()[0].tally == "yes 1 · if need be 0 · no 0"; })); + CHECK(client.vote.statusText() == "your last vote change is undone"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_polls_tests` +Expected: FAIL — `no member named 'commentForm' in 'polls::client::VoteController'`. + +- [ ] **Step 3: Implement** + +In `vote_controller.hpp`, add after `#include `: + +```cpp +#include +#include +#include +``` + +and after `#include "controllers/poll_text.hpp"`: `#include "app/form_success.hpp"`. After `useAdminToken()`'s +declaration add: + +```cpp + /// @brief The comment form's type: typed, on the attached handler. + using CommentForm = ::morph::forms::Form; + /// @brief The finalize form's type. + using FinalizeForm = ::morph::forms::Form; + /// @brief The undo form's type. + using UndoForm = ::morph::forms::Form; + + /// @brief The AddComment form. + /// @return It. + [[nodiscard]] CommentForm& commentForm() noexcept { return _comment; } + + /// @brief The FinalizePoll form; `FinalizePoll` needs the admin token in the session. + /// @return It. + [[nodiscard]] FinalizeForm& finalizeForm() noexcept { return _finalize; } + + /// @brief The UndoLastVoteChange form. + /// @return It. + [[nodiscard]] UndoForm& undoForm() noexcept { return _undo; } + + /// @brief Whether the finalize form acts. Tracked. + /// @return True while a poll is open and not finalized. + [[nodiscard]] bool canFinalize() const { return _canChangeVotes.get(); } +``` + +Add `void onFormSucceeded(::morph::forms::FormSession& session, std::string outcome);` after +`void reportError(...)`, the members + +```cpp + CommentForm _comment; + FinalizeForm _finalize; + UndoForm _undo; +``` + +directly after `_update`, and directly before `::morph::reactive::Effect _issueOpen;`: + +```cpp + ::morph::examples::FormSuccess _onCommented; + ::morph::examples::FormSuccess _onFinalized; + ::morph::examples::FormSuccess _onUndone; +``` + +In `vote_controller.cpp`, after the `_update{…}` initialiser add: + +```cpp + _comment{wiring.runtime, _handler, ::morph::forms::handlerChoiceFetcher(wiring.callbacks, _handler)}, + _finalize{wiring.runtime, _handler, ::morph::forms::handlerChoiceFetcher(wiring.callbacks, _handler)}, + _undo{wiring.runtime, _handler, ::morph::forms::handlerChoiceFetcher(wiring.callbacks, _handler)}, +``` + +and directly before the `_issueOpen{…}` initialiser: + +```cpp + _onCommented{wiring.runtime, _comment.session(), + [this] { onFormSucceeded(_comment.session(), "comment added"); }}, + _onFinalized{wiring.runtime, _finalize.session(), + [this] { onFormSucceeded(_finalize.session(), "poll finalized"); }}, + _onUndone{wiring.runtime, _undo.session(), + [this] { onFormSucceeded(_undo.session(), "your last vote change is undone"); }}, +``` + +and at the end of the file, before the namespace's closing brace: + +```cpp +void VoteController::onFormSucceeded(::morph::forms::FormSession& session, std::string outcome) { + _state.refetch(); + session.reset(); + _status.set(infoLine(std::move(outcome))); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_polls_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/polls/ladder_polls_tests "[polls][controller]" +``` + +Expected: PASS. Mutation check: delete `_state.refetch();` from `onFormSucceeded`. Expected FAIL in "A comment +through its form shows in the comments" (the comment never appears). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/polls/app/controllers examples/polls/tests/test_vote_forms.cpp +git commit -m "wip(polls): the comment, finalize and undo forms, typed on the attached handler + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 21: The live activity feed — a `Poller` per open poll + +Part 6's `examples::Poller` starts from a fixed cursor, so the vote controller holds one per open poll: an Effect +over the attachment starts a fresh poller at the attach's `lastEventId` and drops it when the user leaves. + +**Files:** +- Modify: `examples/polls/app/controllers/vote_controller.{hpp,cpp}` +- Test: `examples/polls/tests/test_vote_activity.cpp` + +**Interfaces:** +- Consumes: `examples::Poller(reactive::Runtime&, reactive::Scheduler&, Fetch, Cursor start, OnEvent, + PollerOptions)`, `Poller::Page` (`PollPage{events, next}`), `stoppedBy()`, + `PollerOptions{interval}` (`examples/common/app/poller.hpp`, Part 6); `examples::mapCompletion(owner, token, + from, onValue, onError)` (`examples/common/app/completion_map.hpp`, Part 6); `async::CallbackScope::token()`; + `Wiring::{scheduler, callbacks}` (Task 19's constructor already takes them); `polls::{GetEventsSince, + GetEventsSinceResult, PollEvent, PollEventId}`; `reactive::testing::ManualScheduler::advance`. +- Produces: `polls::client::ActivityRow`; `VoteController::{activity(), kActivityPeriod, kActivityRows}`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/polls/tests/test_vote_activity.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#include "client_fixture.hpp" +#include "controllers/vote_controller.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; +using polls::client::VoteController; +using polls::testing::VoteFixture; + +} // namespace + +TEST_CASE("The activity feed shows another participant's vote and refreshes the tallies, all three backend modes", + "[polls][controller]") { + auto const mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + VoteFixture client{mode}; + auto const poll = polls::testing::seedPoll(*client.rig, "T", {"1", "2"}); + polls::testing::openAndWait(client.vote, poll.pollId); + std::int64_t const optionId = client.vote.options()[0].optionId; + + // Another participant, on a handler of its own attached to the same shared poll instance. This client + // never votes, so the only way bob's vote reaches its tallies is the feed's refetch. + morph::bridge::BridgeHandler other{client.rig->bridge(0), + client.rig->executor()}; + static_cast(awaitQt(other.execute(polls::OpenPoll{.pollId = poll.pollId}))); + static_cast(awaitQt(other.execute(polls::SubmitVotes{ + .participantName = "bob", + .votes = {polls::OneVote{.optionId = polls::OptionId{.value = optionId}, .choice = polls::VoteChoice::Yes}}}))); + + client.scheduler.advance(VoteController::kActivityPeriod); + REQUIRE(pumpUntil([&] { return !client.vote.activity().empty(); })); + CHECK(client.vote.activity().back().line.find("[vote]") != std::string::npos); + CHECK(client.vote.activity().back().eventId > 0); + REQUIRE(pumpUntil([&] { return client.vote.options()[0].tally == "yes 1 · if need be 0 · no 0"; })); +} + +TEST_CASE("Leaving the poll empties the activity feed", "[polls][controller]") { + DbFixture fixture; + VoteFixture client{Mode::Local}; + auto const poll = polls::testing::seedPoll(*client.rig, "T", {"1", "2"}); + polls::testing::openAndWait(client.vote, poll.pollId); + client.vote.setParticipantName("alice"); + client.vote.setPick(client.vote.options()[0].optionId, polls::VoteChoice::Yes); + client.vote.vote(); + client.scheduler.advance(VoteController::kActivityPeriod); + REQUIRE(pumpUntil([&] { return !client.vote.activity().empty(); })); + + client.vote.close(); + REQUIRE(pumpUntil([&] { return client.vote.activity().empty(); })); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_polls_tests` +Expected: FAIL — `no member named 'activity' in 'polls::client::VoteController'`. + +- [ ] **Step 3: Implement** + +In `vote_controller.hpp`, add the includes ``, ``, ``, +``, `` and `"app/poller.hpp"`. Before +`class VoteController`, add: + +```cpp +/// @brief One event of the open poll's live activity feed. +struct ActivityRow { + std::int64_t eventId = 0; ///< The event; also the row's key. + std::string line; ///< "#12 [vote] alice voted". + + /// @brief Field-wise equality. + bool operator==(ActivityRow const&) const = default; +}; +``` + +Append to the class doc comment: "The activity feed is an `examples::Poller` over `GetEventsSince` on the same +handler, one per open poll, started at the attach's `lastEventId`; an applied event refetches the state, because +an event carries a summary, not a tally delta." The constructor is unchanged: `wiring.scheduler` paces the feed. + +After `useAdminToken()`'s declaration (Task 20's form accessors follow it) add: + +```cpp + /// @brief The open poll's live activity, newest last, at most `kActivityRows`. Tracked. + /// @return The rows. + [[nodiscard]] std::vector const& activity() const { return _activity.get(); } + + /// @brief How often the activity feed asks for new events. + static constexpr std::chrono::milliseconds kActivityPeriod{3000}; + /// @brief How many activity rows are kept; the tallies, not this log, are the source of truth. + static constexpr std::size_t kActivityRows = 200; +``` + +In the private section, after the `Attachment` struct: + +```cpp + using ActivityPoller = ::morph::examples::Poller; + + void startFeed(PollEventId from); + void stopFeed(); + void onEvent(PollEvent const& event); + [[nodiscard]] ::morph::async::Completion eventsSince(PollEventId since); +``` + +Members — add `::morph::exec::IExecutor* _callbacks;` and `::morph::reactive::Scheduler* _scheduler;` directly after +`_bridge`; directly after `_status`: + +```cpp + // Bumped whenever `_feed` is replaced, so a reader of `_feed` re-reads the new one. + ::morph::reactive::Signal _feedGeneration; + ::morph::reactive::Signal> _events; + // Counts applied events since the feed started; each change refetches the state once per flush. + ::morph::reactive::Signal _eventCount; +``` + +`std::optional _feed;` directly after `_undo` (Task 20); +`::morph::reactive::Computed> _activity;` directly after `_canUseAdminToken`; +`::morph::reactive::Effect _onEvents;` and `::morph::reactive::Effect _onFeedStopped;` directly after +`_onUpdateFailed`; `::morph::reactive::Effect _followAttachment;` directly before `_issueOpen`; and, after +`_issueOpen`, as the last member: + +```cpp + // Last: it gates the GetEventsSince continuations `eventsSince` attaches. + ::morph::async::CallbackScope _lifetime; +``` + +In `vote_controller.cpp`, add `#include "app/completion_map.hpp"`; add the initialisers +`_callbacks{&wiring.callbacks},` and `_scheduler{&wiring.scheduler},` after `_bridge{&wiring.bridge},`; +`_feedGeneration{wiring.runtime, 0},`, `_events{wiring.runtime, std::vector{}},` and +`_eventCount{wiring.runtime, 0},` after `_status{…},`; + +```cpp + _activity{wiring.runtime, + [this] { + std::vector rows; + for (auto const& event : _events.get()) { + rows.push_back(ActivityRow{.eventId = *event.id, + .line = "#" + std::to_string(*event.id) + " [" + event.kind + + "] " + event.summary}); + } + return rows; + }}, +``` + +after `_canUseAdminToken{…},`; + +```cpp + _onEvents{wiring.runtime, + [this] { + if (_eventCount.get() > 0) { + _rt->untracked([this] { _state.refetch(); }); + } + }}, + _onFeedStopped{wiring.runtime, + [this] { + static_cast(_feedGeneration.get()); + auto const error = _feed.has_value() ? _feed->stoppedBy() : std::exception_ptr{}; + if (error != nullptr) { + _status.set(StatusLine{.text = "live updates stopped: " + + ::morph::reactive::errorMessage(error), + .isError = true}); + } + }}, +``` + +after `_onUpdateFailed{…},`; and directly before `_issueOpen{…}`: + +```cpp + _followAttachment{wiring.runtime, + [this] { + auto const& attachment = _attached.get(); + std::optional const from = + attachment.has_value() ? std::optional{attachment->cursor} : std::nullopt; + _rt->untracked([&] { + if (from.has_value()) { + startFeed(*from); + } else { + stopFeed(); + } + }); + }}, +``` + +Add at the end of the file: + +```cpp +void VoteController::startFeed(PollEventId from) { + _feed.reset(); + _rt->batch([this] { + _events.set({}); + _eventCount.set(0); + }); + _feed.emplace(*_rt, *_scheduler, [this](PollEventId const& since) { return eventsSince(since); }, from, + [this](PollEvent const& event) { onEvent(event); }, + ::morph::examples::PollerOptions{.interval = kActivityPeriod}); + _feedGeneration.set(_feedGeneration.peek() + 1); +} + +void VoteController::stopFeed() { + if (!_feed.has_value() && _events.peek().empty()) { + return; + } + _feed.reset(); + _rt->batch([this] { + _events.set({}); + _eventCount.set(0); + _feedGeneration.set(_feedGeneration.peek() + 1); + }); +} + +void VoteController::onEvent(PollEvent const& event) { + _events.mutate([&event](std::vector& events) { + events.push_back(event); + if (events.size() > kActivityRows) { + events.erase(events.begin()); + } + }); + _eventCount.set(_eventCount.peek() + 1); +} + +::morph::async::Completion VoteController::eventsSince(PollEventId since) { + using Page = ActivityPoller::Page; + // The page is the reply's events and the cursor after them; a reply with none keeps the asked cursor. + return ::morph::examples::mapCompletion( + *_callbacks, _lifetime.token(), _handler.execute(GetEventsSince{.lastEventId = since}), + [since](GetEventsSinceResult const& result) { + return Page{.events = result.events, .next = result.events.empty() ? since : result.events.back().id}; + }, + [](std::exception_ptr const&) {}); +} +``` + +`startFeed`, `stopFeed`, `eventsSince` and `_onFeedStopped` are the only places that name the `Poller` surface. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_polls_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/polls/ladder_polls_tests "[polls][controller]" +``` + +Expected: PASS — and every earlier `[polls][controller]` case still passes with a feed running whenever a poll is +open. Mutation check: in `_onEvents`, delete the `_rt->untracked([this] { _state.refetch(); });` call. Expected +FAIL in "The activity feed shows another participant's vote and refreshes the tallies" (the last wait times out: +this client never re-reads otherwise). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/polls/app/controllers examples/polls/tests/client_fixture.hpp examples/polls/tests/test_vote_activity.cpp +git commit -m "wip(polls): the live activity feed, a Poller per open poll + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 22: Navigation and the three screens' views + +**Files:** +- Create: `examples/polls/app/controllers/polls_navigator.{hpp,cpp}`, `examples/polls/app/views/poll_views.{hpp,cpp}` +- Test: `examples/polls/tests/test_polls_navigator.cpp`, `examples/polls/tests/test_poll_views.cpp` + +**Interfaces:** +- Consumes: the four controllers (Tasks 18–21); Part 2's `ui` builders (`grid`, `select`, `textInput`, + `forEach` over a `Signal`, `switchOn`), `ui::Mounted`, `RecordingBackend` (`find`, `all`, `prop`, `exists`, + `click`, `edit`, `choose`); `forms::formView`. +- Produces: `polls::client::{Route, PollsNavigator}` — `PollsNavigator(Runtime&, VoteController&, + CreatePollController&, CreateAccess)` with `route()`, `createOffered()`, `openPoll()`, `showCreate()`, + `showLanding()`; `polls::client::{landingScreen, createScreen, voteScreen, pollsScreen}`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/polls/tests/test_polls_navigator.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include "client_fixture.hpp" +#include "controllers/create_poll_controller.hpp" +#include "controllers/polls_navigator.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; +using polls::client::CreateAccess; +using polls::client::Route; + +} // namespace + +TEST_CASE("The navigator routes landing, create, vote and back, closing the vote screen on the way out", + "[polls][controller]") { + DbFixture fixture; + polls::testing::VoteFixture client{Mode::Local}; + polls::client::CreatePollController create{client.runtime, client.rig->bridge(0), *client.rig->executor()}; + polls::client::PollsNavigator navigator{client.runtime, client.vote, create, CreateAccess::Offered}; + CHECK(navigator.route() == Route::Landing); + CHECK(navigator.createOffered()); + + navigator.showCreate(); + CHECK(navigator.route() == Route::Create); + auto const poll = polls::testing::seedPoll(*client.rig, "T", {"1", "2"}); + navigator.openPoll(" " + poll.pollId + " "); + CHECK(navigator.route() == Route::Vote); + REQUIRE(pumpUntil([&] { return client.vote.attached(); })); + + navigator.showLanding(); + CHECK(navigator.route() == Route::Landing); + CHECK_FALSE(client.vote.attached()); + + navigator.openPoll(" "); + CHECK(navigator.route() == Route::Landing); // a blank id opens nothing +} + +TEST_CASE("With create hidden, the create screen is unreachable", "[polls][controller]") { + DbFixture fixture; + polls::testing::VoteFixture client{Mode::Local}; + polls::client::CreatePollController create{client.runtime, client.rig->bridge(0), *client.rig->executor()}; + polls::client::PollsNavigator navigator{client.runtime, client.vote, create, CreateAccess::Hidden}; + CHECK_FALSE(navigator.createOffered()); + navigator.showCreate(); + CHECK(navigator.route() == Route::Landing); +} +``` + +Create `examples/polls/tests/test_poll_views.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +#include "client_fixture.hpp" +#include "controllers/create_poll_controller.hpp" +#include "controllers/landing_controller.hpp" +#include "controllers/polls_navigator.hpp" +#include "testkit/db_fixture.hpp" +#include "views/poll_views.hpp" + +namespace { + +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; +using morph::ui::Key; +using morph::ui::Mounted; +using morph::ui::testing::RecordingBackend; +namespace client = polls::client; + +/// @brief Every widget of @p kind whose @p prop is @p value. +[[nodiscard]] std::vector allWith(RecordingBackend const& backend, std::string const& kind, + std::string const& prop, std::string const& value) { + std::vector found; + for (int const widget : backend.all(kind)) { + if (backend.prop(widget, prop) == value) { + found.push_back(widget); + } + } + return found; +} + +/// @brief One client with all four controllers and the navigator, as the application wires them. +struct ScreenFixture { + explicit ScreenFixture(client::CreateAccess access) + : vote{Mode::Local}, + landing{vote.runtime}, + create{vote.runtime, vote.rig->bridge(0), *vote.rig->executor()}, + navigator{vote.runtime, vote.vote, create, access} {} + + polls::testing::VoteFixture vote; + client::LandingController landing; + client::CreatePollController create; + client::PollsNavigator navigator; +}; + +} // namespace + +TEST_CASE("landingScreen: the tree, the Open gate, and create offered only where it is", "[polls][view]") { + DbFixture fixture; + for (auto const access : {client::CreateAccess::Offered, client::CreateAccess::Hidden}) { + ScreenFixture screen{access}; + RecordingBackend backend; + Mounted const mounted{screen.vote.runtime, backend, client::landingScreen(screen.landing, screen.navigator)}; + REQUIRE(pumpUntil([&] { return backend.find("Button", "label", "Open").has_value(); })); + auto const open = *backend.find("Button", "label", "Open"); + CHECK(backend.prop(open, "enabled") == "false"); + auto const createButton = backend.find("Button", "label", "Create a new poll (organizer)"); + REQUIRE(createButton.has_value()); + CHECK(backend.prop(*createButton, "visible") == (access == client::CreateAccess::Offered ? "true" : "false")); + + auto const field = backend.find("TextInput", "placeholder", "poll id"); + REQUIRE(field.has_value()); + backend.edit(*field, " abc "); + REQUIRE(pumpUntil([&] { return backend.prop(open, "enabled") == "true"; })); + backend.click(open); + REQUIRE(pumpUntil([&] { return screen.navigator.route() == client::Route::Vote; })); + } +} + +TEST_CASE("createScreen: rows follow the drafts, keep their widgets on a removal, and create a poll", + "[polls][view]") { + DbFixture fixture; + ScreenFixture screen{client::CreateAccess::Offered}; + RecordingBackend backend; + Mounted const mounted{screen.vote.runtime, backend, client::createScreen(screen.create, screen.navigator)}; + REQUIRE(pumpUntil([&] { return allWith(backend, "TextInput", "placeholder", "e.g. 2026-09-01").size() == 2; })); + + auto const add = *backend.find("Button", "label", "+ add option"); + backend.click(add); + REQUIRE(pumpUntil([&] { return allWith(backend, "TextInput", "placeholder", "e.g. 2026-09-01").size() == 3; })); + auto const rows = allWith(backend, "TextInput", "placeholder", "e.g. 2026-09-01"); + auto const removes = allWith(backend, "Button", "label", "remove"); + REQUIRE(removes.size() == 3); + backend.click(removes[1]); + REQUIRE(pumpUntil([&] { return allWith(backend, "TextInput", "placeholder", "e.g. 2026-09-01").size() == 2; })); + CHECK(backend.exists(rows[0])); + CHECK_FALSE(backend.exists(rows[1])); + CHECK(backend.exists(rows[2])); // the surviving rows kept their widgets + + backend.edit(*backend.find("TextInput", "placeholder", "e.g. Team offsite"), "Team offsite"); + backend.edit(rows[0], "2026-09-01"); + backend.edit(rows[2], "2026-09-02"); + auto const submit = *backend.find("Button", "label", "Create poll"); + REQUIRE(pumpUntil([&] { return backend.prop(submit, "enabled") == "true"; })); + backend.click(submit); + REQUIRE(pumpUntil([&] { return screen.create.created(); })); + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", screen.create.pollId()).has_value(); })); + CHECK(backend.find("Button", "label", "Open this poll now").has_value()); +} + +TEST_CASE("voteScreen: the tree, a radio pick and the vote button drive the controller", "[polls][view]") { + DbFixture fixture; + ScreenFixture screen{client::CreateAccess::Offered}; + auto const poll = polls::testing::seedPoll(*screen.vote.rig, "Team offsite", {"1", "2"}); + RecordingBackend backend; + Mounted const mounted{screen.vote.runtime, backend, client::voteScreen(screen.vote.vote, screen.navigator)}; + polls::testing::openAndWait(screen.vote.vote, poll.pollId); + REQUIRE(pumpUntil([&] { return backend.all("Select").size() == 2; })); + CHECK(backend.find("Text", "text", "Team offsite").has_value()); + CHECK(backend.find("Text", "text", "Live activity").has_value()); + CHECK(backend.find("Button", "label", "Comment").has_value()); + CHECK(backend.find("Button", "label", "Finalize").has_value()); + + std::int64_t const optionId = screen.vote.vote.options()[0].optionId; + backend.choose(backend.all("Select").front(), Key{std::string{"Yes"}}); + REQUIRE(pumpUntil([&] { return screen.vote.vote.pickFor(optionId) == polls::VoteChoice::Yes; })); + backend.edit(*backend.find("TextInput", "placeholder", "participant name"), "alice"); + auto const voteButton = *backend.find("Button", "label", "Submit my votes"); + REQUIRE(pumpUntil([&] { return backend.prop(voteButton, "enabled") == "true"; })); + backend.click(voteButton); + REQUIRE(pumpUntil([&] { return backend.find("Text", "text", "yes 1 · if need be 0 · no 0").has_value(); })); + CHECK(backend.prop(voteButton, "label") == "Update my votes"); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/all --target ladder_polls_tests` +Expected: FAIL — `'controllers/polls_navigator.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/polls/app/controllers/polls_navigator.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +#include "controllers/create_poll_controller.hpp" +#include "controllers/landing_controller.hpp" +#include "controllers/vote_controller.hpp" + +/// @file +/// Which of polls' three screens shows, and the moves between them. + +namespace polls::client { + +/// @brief polls' screens. +enum class Route : std::uint8_t { + Landing, ///< Open a poll by id, or (native) create one. + Create, ///< The organizer's create-poll screen. + Vote, ///< One open poll. +}; + +/// @brief The navigation controller: the route, and what each move does to the screens it leaves and enters. +class PollsNavigator { +public: + /// @param runtime The runtime. + /// @param vote The vote screen's controller. Borrowed. + /// @param create The create screen's controller. Borrowed. + /// @param access Whether this build may create polls. + PollsNavigator(::morph::reactive::Runtime& runtime, VoteController& vote, CreatePollController& create, + CreateAccess access); + + ~PollsNavigator() = default; + PollsNavigator(PollsNavigator const&) = delete; + PollsNavigator& operator=(PollsNavigator const&) = delete; + PollsNavigator(PollsNavigator&&) = delete; + PollsNavigator& operator=(PollsNavigator&&) = delete; + + /// @brief The screen showing. Tracked. + /// @return The route. + [[nodiscard]] Route route() const { return _route.get(); } + + /// @brief Whether the landing screen offers "Create a new poll". + /// @return True for `CreateAccess::Offered`. + [[nodiscard]] bool createOffered() const noexcept { return _access == CreateAccess::Offered; } + + /// @brief Opens a poll on the vote screen; a blank id does nothing. + /// @param pollId The poll's id, blanks allowed around it. + void openPoll(std::string const& pollId); + + /// @brief Shows an empty create screen; refused when creating is not offered. + void showCreate(); + + /// @brief Back to the landing screen, leaving any open poll. + void showLanding(); + +private: + VoteController* _vote; + CreatePollController* _create; + CreateAccess _access; + ::morph::reactive::Signal _route; +}; + +} // namespace polls::client +``` + +Create `examples/polls/app/controllers/polls_navigator.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/polls_navigator.hpp" + +#include + +#include "controllers/poll_text.hpp" + +namespace polls::client { + +PollsNavigator::PollsNavigator(::morph::reactive::Runtime& runtime, VoteController& vote, CreatePollController& create, + CreateAccess access) + : _vote{&vote}, _create{&create}, _access{access}, _route{runtime, Route::Landing} {} + +void PollsNavigator::openPoll(std::string const& pollId) { + std::string poll = trimmed(pollId); + if (poll.empty()) { + return; + } + _route.set(Route::Vote); + _vote->open(std::move(poll)); +} + +void PollsNavigator::showCreate() { + if (!createOffered()) { + return; + } + _create->startOver(); + _route.set(Route::Create); +} + +void PollsNavigator::showLanding() { + _vote->close(); + _route.set(Route::Landing); +} + +} // namespace polls::client +``` + +Create `examples/polls/app/views/poll_views.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "controllers/create_poll_controller.hpp" +#include "controllers/landing_controller.hpp" +#include "controllers/polls_navigator.hpp" +#include "controllers/vote_controller.hpp" + +/// @file +/// polls' three screens as view trees: bindings over the controllers, and nothing else. Every controller +/// passed in is borrowed and must outlive the mounted tree. + +namespace polls::client { + +/// @brief The landing screen: open a poll by id; create one where that is offered. +/// @param landing The landing controller. +/// @param navigator The navigation controller. +/// @return The screen. +[[nodiscard]] ::morph::ui::Node landingScreen(LandingController& landing, PollsNavigator& navigator); + +/// @brief The create-poll screen: the title, the option drafts (a keyed `forEach` over their signal), and +/// the created poll's ids. +/// @param create The create controller. +/// @param navigator The navigation controller. +/// @return The screen. +[[nodiscard]] ::morph::ui::Node createScreen(CreatePollController& create, PollsNavigator& navigator); + +/// @brief The vote screen: the vote grid (one row of radio choices per option), comments, admin, the three +/// forms and the live activity feed. +/// @param vote The vote controller. +/// @param navigator The navigation controller. +/// @return The screen. +[[nodiscard]] ::morph::ui::Node voteScreen(VoteController& vote, PollsNavigator& navigator); + +/// @brief The whole client: a `Switch` over the navigator's route. +/// @param navigator The navigation controller. +/// @param landing The landing controller. +/// @param create The create controller. +/// @param vote The vote controller. +/// @return The root node. +[[nodiscard]] ::morph::ui::Node pollsScreen(PollsNavigator& navigator, LandingController& landing, + CreatePollController& create, VoteController& vote); + +} // namespace polls::client +``` + +Create `examples/polls/app/views/poll_views.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "views/poll_views.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "controllers/poll_text.hpp" + +namespace polls::client { + +namespace { + +namespace ui = ::morph::ui; + +[[nodiscard]] ui::Node form(::morph::forms::FormSession& session, std::string submitLabel) { + return ::morph::forms::formView(session, {.overrides = {}, .gridColumns = 1, .submitLabel = std::move(submitLabel)}); +} + +template +[[nodiscard]] ui::Node statusOf(Controller& controller) { + return ui::text({.text = [&controller] { return controller.statusText(); }, + .role = [&controller] { + return controller.statusIsError() ? ui::TextRole::Error : ui::TextRole::Success; + }, + .common = {.visible = [&controller] { return controller.hasStatus(); }}}); +} + +[[nodiscard]] std::vector choiceOptions() { + std::vector options; + for (VoteChoice const choice : {VoteChoice::Yes, VoteChoice::IfNeedBe, VoteChoice::No}) { + options.push_back(ui::SelectOption{.key = ui::Key{choiceName(choice)}, .label = choiceLabel(choice)}); + } + return options; +} + +[[nodiscard]] ui::Node optionGrid(VoteController& vote, ::morph::reactive::Signal const& row) { + return ui::grid( + {.columns = 3, + .cells = {ui::GridCell{.node = ui::text({.text = [&row] { return row.get().label; }})}, + ui::GridCell{.node = ui::text({.text = [&row] { return row.get().tally; }, + .role = ui::TextRole::Muted})}, + ui::GridCell{.node = ui::select( + {.options = choiceOptions(), + .selected = + [&vote, &row] { + return std::optional{ + ui::Key{choiceName(vote.pickFor(row.get().optionId))}}; + }, + .onSelect = + [&vote, &row](ui::Key const& key) { + if (auto const* name = std::get_if(&key)) { + vote.setPick(row.peek().optionId, choiceNamed(*name)); + } + }, + .style = ui::SelectStyle::Radio, + .common = {.enabled = [&vote] { return vote.canChangeVotes(); }}})}}, + .gap = 2}); +} + +[[nodiscard]] ui::Node backRow(PollsNavigator& navigator, ui::Prop heading) { + return ui::row({.children = {ui::button({.label = "< Back", .onClick = [&navigator] { navigator.showLanding(); }}), + ui::text({.text = std::move(heading), .role = ui::TextRole::Heading})}, + .gap = 2}); +} + +} // namespace + +::morph::ui::Node landingScreen(LandingController& landing, PollsNavigator& navigator) { + return ui::column( + {.children = {ui::text({.text = "Doodle-style scheduling polls", .role = ui::TextRole::Heading}), + ui::text({.text = "Open a poll (paste the shared link's id)"}), + ui::row({.children = {ui::textInput({.value = [&landing] { return landing.pollIdDraft(); }, + .onChange = [&landing](std::string text) { + landing.setPollIdDraft(std::move(text)); + }, + .onSubmit = [&landing, &navigator](std::string const&) { + navigator.openPoll(landing.trimmedPollId()); + }, + .placeholder = "poll id", + .common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::button({.label = "Open", + .onClick = [&landing, &navigator] { + navigator.openPoll(landing.trimmedPollId()); + }, + .common = {.enabled = [&landing] { return landing.canOpen(); }}})}, + .gap = 1}), + ui::button({.label = "Create a new poll (organizer)", + .onClick = [&navigator] { navigator.showCreate(); }, + .common = {.visible = [&navigator] { return navigator.createOffered(); }}})}, + .gap = 1}); +} + +::morph::ui::Node createScreen(CreatePollController& create, PollsNavigator& navigator) { + ui::Node const draftRows = ui::forEach( + create.options(), [](OptionDraft const& draft) { return ui::Key{draft.key}; }, + [&create](::morph::reactive::Signal const& draft) { + return ui::row( + {.children = {ui::textInput({.value = [&draft] { return draft.get().label; }, + .onChange = [&create, &draft](std::string text) { + create.setOptionLabel(draft.peek().key, std::move(text)); + }, + .onSubmit = {}, + .placeholder = "e.g. 2026-09-01", + .common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::button({.label = "remove", + .onClick = [&create, &draft] { create.removeOption(draft.peek().key); }, + .common = {.enabled = [&create] { return create.canRemove(); }}})}, + .gap = 1}); + }); + ui::Node const editor = ui::panel( + {.title = "New poll", + .padding = 1, + .child = ui::column( + {.children = {ui::text({.text = "Title"}), + ui::textInput({.value = [&create] { return create.title(); }, + .onChange = [&create](std::string text) { create.setTitle(std::move(text)); }, + .onSubmit = {}, + .placeholder = "e.g. Team offsite"}), + ui::text({.text = "Candidate dates/options (2-20)"}), draftRows, + ui::button({.label = "+ add option", + .onClick = [&create] { create.addOption(); }, + .common = {.enabled = [&create] { return create.canAdd(); }}}), + ui::button({.label = "Create poll", + .onClick = [&create] { create.submit(); }, + .common = {.enabled = [&create] { return create.canSubmit(); }}})}, + .gap = 1}), + .common = {.visible = [&create] { return create.editing(); }}}); + ui::Node const result = ui::panel( + {.title = "Poll created", + .padding = 1, + .child = ui::column( + {.children = {ui::text({.text = "Poll id (share it with the participants):"}), + ui::text({.text = [&create] { return create.pollId(); }}), + ui::text({.text = "Admin token (keep it: finalizing the poll needs it):"}), + ui::text({.text = [&create] { return create.adminToken(); }}), + ui::text({.text = "Participant token (goes out with the shared link):"}), + ui::text({.text = [&create] { return create.participantToken(); }}), + ui::button({.label = "Open this poll now", + .onClick = [&create, &navigator] { navigator.openPoll(create.pollId()); }})}, + .gap = 1}), + .common = {.visible = [&create] { return create.created(); }}}); + return ui::column({.children = {backRow(navigator, "Create a poll"), statusOf(create), editor, result}, .gap = 1}); +} + +::morph::ui::Node voteScreen(VoteController& vote, PollsNavigator& navigator) { + ui::Node const votes = ui::column( + {.children = {ui::text({.text = "Your name"}), + ui::textInput({.value = [&vote] { return vote.participantName(); }, + .onChange = [&vote](std::string text) { vote.setParticipantName(std::move(text)); }, + .onSubmit = {}, + .placeholder = "participant name"}), + ui::text({.text = "Options", .role = ui::TextRole::Heading}), + ui::forEach( + [&vote] { return vote.options(); }, [](OptionRow const& row) { return ui::Key{row.optionId}; }, + [&vote](::morph::reactive::Signal const& row) { return optionGrid(vote, row); }), + ui::button({.label = [&vote] { return vote.voteLabel(); }, + .onClick = [&vote] { vote.vote(); }, + .common = {.enabled = [&vote] { return vote.canVote(); }}}), + ui::panel({.title = "Undo my last change", .padding = 1, .child = form(vote.undoForm().session(), "Undo")})}, + .gap = 1, + .common = {.layout = {.width = ui::Sizing::stretch()}}}); + ui::Node const talk = ui::column( + {.children = {ui::text({.text = [&vote] { return vote.commentsHeading(); }, .role = ui::TextRole::Heading}), + ui::forEach( + [&vote] { return vote.comments(); }, [](CommentRow const& row) { return ui::Key{row.position}; }, + [](::morph::reactive::Signal const& row) { + return ui::text({.text = [&row] { return row.get().line; }}); + }), + ui::panel({.title = "Add a comment", .padding = 1, .child = form(vote.commentForm().session(), "Comment")}), + ui::text({.text = "Admin", .role = ui::TextRole::Heading}), + ui::row({.children = {ui::textInput({.value = [&vote] { return vote.adminTokenDraft(); }, + .onChange = [&vote](std::string text) { + vote.setAdminTokenDraft(std::move(text)); + }, + .onSubmit = {}, + .placeholder = "admin token", + .mode = ui::TextInputMode::Password, + .common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::button({.label = "use", + .onClick = [&vote] { vote.useAdminToken(); }, + .common = {.enabled = [&vote] { return vote.canUseAdminToken(); }}})}, + .gap = 1}), + ui::panel({.title = "Finalize", + .padding = 1, + .child = form(vote.finalizeForm().session(), "Finalize"), + .common = {.enabled = [&vote] { return vote.canFinalize(); }}})}, + .gap = 1, + .common = {.layout = {.width = ui::Sizing::stretch()}}}); + ui::Node const activity = ui::column( + {.children = {ui::text({.text = "Live activity", .role = ui::TextRole::Heading}), + ui::forEach( + [&vote] { return vote.activity(); }, [](ActivityRow const& row) { return ui::Key{row.eventId}; }, + [](::morph::reactive::Signal const& row) { + return ui::text({.text = [&row] { return row.get().line; }, .role = ui::TextRole::Muted}); + })}, + .gap = 1, + .common = {.layout = {.width = ui::Sizing::stretch()}}}); + return ui::column({.children = {backRow(navigator, [&vote] { return vote.title(); }), statusOf(vote), + ui::row({.children = {votes, talk, activity}, + .gap = 2, + .common = {.layout = {.height = ui::Sizing::stretch()}}})}, + .gap = 1}); +} + +::morph::ui::Node pollsScreen(PollsNavigator& navigator, LandingController& landing, CreatePollController& create, + VoteController& vote) { + return ui::column( + {.children = {ui::text({.text = "polls", .role = ui::TextRole::Heading}), + ui::switchOn([&navigator] { return navigator.route(); }, + {{Route::Landing, landingScreen(landing, navigator)}, + {Route::Create, createScreen(create, navigator)}, + {Route::Vote, voteScreen(vote, navigator)}})}, + .gap = 1}); +} + +} // namespace polls::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: + +```bash +cmake --build build/all --target ladder_polls_tests \ + && QT_QPA_PLATFORM=offscreen ./build/all/examples/polls/ladder_polls_tests "[polls][controller],[polls][view]" +``` + +Expected: PASS. Mutation check: in `createScreen`, key the option rows by position instead of draft key — replace +`[](OptionDraft const& draft) { return ui::Key{draft.key}; }` with a lambda returning a running index. Expected +FAIL in "createScreen: rows follow the drafts, keep their widgets on a removal…" (`CHECK(backend.exists(rows[2]))`: +the last row's widget is the one destroyed). Restore. + +- [ ] **Step 5: Commit** + +```bash +git add examples/polls/app examples/polls/tests/test_polls_navigator.cpp examples/polls/tests/test_poll_views.cpp +git commit -m "wip(polls): navigation and the landing, create-poll and vote screens + +Signed-off-by: Christian Parpart " +``` + +--- +### Task 23: polls' application, its native binary beside the browser one, and the old client deleted + +**Files:** +- Create: `examples/polls/app/polls_application.{hpp,cpp}`, `examples/polls/ui/main.cpp` +- Modify: `codecov.yml` — polls' two `ignore:` entries +- Delete: `examples/polls/gui/`, `examples/polls/gui_lib/`, `examples/polls/gui_wasm/`, + `examples/polls/tests/{test_poll_presenter,test_poll_qml_bridges,test_gui_qml_smoke}.cpp` +- Test: `examples/polls/tests/smoke/test_polls_frontends.cpp` (binary `ladder_polls_smoke_tests`, C3) + +**Interfaces:** +- Consumes: as Task 6, plus `AppEnvironment::pollId` (Part 6's `fromArgs` fills it from `--poll` natively and from + the page url's `?poll=` in the browser build), `Connection::ready()`, `ui::AppContext::{runtime, scheduler}`, + `examples::Wiring`, `polls::db::setup`; the controllers and `pollsScreen` (Tasks 18–22). +- Produces: `polls::client::PollsApplication(ui::AppContext&, examples::AppEnvironment const&)`, + `polls::client::makeApplication(ui::AppContext&, examples::AppEnvironment const&)` (the contract's convention: + the local database setup and `CreateAccess` — offered natively, hidden in the browser build — are decided + inside); the `polls` binary — native for the first time. + +Claims of the deleted test files, and where each one now lives: + +| Deleted case | Re-expressed as | +|---|---| +| presenter: createPoll then openPoll (3 modes) | `test_vote_controller.cpp` "The vote screen opens a poll the create screen made" | +| presenter: getPollState after openPoll (3 modes) | "refresh re-reads the same poll's state" | +| presenter: submitVotes tallies (3 modes) | "A vote is tallied, and a second one replaces it" | +| presenter: updateVotes replaces (3 modes) | same case | +| presenter: addComment (3 modes) | `test_vote_forms.cpp` "A comment through its form shows in the comments" | +| presenter: finalizePoll with the admin token (3 modes) | "FinalizePoll is refused without the admin token and finalizes with it" | +| presenter: undoLastVoteChange (3 modes) | "UndoLastVoteChange puts the participant's previous votes back" | +| presenter: getEventsSince (3 modes) | `test_vote_activity.cpp` "The activity feed shows another participant's vote…" | +| presenter: every validation-driven action routes its failure | create: `test_create_poll_controller.cpp` "canSubmit needs a title and every option label…"; vote: "A vote is tallied…" (no name: `canVote()` false, `vote()` issues nothing); forms: "The vote screen's three forms are … each explicit" (`ready()` false for what `validate()` refuses) | +| presenter: getPollState/getEventsSince never attached | `test_vote_controller.cpp` "Before a poll is open nothing is issued…" (both are keyed on the attachment: idle, not failed) | +| presenter: finalizePoll with no session | "FinalizePoll is refused without the admin token…" (its first half) | +| bridges: QML surface audit | dropped with the QML (spec 4 §7) | +| bridges: createPoll bag {pollId, adminToken, participantToken} | "The create screen creates a poll and shows its three ids" | +| bridges: createPoll with < 2 options fails | "option drafts: the bounds hold…" (fewer than two cannot be built) + "canSubmit needs…" | +| bridges: openPoll emits state; a bad id fails | "The vote screen opens a poll…" + "Opening an unknown poll reports the model's message and attaches nothing" | +| bridges: openPoll's attach threads through every later action | the vote, form and activity suites all run on the one attached handler; "FinalizePoll is refused…" and "UndoLastVoteChange…" run after votes on it | +| bridges: submitIfValid refuses an action outside the schema document | "The vote screen's three forms are AddComment, FinalizePoll and UndoLastVoteChange, each explicit" — there is no untyped submission path left to refuse through | +| bridges: the EventPoller applies a live event and refreshes state | "The activity feed shows another participant's vote and refreshes the tallies" | +| smoke: Main.qml, the create screen, the vote screen load; three Submit buttons | `tests/smoke/test_polls_frontends.cpp` (TUI and Qt Quick) and `test_poll_views.cpp` (each screen; "Comment", "Finalize", "Undo" buttons) | + +- [ ] **Step 1: Write the failing test** + +Create `examples/polls/tests/smoke/test_polls_frontends.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "app/app_environment.hpp" +#include "polls_application.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/frontend_smoke.hpp" + +#if MORPH_EXAMPLE_HAS_TUI || MORPH_EXAMPLE_HAS_QT_QUICK + +namespace { + +/// @brief The application in local mode, `--db` naming the database `DbFixture` already prepared. +[[nodiscard]] morph::ui::ApplicationFactory pollsFactory() { + return [](morph::ui::AppContext& ctx) { + morph::examples::AppEnvironment env; + env.db = morph::ladder::testkit::DbFixture::computeConnectionString(std::getenv("ODBC_CONNECTION_STRING")); + return polls::client::makeApplication(ctx, env); + }; +} + +} // namespace + +#endif + +#if MORPH_EXAMPLE_HAS_TUI +TEST_CASE("polls mounts and quits on the TUI", "[polls][frontend]") { + morph::ladder::testkit::DbFixture const fixture; + morph::examples::testing::runFrontendSmoke(pollsFactory(), morph::examples::testing::SmokeFrontend::Tui); +} +#endif + +#if MORPH_EXAMPLE_HAS_QT_QUICK +TEST_CASE("polls mounts and quits on Qt Quick", "[polls][frontend]") { + morph::ladder::testkit::DbFixture const fixture; + morph::examples::testing::runFrontendSmoke(pollsFactory(), morph::examples::testing::SmokeFrontend::QtQuick); +} +#endif +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake --build build/all --target ladder_polls_smoke_tests` +Expected: FAIL — `'polls_application.hpp' file not found`. + +- [ ] **Step 3: Implement the application and the binary** + +Create `examples/polls/app/polls_application.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include "app/app_environment.hpp" +#include "app/transport.hpp" +#include "controllers/create_poll_controller.hpp" +#include "controllers/landing_controller.hpp" +#include "controllers/polls_navigator.hpp" +#include "controllers/vote_controller.hpp" + +/// @file +/// polls as a `ui::Application`. + +namespace polls::client { + +/// @brief polls' application: one connection, the landing, create and vote controllers, and the navigator. +/// A poll id in the environment (`--poll`, or a browser's `?poll=`) opens that poll at start-up. A native +/// build offers creating a poll; the browser build does not (the README's design decision 6). +class PollsApplication final : public ::morph::ui::Application { +public: + /// @param ctx The frontend's context; its scheduler runs the vote screen's timers. + /// @param env What the command line (or the browser build) asked for; without `--server` its `db` (else + /// `POLLS_DB`, else `polls.db` in the working directory) is the database set up and hosted. + PollsApplication(::morph::ui::AppContext& ctx, ::morph::examples::AppEnvironment const& env); + + ~PollsApplication() override = default; + PollsApplication(PollsApplication const&) = delete; + PollsApplication& operator=(PollsApplication const&) = delete; + PollsApplication(PollsApplication&&) = delete; + PollsApplication& operator=(PollsApplication&&) = delete; + + /// @brief The client's root view. + /// @return `pollsScreen()` over the controllers. + [[nodiscard]] ::morph::ui::Node view() override; + +private: + std::unique_ptr<::morph::examples::Connection> _connection; + LandingController _landing; + CreatePollController _create; + VoteController _vote; + PollsNavigator _navigator; +}; + +/// @brief The application factory `ui/main.cpp` and the smoke tests share. +/// @param ctx The frontend's context. +/// @param env The environment. +/// @return The application. +[[nodiscard]] std::unique_ptr<::morph::ui::Application> makeApplication(::morph::ui::AppContext& ctx, + ::morph::examples::AppEnvironment const& env); + +} // namespace polls::client +``` + +Create `examples/polls/app/polls_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "polls_application.hpp" + +#include +#include + +#include "app/wiring.hpp" +#include "views/poll_views.hpp" + +#ifndef __EMSCRIPTEN__ +#include "polls/db/database.hpp" +#endif + +namespace polls::client { + +namespace { + +// Creating a poll is the organizer's, from a native client; a participant in a browser follows a shared link. +#ifdef __EMSCRIPTEN__ +constexpr CreateAccess kCreateAccess = CreateAccess::Hidden; +#else +constexpr CreateAccess kCreateAccess = CreateAccess::Offered; +#endif + +/// @brief How a client without `--server` prepares its database: `--db`, else `POLLS_DB`, else `polls.db` in the +/// working directory. The browser build is always remote and never calls it. +[[nodiscard]] ::morph::examples::LocalSetup localSetup() { +#ifdef __EMSCRIPTEN__ + return ::morph::examples::LocalSetup{}; +#else + return ::morph::examples::LocalSetup{.setupDatabase = [](std::string const& database) { + if (!database.empty()) { + db::setup(database); + return; + } + char const* fromEnvironment = std::getenv("POLLS_DB"); + db::setup(fromEnvironment != nullptr ? fromEnvironment : "DRIVER=SQLite3;Database=polls.db;Timeout=5000"); + }}; +#endif +} + +} // namespace + +PollsApplication::PollsApplication(::morph::ui::AppContext& ctx, ::morph::examples::AppEnvironment const& env) + : _connection{::morph::examples::connect(ctx, env, localSetup())}, + _landing{ctx.runtime()}, + _create{ctx.runtime(), _connection->bridge(), _connection->callbacks()}, + _vote{::morph::examples::Wiring{.runtime = ctx.runtime(), + .scheduler = ctx.scheduler(), + .bridge = _connection->bridge(), + .callbacks = _connection->callbacks()}, + [this] { return _connection->ready().get(); }}, + _navigator{ctx.runtime(), _vote, _create, kCreateAccess} { + if (env.pollId.has_value()) { + _navigator.openPoll(*env.pollId); + } +} + +::morph::ui::Node PollsApplication::view() { return pollsScreen(_navigator, _landing, _create, _vote); } + +std::unique_ptr<::morph::ui::Application> makeApplication(::morph::ui::AppContext& ctx, + ::morph::examples::AppEnvironment const& env) { + return std::make_unique(ctx, env); +} + +} // namespace polls::client +``` + +Create `examples/polls/ui/main.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// polls' one binary, native and WebAssembly: it reads the environment, offers every frontend this build +/// has, lets `ui::selectFrontend` pick one, and hands the frontend the application factory. +/// +/// @code +/// polls # in-process: hosts PollModel over --db / POLLS_DB +/// polls --server ws://127.0.0.1:8767 # against ladder_polls_server +/// polls --poll # straight to that poll's vote screen +/// @endcode +/// +/// The browser build is always remote, against `MORPH_LADDER_POLLS_WASM_SERVER_URL` unless the page url names a +/// `?server=`; `AppEnvironment::fromArgs` reads the page url's `?poll=` into `pollId`, so a shared link opens +/// its poll. It does not offer creating a poll — an organizer creates from a native client, and participants +/// follow the shared link (README, design decision 6; `app/polls_application.cpp` decides it). + +#include +#include +#include +#include +#include + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#endif +#if MORPH_EXAMPLE_HAS_TUI +#include +#endif + +#include "app/app_environment.hpp" +#include "polls_application.hpp" + +int main(int argc, char** argv) { + try { + auto env = morph::examples::AppEnvironment::fromArgs(argc, argv); +#ifdef __EMSCRIPTEN__ + if (!env.server.has_value()) { + env.server = std::string{MORPH_LADDER_POLLS_WASM_SERVER_URL}; + } +#endif + std::vector built; +#if MORPH_EXAMPLE_HAS_QT_QUICK + built.push_back(morph::qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_EXAMPLE_HAS_TUI + built.push_back(morph::tui::frontendOption()); +#endif + auto const frontend = morph::ui::selectFrontend(built, argc, argv); + return frontend->run([&env](morph::ui::AppContext& ctx) { return polls::client::makeApplication(ctx, env); }); + } catch (std::exception const& error) { + std::cerr << "polls: " << error.what() << '\n'; + } catch (...) { + std::cerr << "polls: unknown error\n"; + } + return 1; +} +``` + +- [ ] **Step 4: Delete the old client, its tests, and its coverage exclusions** + +```bash +git rm -r -q examples/polls/gui examples/polls/gui_lib examples/polls/gui_wasm \ + examples/polls/tests/test_poll_presenter.cpp examples/polls/tests/test_poll_qml_bridges.cpp \ + examples/polls/tests/test_gui_qml_smoke.cpp +``` + +In `codecov.yml`, replace the `ignore:` entries `"examples/polls/gui/**"` and `"examples/polls/gui_wasm/**"` with +the one entry `"examples/polls/ui/**"`. + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cmake --build build/all --target ladder_polls_tests ladder_polls_smoke_tests polls ladder_polls_server +QT_QPA_PLATFORM=offscreen ./build/all/examples/polls/ladder_polls_tests "[polls]" +ctest --test-dir build/all -R '^polls\.smoke\.' --output-on-failure +ls examples/polls # CMakeLists.txt README.md app include src tests ui +``` + +Expected: PASS; the `ctest` line runs both frontend cases. Mutation check: change +`PollsApplication::view()`'s body to `throw std::logic_error{"no view"};` (with `#include `). Expected +FAIL in both `polls.smoke.` cases. Restore. + +- [ ] **Step 6: Commit** + +```bash +git add -A examples/polls codecov.yml +git commit -m "wip(polls): the application, a native binary beside the browser one, the QML client removed + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 24: polls' README + +**Files:** +- Modify: `examples/polls/README.md` — the status paragraph, three design-decision sentences, two + Definition-of-done bullets, and everything from the (duplicated) heading "The client, and its known gaps" + to the end of the file + +**Interfaces:** none. + +- [ ] **Step 1: List the stale references** + +```bash +grep -n 'gui_lib\|gui_wasm\|gui/main\.cpp\|gui/qml\|\.qml\|PollBridge\|PollPresenter\|PollFormsController\|DynamicForm\|EventPoller\|nativeClient' examples/polls/README.md +``` + +Expected: lines 53, 138, 160, 381–437 and 439–593 (the sections the next step replaces). + +- [ ] **Step 2: Rewrite** + +Replace the status paragraph (lines 3–10, "**Status: shipped** …" through "… per [`LADDER.md`](../LADDER.md)'s +discipline rule.") with: + +```markdown +**Status: shipped** — every rung-3 task is complete; see [Definition of done](#definition-of-done) for what that +does and does not mean, and ["The client, and its known gaps"](#the-client-and-its-known-gaps--stated-rather-than-smoothed-over) +for what the client cannot reach. The client runs natively on the terminal UI and on Qt Quick; its browser build +is the same program, compile-gated in CI and never run here. Design decisions below were resolved in writing +before implementation began, per [`LADDER.md`](../LADDER.md)'s discipline rule. +``` + +In design decision 1, replace "is accordingly generated, stored, returned and shown by\n `CreatePollView.qml` —" +with "is accordingly generated, stored, returned and shown by\n the create screen (`app/views/poll_views.cpp`) —". +In design decision 6, replace "since the `nativeClient` gate\n in `gui/qml/Main.qml` still points here." with +"since the browser build's\n `CreateAccess::Hidden` (`app/polls_application.cpp`) still points here.", and replace +"it dispatches through\n `PollPresenter::_creator`, a plain `NoSharing`" with "it dispatches through\n +`CreatePollController`'s handler, a plain `NoSharing`" (`\n` marks the file's own line break; keep its indentation). + +In "Definition of done", replace the "Live demo" bullet's text after its first sentence (from "**Not satisfied.**" +to the end of the bullet) with: + +```markdown + The `polls` binary runs natively on the terminal UI and on Qt Quick, so the demo can be run; it is a manual + check (the group's verification step records what was exercised), not an automated one. Beneath it, + `tests/test_app.cpp` drives the remote backend end to end, the vote-controller suites drive every action + through the one attached handler in all three backend modes — including another participant's vote reaching + this client through the activity feed — and `tests/test_shared_instance_lifecycle.cpp` covers multi-handler + convergence on one shared poll. +``` + +and replace the "event-polling helper" bullet's **Confirmed (Task 15)** paragraph with: + +```markdown + **Confirmed:** the client's activity feed is `examples::Poller` + (`examples/common/app/poller.hpp`), outside this rung: it names no `polls::` type and takes its fetch as a + function, which is what lets kanban drive its own feed with it. `VoteController` is its consumer here. +``` + +Replace everything from the first "## The client, and its known gaps — stated rather than smoothed over" line to +the end of the file with: + +```markdown +## The client, and its known gaps — stated rather than smoothed over + +The client (`app/`, target `ladder_polls_app`) is toolkit-free; `ui/main.cpp` picks the terminal UI or Qt Quick. +Three screens, one controller each, and a navigator between them: + +- **Landing** — paste a poll id and open it; on a native client, "Create a new poll (organizer)". +- **Create a poll** — a title and 2–20 option drafts. `CreatePoll::options` is a list of objects, which a schema + form does not edit, so the drafts are a signal the view renders with a keyed `forEach`: each draft keeps its + key across every add and remove, so removing one leaves the others' fields alone. The created poll's id, + admin token and participant token show once. +- **Vote** — the vote grid (one row per option: label, tally, a radio choice of Yes / If need be / No), the + participant's name and the Submit/Update button, comments, the admin token, and three forms — + `AddComment`, `FinalizePoll`, `UndoLastVoteChange` — each a typed `forms::Form` + with its own Submit button (`explicitSubmit`). The live activity feed polls `GetEventsSince` every three + seconds; each applied event refetches the poll's state, because an event carries a summary, not a tally. + +Every action on an open poll runs through **one** `BridgeHandler`: `OpenPoll` attaches +it to the poll's shared instance, and an action on any other handler would have no instance to run against. A +reply to an `OpenPoll` for a poll the user has already left is dropped. A browser following a `?poll=` link +opens its poll at start-up, while its socket may still be connecting — and a keyed attach is refused outright then +("disconnected") rather than queued, because it may re-point a live instance. The vote controller therefore issues +`OpenPoll` only once the connection reports ready (`examples::Connection::ready()`). + +What the client shows differently from the QML client it replaced: + +- It runs natively. The QML client was browser-only, so creating a poll — native-only by design decision 6 — + had no client at all; the native `polls` binary is that client. +- The created poll's ids show as text, not as selectable read-only fields: a terminal has no selection to copy + from, and the Qt Quick rendering follows the same view. +- A form shows its own reply or error; the screen's status line reports opening, votes and the feed. + +Known gaps: + +- **The finalize form asks for an option's number.** `FinalizePoll::optionId` is a plain `OptionId`, not a + `morph::forms::Choice`, so the form takes the `#id` the vote grid shows rather than offering the options. +- **The results display resyncs on every applied event rather than applying an increment.** `PollEvent` carries + no tally delta, so each tick with an event refetches `GetPollState` — correct and simple, one extra round trip + per busy tick, worth revisiting if a later rung's event volume makes it not. +- **The browser build cannot create a poll at all** (design decision 6); a participant follows a link or pastes + an id. +- **The browser build is compile-gated, not run.** `.github/workflows/wasm-ladder.yml` builds target `polls` in + its Emscripten configure; its `?poll=` handling and the wait for a ready connection have never run in a + browser here. +- **No admin-token persistence.** The token is installed as the bridge's default session for the rest of the + process; reopening the client needs it pasted again. +- **The frontend smoke tests prove mounting, not behaviour** — the behavioural half is the controller and view + suites plus a manual run against `ladder_polls_server`. +``` + +- [ ] **Step 3: Verify** + +Re-run Step 1's `grep`. Expected: no output. Then markdownlint on the file. Expected: no findings. + +- [ ] **Step 4: Commit** + +```bash +git add examples/polls/README.md +git commit -m "wip(polls): README describes the native and browser client + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task 25: Verify polls, the part as a whole, and squash the group + +**Files:** none new. + +- [ ] **Step 1: The three rungs' suites and the shared testkit's** + +```bash +cmake --build build/all +ctest --test-dir build/all -L 'ladder-polls|ladder-bookmarks|ladder-pastebin|ladder-0' --output-on-failure +``` + +Expected: every test passes. + +- [ ] **Step 2: The clients are toolkit-free** + +```bash +grep -rnE '#include # participant 1 +./build/all/examples/polls/polls --server ws://127.0.0.1:8767 --ui=tui --poll # participant 2 +kill %1 +``` + +Vote from both participants and watch each one's activity feed and tallies follow within a poll period; finalize +with the admin token on the organizer and see voting lock on the participants. Record what was exercised in the +squash body. + +- [ ] **Step 5: Sanitizer** + +```bash +cmake --preset clang-asan -DMORPH_BUILD_QT=ON -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT_QUICK=ON \ + -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=polls +cmake --build build/clang-asan --target ladder_polls_tests +bash scripts/check_sanitizer_instrumentation.sh --binary build/clang-asan/examples/polls/ladder_polls_tests asan +QT_QPA_PLATFORM=offscreen ./build/clang-asan/examples/polls/ladder_polls_tests "[polls][controller],[polls][view]" +``` + +Expected: clean. + +- [ ] **Step 6: clang-tidy over the changed lines** — CONTRIBUTING's recipe, file count printed and asserted. + Expected: no findings in the three rungs' `app/`, `ui/`, `tests/`. + +- [ ] **Step 7: WebAssembly** — the three browser builds are `wasm-ladder.yml`'s named targets now (`pastebin`, + `bookmarks`, `polls`). With no Emscripten toolchain locally, push nothing yet: state in the squash body that the + browser builds are verified by that workflow only, and confirm on the pull request's first CI run that its + "Build the WASM-remote spike and every rung's WASM client" step built all three (the step's log names each + target). + +- [ ] **Step 8: Commit any fixes** (skip if none, and say so) + +```bash +git add -A examples/polls examples/bookmarks examples/pastebin examples/common cmake +git commit -m "wip(polls): fixes from the verification gates + +Signed-off-by: Christian Parpart " +``` + +- [ ] **Step 9: Squash the group** + +Follow the master plan's "Squashing a part" procedure with `key=polls` and this message: + +```text +examples/polls: one app, any frontend + +polls' client is a toolkit-free app library — landing, create-poll (a +keyed forEach over a signal of 2-20 option drafts), vote (a grid of radio +choices, typed AddComment/FinalizePoll/UndoLastVoteChange forms on the one +attached AllowShared handler, a Poller activity feed) and a navigator — +plus one binary that now runs natively as well as in the browser, where a +?poll= link opens its poll once the connection is ready (a keyed attach is +refused, not queued, while it connects). The QML client and its tests are gone. + +Signed-off-by: Christian Parpart +``` + +The last line of the procedure must show the history ending in `examples/pastebin: …`, `examples/bookmarks: …`, +`examples/polls: …`. diff --git a/docs/superpowers/plans/2026-10-04-declarative-ui-tui-9-examples-ladder-b.md b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-9-examples-ladder-b.md new file mode 100644 index 000000000..f88642dd8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-9-examples-ladder-b.md @@ -0,0 +1,13618 @@ +# Declarative UI, Part 9 — the ladder rungs kanban, ledger and lims Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Re-express the kanban, ledger and lims clients as toolkit-free app libraries (controllers plus `ui::Node` +views) with one binary each that picks the TUI or Qt Quick frontend at runtime, and delete their QML, bridges, +presenters and Qt mains. + +**Architecture:** Each rung gains `app/` (target `ladder__app`: controllers built from `reactive::Query`, +`Mutation`, runtime `forms::FormSession`s and `Computed` projections; views that are bindings only) and +`ui/main.cpp` (the composition root of spec 4 §3). The domain libraries lose Qt: kanban's HTTP attachment server and +ledger's report-runner `App` move into `src/server/`, beside the servers that own them. Kanban keeps its headless +Qt-core test client, now driving the `BoardController` over `QtWebSocketBackend`. + +**Tech Stack:** C++23; `morph::reactive`, `morph::ui`, the forms engine, `examples/common/app` (Part 6), +`morph::offline` (optional), POSIX sockets (kanban's TUI attachment client), Qt 6 only in servers, the headless +client and kanban's Qt attachment transfer; Catch2 v3. + +**Spec:** `docs/superpowers/specs/2026-10-04-examples-migration-design.md` (spec 4) §1–§3, §5 (kanban, ledger, lims), +§6, §7, §8, §9; `docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md` (spec 1) §4b (the controller) and +§5 (drag-and-drop on `Common`: `dragKey`, `accepts`, `onDrop`). + +This is **Part 9 of 11** of the declarative-UI program. Read the master plan +`docs/superpowers/plans/2026-10-04-declarative-ui-tui.md` first: it fixes the branch, the commit layout and the +`wip(...)` commit convention every task below follows. This part has **three groups** — keys `kanban`, `ledger`, +`lims` — and each group ends by squashing its own `wip()` commits into one commit (commits 14, 15 and 16 of +the pull request's history). + +## Global Constraints + +Part 1's constraints apply unchanged. In addition: + +- `app/` is toolkit-free: no Qt header, no `morph/qt*`, no `morph/tui`, no `morph/qt_quick` include. Controllers + (`app/controllers/`) include nothing from `morph/ui/`; views (`app/views/`) are bindings only — every + conditional, format and validation is a controller member or a `Computed` (spec 4 §7 rules 1–2). +- Client code lives in `namespace kanban::client`, `ledger::client`, `lims::client`; each app's factory is + `[[nodiscard]] std::unique_ptr ::client::makeApplication(ui::AppContext& ctx, + examples::AppEnvironment const& env)` (the contract's "Conventions for every example application"). An app-specific + knob comes from `env`, from an environment variable read through `ui::processEnvironment()`, or is defaulted, so + every `ui/main.cpp` has spec 4 §3's shape. +- Controllers are built from `morph::examples::Wiring` (`"app/wiring.hpp"`), derive completions with + `morph::examples::mapCompletion` (`"app/completion_map.hpp"`) and read a DTO's form with + `morph::forms::FormModel::forAction()` (Part 5) — none of the three is redefined per app. +- The domain libraries (`include/`, `src/` outside `src/server/`) include no Qt header; server-only Qt code lives in + `src/server/app/` with its headers under `src/server/include/`, which Part 8's `morph_add_rung` builds into + `ladder__server_app` for the server and the tests. +- No identifier shorter than three characters outside `.clang-tidy`'s exceptions (parameters `i j k x y n N fn cb + op`, variables `i j k x y lk cb op fn`): the runtime is `runtime`, never `rt`; an id is `taskId`, `rowId`, never + `id` (struct members keep the DTOs' spelling). Headers under `app/` use `.at()` or iteration, never an unchecked + container `operator[]`. +- No two adjacent parameters of interchangeable types (`bugprone-easily-swappable-parameters`): a call with two + strings or two ids takes a request struct. +- Every `switch` over an enum names every enumerator **and** has a `default:` (`-Wswitch-enum` with + `-Wswitch-default`, `cmake/compiler_options.cmake`). +- Tests never sleep. Controller tests wait with `morph::examples::testing::pumpUntil(owner, …)` (Part 6, + `testkit/wait.hpp`) over a `MainThreadExecutor`; a test whose subject needs the Qt event loop (an + `AttachmentServer`, a `QtWebSocketServer`, ledger's report runner) uses `morph::ladder::testkit::pumpUntil` + (`testkit/pump.hpp`) over a `QtExecutor` owner, which spec 4 §4 keeps for exactly that case. +- New test cases are tagged `[][client]`, `[][view]` or `[][smoke]`. Controller and view tests live + under `examples//tests/client/` and run in `ladder__tests`; frontend smoke tests live under + `examples//tests/smoke/` and run in `ladder__smoke_tests` (Part 6: its `main` owns no Qt application + object, because the Qt Quick frontend constructs its own). +- No `CHANGELOG.md` entry (CONTRIBUTING: examples-only changes get none). `docs/spec/` is touched only where a spec + names a file this part deletes. +- Each group ends with its own squash; a group never contains another group's `wip` commit. + +## Review Focus + +1. **A drop whose key is not a task id** (a foreign drag, a string key) must be refused by every board drop target + and never reach `MoveTaskPosition` (Task K12, test "a drop carrying a non-task key is refused and moves nothing"). +2. **A move made while offline** must not be dispatched, must survive in the queue, and must replay exactly once on + reconnect (Task K8, test "a move made while offline is queued, not sent, and replays on reconnect"). +3. **Closing the board while polling** must leave no poll timer armed, so a closed board is never refreshed behind + the user's back (Task K6, test "closing the board stops polling"). +4. **A statement job that reached Done** must stop being polled and keep its lines (Task L6, test "a statement is + polled to Done; a Done job stops the poll and keeps its lines"). +5. **The sign-in reply on screen** must never carry the minted token (Task K3, test "the sign-in reply on screen + carries no token"). + +## What this plan relies on from earlier parts + +The interface contract fixes the names below, and the upstream plans fix their shapes; this section states the +shapes this plan's code is written against. If an upstream plan changes one, adapt the named call sites and state +it in the group's squash body (master plan, "The docs commit"). + +- **`morph_add_rung`, as Part 6 (C1–C3) and Part 8 (Task 1) write it.** `app/**/*.cpp` → STATIC + `ladder__app` with PUBLIC include dirs `app/` and `include/`, linking `morph::morph`, + `morph::ladder_app_common` and `morph::ladder__lib` — so `#include "controllers/x.hpp"` resolves under + `/app/` and this plan adds no include or link line for the app library. `ui/*.cpp` → the executable `` + (`morph_add_example_ui`, only when a frontend is built), with `MORPH_EXAMPLE_HAS_TUI` and + `MORPH_EXAMPLE_HAS_QT_QUICK` always defined to `0` or `1`. `ladder__tests` (`tests/**/*.cpp` minus + `tests/smoke/`) links `morph::ladder__app` and `morph::example_testkit`; `tests/smoke/*.cpp` → + `ladder__smoke_tests` (`morph_test_main`, both definitions). `src/server/app/*.cpp` → STATIC + `ladder__server_app` (natively, when such files exist) with PUBLIC include dir `src/server/include`, linking + PUBLIC `morph::ladder__lib morph::qt morph_qt_impl Qt6::Core` with `AUTOMOC ON`; the server and + `ladder__tests` link it, the app library and the binary do not, and the server no longer compiles + `src/server/app/` itself. `ladder__lib` links `Qt6::Core` (and turns on `AUTOMOC`) only while the rung has + no `app/` directory. +- **Part 6 (`examples/common`, include root `examples/common`)** — `"app/app_environment.hpp"` + (`morph::examples::AppEnvironment{server, db, user, seed, pollId}`; `fromArgs` reads `--server`, `--db`, `--user`, + `--poll` and the `--seed` flag and ignores every other argument); `"app/transport.hpp"` (`Connection` with + `bridge()` and `callbacks()` — `callbacks()` is `ctx.executor()` —, `LocalSetup{setupDatabase, workers}`, + `connect(ctx, env, local)`, which sets `--user` as the default session when it is given, and `TransportError`); + `"app/uuid.hpp"` (`newUuid()`); `"app/wiring.hpp"` (`morph::examples::Wiring{runtime, scheduler, bridge, + callbacks}`, all four borrowed); `"app/completion_map.hpp"` (`morph::examples::mapCompletion(owner, token, + from, onValue, onError)`: both callbacks run on `owner`, gated by `token`; a throw from `onValue` fails the derived + completion; `onError` observes a failure before it is passed on — Part 6 owns its tests); `"app/poller.hpp"` + (`Poller(Runtime&, Scheduler&, Fetch, Cursor start, OnEvent, PollerOptions{interval})`, `Fetch = + std::function>(Cursor const&)>`, `PollPage{events, next}`, tracked + `stoppedBy()`; it arms its timer through the given `Scheduler` and cancels it on destruction); + `"testkit/wait.hpp"` (`morph::examples::testing::pumpUntil(MainThreadExecutor&, pred, budget)`); + `"testkit/frontend_smoke.hpp"` (`runFrontendSmoke(factory, SmokeFrontend)`, which skips a frontend the configure + did not build). The rig's Qt pump (`"testkit/pump.hpp"`, `morph::ladder::testkit::pumpUntil`, `awaitQt`) stays for + tests whose subject needs the Qt event loop. +- **Part 5** — `` (`FormModel`, `FormModel::forAction()`, which throws + `std::logic_error` naming the schema path when the action's own schema does not read, `SubmitMode`), + `` (`FormSession(Runtime&, FormModel, Submitter, ChoiceFetcher, + FormSessionOptions = {})`, `Submitter`, `ChoiceFetcher`), `` + (`handlerSubmitter`/`handlerChoiceFetcher(callbacks, handlers...)`: each action goes to the first handler whose + `servesAction` holds; an action none serves fails with `std::invalid_argument` naming it) and + `` (`formView`, `FormViewOptions{.submitLabel}`). `pending()`, `lastReply()` + and `lastError()` are tracked; `prefill(json)` **replaces every field** (a member the JSON does not name starts + blank), `assign(path, jsonValue)` sets one field, and `reset()` blanks every field — all three programmatically, + so none submits. So a controller fills a form's context (a project, a task, a client id) with `assign`, after any + `reset`, and a test fills the user's fields one by one (`fill`, below), never with `prefill`, which would blank + that context. `submit()` does nothing unless the form is ready, and otherwise runs the submission's `Mutation`, so + `pending()` is true when it returns. Every action this plan renders declares `explicitSubmit`, so its form view + carries a Submit button, labelled by `submitLabel`, whose `enabled` follows `ready()`; the view shows the last + reply (`ok: …`) or error (`error: …`) itself. Part 5 has no success hook: a controller that acts on a reply wraps + the `Submitter` it hands the session (`mapCompletion` over the handler's `executeJson`). +- **Part 2** — `` and `` (`Mounted(runtime, backend, + root)`; the initial mount is synchronous). Kinds are the widget interfaces' names without `Widget`, except that a + stack is `Column` or `Row` by its axis; props are the setters' names without `set`. Values are unquoted + (`text=Hi bob`), a string `Key` is quoted and an integer one is not (`selected="Manager"`, `dragKey=7`). A helper + (`click`, `edit`, `drag`, `pick`, …) calls the widget's callback synchronously, and does nothing to a widget whose + own `enabled` (or `visible`) prop is `false`; the flush a callback causes is posted to the runtime's owner, so a + test settles before it reads the tree — and before it clicks a button whose `enabled` a write just changed. A + `Dialog`'s content exists only while it is open; a `Panel`'s child always exists; a `Tabs` page is mounted the + first time its tab is selected and only hidden afterwards, so the tree of an app on its first tab holds the other + tabs' labels (the `Tabs` widget's `tabs` prop) but none of their content. + +--- +## File Structure + +### Group `kanban` (`examples/kanban/`) + +| File | Responsibility | +|---|---| +| `src/server/include/kanban/http/attachment_server.hpp`, `src/server/app/attachment_server.cpp` | The HTTP attachment side channel (moved from `include/kanban/http/`, `src/http/`); in `ladder_kanban_server_app` (Part 8), server-only, Qt | +| `app/support/board_format.{hpp,cpp}` | Display strings: column headers, project lines, rule lines, activity, sync lines | +| `app/controllers/session_controller.{hpp,cpp}` | Sign-in form; installs the token; redacts it from the reply | +| `app/controllers/projects_controller.{hpp,cpp}` | Project list, create-project form, members (role picker, remove, add-member form) | +| `app/controllers/offline_config.hpp` | `OfflineConfig` (always built) | +| `app/controllers/offline_moves.{hpp,cpp}` | Durable offline queue, network monitor, reconnect replay (`MORPH_BUILD_OFFLINE_SQLITE`) | +| `app/controllers/board_controller.{hpp,cpp}` | Attach, board/activity queries, board forms, moves, shape and cards, polling, offline hook | +| `app/controllers/rules_controller.{hpp,cpp}` | Rules dialog: list, create form (with its Choice fetcher), delete | +| `app/attachments/attachment_transfer.{hpp,cpp}` | `IAttachmentTransfer`, request structs, `contentTypeFor`, `localPathFrom`, `attachmentServerFor` | +| `app/http/http_client.{hpp,cpp}` | Minimal blocking HTTP/1.1 client over POSIX sockets; URL and response parsing | +| `app/http/http_attachment_transfer.{hpp,cpp}` | `IAttachmentTransfer` over `http_client` on a worker thread | +| `app/controllers/task_detail_controller.{hpp,cpp}` | Task dialog: comments, add-comment form, attachments list, upload, download | +| `app/app_controller.{hpp,cpp}` | Owns the five controllers; the route `Computed` | +| `app/views/{sign_in,projects,board,root}_view.{hpp,cpp}` | `ui::Node` builders per screen, bindings only | +| `app/kanban_application.{hpp,cpp}` | `kanban::client::makeApplication`: connection, local token issuer, attachment transfer and offline queue from the environment | +| `ui/main.cpp` | Composition root (spec 4 §3) | +| `qt/qt_attachment_transfer.{hpp,cpp}` | `makeQtAttachmentTransfer`: `IAttachmentTransfer` over `QNetworkAccessManager`, behind a Qt-free header; compiled into `ladder_kanban_app` only with Qt Quick | +| `headless/main.cpp` | Headless Qt-core client driving `BoardController` (moved from `src/headless/`) | +| `CMakeLists.txt` | Server-side attachment wiring, headless target, Qt transfer, offline flags | +| `tests/client/*.cpp`, `tests/client/kanban_client_support.hpp` | Controller, transfer and view tests | +| `tests/smoke/test_kanban_frontends.cpp` | The app mounts and quits on each built frontend (`ladder_kanban_smoke_tests`) | +| `tests/test_board_concurrent_drag.cpp` | Rewritten over `BoardController` | +| `README.md` | Client section; visible differences | +| Deleted | `gui/`, `gui_lib/`, `src/headless/`, `include/kanban/http/`, `src/http/`, `tests/test_board_presenter.cpp`, `test_board_qml_bridge.cpp`, `test_board_offline_bridge.cpp`, `test_project_admin_presenter.cpp`, `test_project_admin_qml_bridge.cpp`, `test_gui_forms_render.cpp`, `test_board_layout.cpp`, `test_kanban_qml_surface.cpp`, `test_gui_qml_smoke.cpp` | + +Outside the rung, in the kanban group: `codecov.yml` (kanban's ignore entries), `scripts/coverage.sh` (`app` among +the measured sub-directories), `docs/spec/offline/offline.md` and `examples/IMPLEMENTATION.md` (the one sentence +each that names `board_qml_bridge.cpp`). + +### Group `ledger` (`examples/ledger/`) + +| File | Responsibility | +|---|---| +| `src/server/include/ledger/app/app.hpp`, `src/server/app/app.cpp` | The report-runner `ledger::app::App` (moved from `include/ledger/app/`, `src/app/`); in `ladder_ledger_server_app` (Part 8), server-only, Qt | +| `app/support/ledger_format.{hpp,cpp}` | Account/entry/report rows, id and amount parsing, month check, local UTC offset | +| `app/controllers/boot_controller.{hpp,cpp}` | Dev principal (`--user`): local session or remote `Login`; optional seed ledger | +| `app/controllers/accounts_controller.{hpp,cpp}` | Accounts tab: ledger query, open account, store transfer, list month, undo | +| `app/controllers/budget_controller.{hpp,cpp}` | Budgets tab: category, link, budget, limit, report | +| `app/controllers/rules_controller.{hpp,cpp}` | Rules tab: create, update, last rule | +| `app/controllers/statement_controller.{hpp,cpp}` | Statement tab: submit, `Query` with `refreshEvery`, lines | +| `app/app_controller.{hpp,cpp}` | Owns boot and the four tab controllers | +| `app/views/ledger_views.{hpp,cpp}` | Boot screen and the four tabs | +| `app/ledger_application.{hpp,cpp}` | `ledger::client::makeApplication` (book 1 or `--seed`'s, the local UTC offset) | +| `ui/main.cpp` | Composition root (spec 4 §3) | +| `tests/client/*.cpp`, `tests/client/ledger_client_support.hpp` | Controller and view tests | +| `tests/smoke/test_ledger_frontends.cpp` | The app mounts and quits on each built frontend (`ladder_ledger_smoke_tests`) | +| `tests/test_multiclient.cpp` | Rewritten over `AccountsController` | +| `README.md` | Client section | +| Deleted | `gui/`, `gui_lib/`, `include/ledger/app/`, `src/app/`, `tests/test_ledger_presenter.cpp`, `test_ledger_qml_bridge.cpp`, `test_budget_presenter.cpp`, `test_budget_qml_bridge.cpp`, `test_rule_presenter.cpp`, `test_report_presenter.cpp`, `test_report_job_poller.cpp`, `test_ledger_qml_surface.cpp`, `test_gui_qml_smoke.cpp` | + +Outside the rung: `codecov.yml` (ledger's ignore entries). + +### Group `lims` (`examples/lims/`) + +| File | Responsibility | +|---|---| +| `app/support/lims_format.{hpp,cpp}` | Sample header, result and conflict lines, quantity text | +| `app/controllers/sample_controller.{hpp,cpp}` | Lifecycle tab: register forms, open, transitions, return/reject forms | +| `app/controllers/result_controller.{hpp,cpp}` | Results tab: catalogue, attach, capture form, results, verify, conflicts, resolve form | +| `app/app_controller.{hpp,cpp}` | Owns both controllers | +| `app/views/lims_views.{hpp,cpp}` | The two tabs and the shell | +| `app/lims_application.{hpp,cpp}` | `lims::client::makeApplication` (`--db`, else `LIMS_DB`, else `lims.db`) | +| `ui/main.cpp` | Composition root (spec 4 §3) | +| `tests/client/*.cpp`, `tests/client/lims_client_support.hpp` | Controller and view tests | +| `tests/smoke/test_lims_frontends.cpp` | The app mounts and quits on each built frontend (`ladder_lims_smoke_tests`) | +| `README.md` | "The client" section rewritten | +| Deleted | `gui/`, `gui_lib/`, `tests/test_lims_presenters.cpp`, `test_lims_qml_bridges.cpp`, `test_lims_qml_surface.cpp`, `test_gui_qml_smoke.cpp` | + +Outside the rung: `codecov.yml` (lims' ignore entries) and `examples/crm/include/crm/gui/crm_schemas.hpp` (its +comment names the deleted `lims_schemas.hpp`). `cmake/morph_add_rung.cmake` is not touched: Part 8 already links +`ladder__lib` to Qt only while a rung has no `app/` (Task M7 confirms it). + +## Build and test commands + +```bash +# Once, before Task K1 (the master plan's build/all plus the optional offline queue kanban exercises): +cmake -S . -B build/all -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_NET=ON -DMORPH_BUILD_FORMS_QML=ON -DMORPH_BUILD_OFFLINE_SQLITE=ON +# Without either frontend, to prove the app libraries and their tests do not need one (Tasks K16, L11, M7): +cmake -S . -B build/nofront -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_QT=ON -DMORPH_BUILD_LADDER=ON \ + -DMORPH_LADDER_RUNGS="kanban;ledger;lims" -DMORPH_BUILD_NET=ON +# Per task: +cmake --build build/all --target ladder_kanban_tests # or ladder_ledger_tests / ladder_lims_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client]" +``` + +Configuring prints `morph: warnings: ... strict=ON`; if it does not, stop and say so (CONTRIBUTING, "Warnings +are errors"). + +--- +# Group `kanban` + +### Task K1: The domain library loses Qt — the attachment server moves to the server + +The HTTP attachment side channel is a `QTcpServer`. It runs only in `ladder_kanban_server` and in the tests that +exercise it, so it moves to the server side by Part 8's convention: `src/server/app/*.cpp` with headers under +`src/server/include/` become `ladder_kanban_server_app`, which the server and `ladder_kanban_tests` link. The header +keeps its include spelling, `"kanban/http/attachment_server.hpp"`, so no includer changes. `ladder_kanban_lib` then +needs no Qt module of its own (kanban's `App` in `src/app/` is already Qt-free and stays); Part 8's `morph_add_rung` +drops its remaining `Qt6::Core` link once the rung has `app/` (Task K2). + +**Files:** +- Move: `examples/kanban/include/kanban/http/attachment_server.hpp` → + `examples/kanban/src/server/include/kanban/http/attachment_server.hpp` +- Move: `examples/kanban/src/http/attachment_server.cpp` → `examples/kanban/src/server/app/attachment_server.cpp` +- Modify: `examples/kanban/CMakeLists.txt` — replace the "Task 17" block (the one that adds + `src/http/attachment_server.cpp` to `ladder_kanban_lib` and links `Qt6::Network` to it) +- Test: `examples/kanban/tests/test_attachment_server.cpp` (unchanged cases), plus the include check below + +**Interfaces:** +- Consumes: `kanban::http::AttachmentServer` (unchanged); Part 8's `morph_add_rung` rule `src/server/app/*.cpp` + + `src/server/include/` → `ladder__server_app` (linked by `ladder__server` and `ladder__tests`). +- Produces: `ladder_kanban_server_app` carrying `AttachmentServer` and `Qt6::Network`; the header still included as + `"kanban/http/attachment_server.hpp"` by the server, the tests and (Tasks K10, K11) the transfer tests. + +- [ ] **Step 1: Write the failing check** + +```bash +git grep -n -E '#include ` (and three more `` lines) and `examples/kanban/src/http/attachment_server.cpp:6:#include +`, and does not print "domain library is Qt-free". + +- [ ] **Step 3: Implement** + +```bash +mkdir -p examples/kanban/src/server/include/kanban/http examples/kanban/src/server/app +git mv examples/kanban/include/kanban/http/attachment_server.hpp \ + examples/kanban/src/server/include/kanban/http/attachment_server.hpp +git mv examples/kanban/src/http/attachment_server.cpp examples/kanban/src/server/app/attachment_server.cpp +``` + +In `examples/kanban/CMakeLists.txt`, replace the whole comment-and-`if` block that begins +`# Task 17: the attachment HTTP side channel` (through its `endif()`) with: + +```cmake +# The attachment side channel (src/server/app/attachment_server.cpp) is a QTcpServer. It lives in +# ladder_kanban_server_app, which only the server and the tests link, so the domain library stays toolkit-free. +if(TARGET ladder_kanban_server_app) + find_package(Qt6 6.5 REQUIRED COMPONENTS Network) + target_link_libraries(ladder_kanban_server_app PUBLIC Qt6::Network) +endif() +``` + +The block that links `Qt6::Network` onto `ladder_kanban_gui_lib` stays until Task K15 deletes `gui_lib/`. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests ladder_kanban_server +./build/all/examples/kanban/ladder_kanban_tests "[kanban][attachments]" +git grep -n -E '#include ` as the first include of `examples/kanban/src/models/board_model.cpp`. +Expected: the check lists that line and does not print "domain library is Qt-free". Remove it. + +- [ ] **Step 5: Commit** + +```bash +git add -A examples/kanban/include/kanban/http examples/kanban/src/http examples/kanban/src/server \ + examples/kanban/CMakeLists.txt +git commit -m "wip(kanban): the attachment server moves to the server; the domain library is Qt-free + +Signed-off-by: Christian Parpart " +``` + +--- +### Task K2: The kanban app library — display strings + +The app library's first file. What every controller is built from is shared, not kanban's: `morph::examples::Wiring` +(`"app/wiring.hpp"`), `morph::examples::mapCompletion` (`"app/completion_map.hpp"`, tested in `examples/common`) and +`morph::forms::FormModel::forAction()` (Part 5) — so this task adds only the rung's own display strings, and +`morph_add_rung` builds `app/` into `ladder_kanban_app` and links it into `ladder_kanban_tests` with no CMake line of +this rung's. + +**Files:** +- Create: `examples/kanban/app/support/board_format.hpp`, `examples/kanban/app/support/board_format.cpp` +- Test: `examples/kanban/tests/client/test_board_format.cpp` + +**Interfaces:** +- Consumes: `kanban::roleToString`, `ruleMutationTypeToString` and the DTOs named below (`kanban/core/types.hpp`, + `kanban/dto/*.hpp`); Parts 6 and 8's `morph_add_rung` (`app/` → `ladder_kanban_app`, include dir `app/`). +- Produces (later tasks use exactly these): + - `kanban::client::columnHeader(ColumnView const&)`, `roleLabel(Role)`, `projectLine(MyProjectSummary const&)`, + `ruleLine(RuleView const&, std::vector const&)`, `activityMeta(ActivityEvent const&)`, + `pendingSyncLine(int)`, `deadLetterLine(int)`, `attachmentLine(AttachmentView const&)` — all `-> std::string`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/kanban/tests/client/test_board_format.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include "support/board_format.hpp" + +using kanban::ColumnId; +using kanban::ColumnView; + +TEST_CASE("kanban::client::columnHeader shows the count, and the WIP limit when there is one", "[kanban][client]") { + CHECK(kanban::client::columnHeader(ColumnView{.id = ColumnId{1}, .name = "To Do", .wipLimit = 0, .taskCount = 2}) == + "To Do (2)"); + CHECK(kanban::client::columnHeader(ColumnView{.id = ColumnId{1}, .name = "Doing", .wipLimit = 3, .taskCount = 2}) == + "Doing (2/3)"); +} + +TEST_CASE("kanban::client::ruleLine names the trigger column, or its id when the column is unknown", + "[kanban][client]") { + std::vector const columns{ColumnView{.id = ColumnId{7}, .name = "Done", .wipLimit = 0, .taskCount = 0}}; + kanban::RuleView rule{.id = kanban::RuleId{1}, + .triggerColumnId = ColumnId{7}, + .mutationType = kanban::RuleMutationType::AddTag, + .mutationValue = "shipped"}; + CHECK(kanban::client::ruleLine(rule, columns) == "when moved to \"Done\": AddTag \"shipped\""); + rule.triggerColumnId = ColumnId{9}; + rule.mutationType = kanban::RuleMutationType::RemoveTag; + CHECK(kanban::client::ruleLine(rule, columns) == "when moved to \"9\": RemoveTag \"shipped\""); +} + +TEST_CASE("kanban::client sync lines are empty at zero and count otherwise", "[kanban][client]") { + CHECK(kanban::client::pendingSyncLine(0).empty()); + CHECK(kanban::client::pendingSyncLine(2) == "2 changes pending sync"); + CHECK(kanban::client::deadLetterLine(0).empty()); + CHECK(kanban::client::deadLetterLine(1) == "1 changes could not be synced"); +} + +TEST_CASE("kanban::client::projectLine, activityMeta and attachmentLine join their parts", "[kanban][client]") { + CHECK(kanban::client::projectLine(kanban::MyProjectSummary{ + .id = kanban::ProjectId{3}, .name = "Sprint", .myRole = kanban::Role::Manager}) == "Sprint · Manager"); + CHECK(kanban::client::activityMeta(kanban::ActivityEvent{ + .actionType = "MoveTaskPosition", .principal = "alice", .timestampMs = 0, .summary = "moved"}) == + "alice · MoveTaskPosition"); + CHECK(kanban::client::attachmentLine(kanban::AttachmentView{.id = kanban::AttachmentId{1}, + .taskId = kanban::TaskId{2}, + .filename = "report.pdf", + .contentType = "application/pdf", + .sizeBytes = 34, + .storageKey = "k", + .uploadedBy = "alice", + .uploadedAtMs = 0}) == + "report.pdf (34 bytes, alice)"); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake --build build/all --target ladder_kanban_tests` +Expected: compile error, `'support/board_format.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/kanban/app/support/board_format.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "kanban/core/types.hpp" +#include "kanban/dto/activity_dto.hpp" +#include "kanban/dto/attachment_dto.hpp" +#include "kanban/dto/board_dto.hpp" +#include "kanban/dto/project_dto.hpp" +#include "kanban/dto/rule_dto.hpp" + +namespace kanban::client { + +/// @brief A column's header: its name and task count, plus the WIP limit when it has one. +/// @param column The column. +/// @return E.g. `"To Do (2)"` or `"Doing (2/3)"`. +[[nodiscard]] std::string columnHeader(ColumnView const& column); + +/// @brief A role's display name. +/// @param role The role. +/// @return `"Viewer"`, `"Member"` or `"Manager"`. +[[nodiscard]] std::string roleLabel(Role role); + +/// @brief A project row in the project list. +/// @param project The project and the caller's role on it. +/// @return E.g. `"Sprint · Manager"`. +[[nodiscard]] std::string projectLine(MyProjectSummary const& project); + +/// @brief A rule row: the trigger column by name (its id when the column is gone) and the mutation. +/// @param rule The rule. +/// @param columns The board's columns. +/// @return E.g. `when moved to "Done": AddTag "shipped"`. +[[nodiscard]] std::string ruleLine(RuleView const& rule, std::vector const& columns); + +/// @brief The second line of an activity entry: who did what. +/// @param event The entry. +/// @return E.g. `"alice · MoveTaskPosition"`. +[[nodiscard]] std::string activityMeta(ActivityEvent const& event); + +/// @brief The pending-sync banner. +/// @param queueDepth Moves waiting in the offline queue. +/// @return Empty at zero, else `" changes pending sync"`. +[[nodiscard]] std::string pendingSyncLine(int queueDepth); + +/// @brief The dead-letter banner. +/// @param deadLettered Moves the server kept refusing until their retry budget ran out. +/// @return Empty at zero, else `" changes could not be synced"`. +[[nodiscard]] std::string deadLetterLine(int deadLettered); + +/// @brief An attachment row. +/// @param attachment The attachment's metadata. +/// @return E.g. `"report.pdf (34 bytes, alice)"`. +[[nodiscard]] std::string attachmentLine(AttachmentView const& attachment); + +} // namespace kanban::client +``` + +Create `examples/kanban/app/support/board_format.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "support/board_format.hpp" + +#include + +namespace kanban::client { + +std::string columnHeader(ColumnView const& column) { + std::string header = column.name + " (" + std::to_string(column.taskCount); + if (column.wipLimit > 0) { + header += "/" + std::to_string(column.wipLimit); + } + return header + ")"; +} + +std::string roleLabel(Role role) { return std::string{roleToString(role)}; } + +std::string projectLine(MyProjectSummary const& project) { return project.name + " · " + roleLabel(project.myRole); } + +std::string ruleLine(RuleView const& rule, std::vector const& columns) { + std::string column = rule.triggerColumnId.hasValue() ? std::to_string(*rule.triggerColumnId) : std::string{"?"}; + for (ColumnView const& candidate : columns) { + if (candidate.id == rule.triggerColumnId) { + column = candidate.name; + break; + } + } + return "when moved to \"" + column + "\": " + std::string{ruleMutationTypeToString(rule.mutationType)} + " \"" + + rule.mutationValue + "\""; +} + +std::string activityMeta(ActivityEvent const& event) { return event.principal + " · " + event.actionType; } + +std::string pendingSyncLine(int queueDepth) { + return queueDepth > 0 ? std::to_string(queueDepth) + " changes pending sync" : std::string{}; +} + +std::string deadLetterLine(int deadLettered) { + return deadLettered > 0 ? std::to_string(deadLettered) + " changes could not be synced" : std::string{}; +} + +std::string attachmentLine(AttachmentView const& attachment) { + return attachment.filename + " (" + std::to_string(attachment.sizeBytes) + " bytes, " + attachment.uploadedBy + + ")"; +} + +} // namespace kanban::client +``` + +Re-run the configure (`cmake build/all`) so `morph_add_rung`'s globs see the new `app/` and `tests/client/` files: +the first `app/*.cpp` is what creates `ladder_kanban_app`. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client]" +``` + +Expected: PASS, 4 test cases. + +Mutation check: in `columnHeader`, drop the `if (column.wipLimit > 0)` block. Expected FAIL in "columnHeader shows +the count, and the WIP limit when there is one" (`"Doing (2)"`). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/kanban/app examples/kanban/tests/client +git commit -m "wip(kanban): app library foundations: display strings + +Signed-off-by: Christian Parpart " +``` + +--- +### Task K3: `SessionController` — sign-in, with the token kept off the screen + +Re-expresses `ProjectAdminPresenter::login`/`onLoginSucceeded` and `ProjectAdminBridge::submitIfValid("Login")` +(`gui_lib/project_admin_presenter.cpp`): the schema-driven `Login` form submits through `AuthModel`; a success +installs `{principal, token}` as the bridge's default session; the reply the form shows is the `LoginResult` with the +token cleared. + +**Files:** +- Create: `examples/kanban/app/controllers/session_controller.hpp`, `examples/kanban/app/controllers/session_controller.cpp` +- Create: `examples/kanban/tests/client/kanban_client_support.hpp` +- Test: `examples/kanban/tests/client/test_session_controller.cpp` + +**Interfaces:** +- Consumes: `morph::examples::Wiring`, `mapCompletion` (Part 6); `forms::FormModel::forAction()`, + `forms::FormSession(Runtime&, FormModel, Submitter, ChoiceFetcher, FormSessionOptions)`, `assign`, `submit`, + `ready`, `pending`, `lastReply`, `lastError` (Part 5); + `BridgeHandler::executeJson(std::string_view, std::string_view)` (`morph/core/bridge.hpp:2312`); + `Bridge::setDefaultSession`, `defaultSession` (`bridge.hpp:988`, `:1037`); `TokenVerifier::verify(token, nowMs)` + (`morph/session/session_auth.hpp:460`); `kanban::auth::setTokenIssuer`. +- Produces: + - `kanban::client::SessionController(examples::Wiring)`: `loginForm() -> forms::FormSession&`, `signedIn() -> bool`, + `principal() -> std::string const&`, `headerText() -> std::string` (all reads tracked). + - Test support (`kanban::testing`): `kSecret`, `signedContext(principal)`, `IssuerScope`, `LocalClient{pool, owner, + bridge, runtime, scheduler; settle(pred, budget); wiring()}`, `await(client, completion)`, + `seedProject(client, name) -> ProjectId`, `fill(form, json)`, `submitForm(client, form, json)`, + `submitFormExpectingRefusal(client, form, json) -> std::string`, `signIn(client, session, principal)`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/kanban/tests/client/kanban_client_support.hpp`: + +```cpp +// 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 +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/session_controller.hpp" +#include "kanban/auth/kanban_authorizer.hpp" +#include "kanban/models/board_model.hpp" +#include "kanban/models/project_admin_model.hpp" +#include "testkit/wait.hpp" + +namespace kanban::testing { + +using namespace std::chrono_literals; + +/// The signing secret every kanban client test shares. +inline constexpr std::string_view kSecret = "kanban-client-test-secret-32-bytes!!"; + +/// A session for @p principal whose token is signed with `kSecret`. +[[nodiscard]] inline morph::session::Context signedContext(std::string const& principal) { + morph::session::TokenIssuer const issuer{std::string{kSecret}, morph::session::hmacSha256}; + morph::session::Context context; + context.principal = principal; + context.token = issuer.issue(morph::session::SessionToken{ + .principal = principal, .issuedAtMs = 0, .expiresAtMs = 4102444800000, .roles = {}}); + return context; +} + +/// Installs the process-wide issuer `Login` mints tokens from, for one test, and removes it after. +class IssuerScope { +public: + IssuerScope() { + kanban::auth::setTokenIssuer( + std::make_shared(std::string{kSecret}, morph::session::hmacSha256)); + } + ~IssuerScope() { kanban::auth::setTokenIssuer(nullptr); } + IssuerScope(IssuerScope const&) = delete; + IssuerScope& operator=(IssuerScope const&) = delete; + IssuerScope(IssuerScope&&) = delete; + IssuerScope& operator=(IssuerScope&&) = delete; +}; + +/// One client over a local backend: the owner every completion and every flush runs on, a manual clock for +/// timed refreshes, and the bridge. Declare it before the controllers under test, so they are destroyed first. +class LocalClient { +public: + LocalClient() = default; + explicit LocalClient(morph::session::Context session) { bridge.setDefaultSession(std::move(session)); } + ~LocalClient() = default; + LocalClient(LocalClient const&) = delete; + LocalClient& operator=(LocalClient const&) = delete; + LocalClient(LocalClient&&) = delete; + LocalClient& operator=(LocalClient&&) = delete; + + /// Pumps the owner until @p done holds or the budget runs out. + template + [[nodiscard]] bool settle(Pred done, std::chrono::milliseconds budget = 5000ms) { + return morph::examples::testing::pumpUntil(owner, std::move(done), budget); + } + + /// What a controller under test is built from. + [[nodiscard]] morph::examples::Wiring wiring() { + return morph::examples::Wiring{ + .runtime = runtime, .scheduler = scheduler, .bridge = bridge, .callbacks = owner}; + } + + morph::exec::ThreadPoolExecutor pool{4}; + morph::exec::MainThreadExecutor owner; + morph::bridge::Bridge bridge{std::make_unique(pool), owner}; + morph::reactive::Runtime runtime{owner}; + morph::reactive::testing::ManualScheduler scheduler; +}; + +/// Waits for @p completion on @p client's owner and returns its value, or rethrows its failure. +template +[[nodiscard]] T await(Client& client, morph::async::Completion completion) { + struct State { + std::optional value; + std::exception_ptr error; + }; + auto state = std::make_shared(); + completion.thenDetached([state](T const& value) { state->value = value; }) + .onErrorDetached([state](std::exception_ptr error) { state->error = std::move(error); }); + if (!client.settle([&state] { return state->value.has_value() || state->error != nullptr; })) { + throw std::runtime_error{"await: the completion did not settle"}; + } + if (state->error != nullptr) { + std::rethrow_exception(state->error); + } + return std::move(*state->value); +} + +/// Creates a project as the client's principal, who becomes its Manager. +[[nodiscard]] inline kanban::ProjectId seedProject(LocalClient& client, std::string name) { + morph::bridge::BridgeHandler admin{client.bridge, &client.owner}; + return await(client, admin.execute(kanban::CreateProject{.name = std::move(name)})).id; +} + +/// Sets each member @p bodyJson names, as typing into those fields would, and leaves every other field alone — the +/// context its controller filled in (a project, a task) stays. `prefill` would blank that context. +inline void fill(morph::forms::FormSession& form, std::string_view bodyJson) { + glz::generic_u64 members; + REQUIRE_FALSE(glz::read_json(members, std::string{bodyJson})); + REQUIRE(members.is_object()); + for (auto const& [name, value] : members.get_object()) { + form.assign(name, glz::write_json(value).value_or(std::string{"null"})); + } +} + +/// Fills @p form, submits it, waits for the outcome and requires success. +template +void submitForm(Client& client, morph::forms::FormSession& form, std::string_view bodyJson) { + fill(form, bodyJson); + REQUIRE(form.ready()); + form.submit(); + REQUIRE(client.settle([&form] { return !form.pending(); })); + INFO("the form reported: " << morph::reactive::errorMessage(form.lastError())); + REQUIRE(form.lastError() == nullptr); +} + +/// Fills @p form, submits it, waits for the outcome, requires a refusal and returns its message. +template +[[nodiscard]] std::string submitFormExpectingRefusal(Client& client, morph::forms::FormSession& form, + std::string_view bodyJson) { + fill(form, bodyJson); + REQUIRE(form.ready()); + form.submit(); + REQUIRE(client.settle([&form] { return !form.pending(); })); + REQUIRE(form.lastError() != nullptr); + return morph::reactive::errorMessage(form.lastError()); +} + +/// Signs @p principal in through @p session's form. Needs an `IssuerScope`. +template +void signIn(Client& client, kanban::client::SessionController& session, std::string const& principal) { + submitForm(client, session.loginForm(), R"({"username":")" + principal + R"("})"); + REQUIRE(session.signedIn()); +} + +} // namespace kanban::testing +``` + +Create `examples/kanban/tests/client/test_session_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "controllers/session_controller.hpp" +#include "kanban_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using kanban::testing::IssuerScope; +using kanban::testing::LocalClient; +using morph::ladder::testkit::DbFixture; + +TEST_CASE("kanban::client::SessionController: signing in installs the minted token as the default session", + "[kanban][client]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + kanban::client::SessionController session{client.wiring()}; + CHECK_FALSE(session.signedIn()); + CHECK(session.headerText() == "not signed in"); + + kanban::testing::signIn(client, session, "alice"); + + CHECK(session.principal() == "alice"); + CHECK(session.headerText() == "signed in as alice"); + CHECK(client.bridge.defaultSession().principal == "alice"); + morph::session::TokenVerifier const verifier{std::string{kanban::testing::kSecret}, morph::session::hmacSha256}; + auto const nowMs = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + auto const verified = verifier.verify(client.bridge.defaultSession().token, nowMs); + REQUIRE(verified.has_value()); + CHECK(verified->principal == "alice"); +} + +TEST_CASE("kanban::client::SessionController: the sign-in reply on screen carries no token", "[kanban][client]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + kanban::client::SessionController session{client.wiring()}; + kanban::testing::signIn(client, session, "alice"); + + std::string const token = client.bridge.defaultSession().token; + REQUIRE_FALSE(token.empty()); + auto const& reply = session.loginForm().lastReply(); + REQUIRE(reply.has_value()); + CHECK(reply->find(token) == std::string::npos); + CHECK(reply->find(R"("principal":"alice")") != std::string::npos); +} + +TEST_CASE("kanban::client::SessionController: a refused sign-in leaves the bridge unauthenticated", + "[kanban][client]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + kanban::client::SessionController session{client.wiring()}; + + std::string const message = + kanban::testing::submitFormExpectingRefusal(client, session.loginForm(), R"({"username":"system:root"})"); + + CHECK_FALSE(message.empty()); + CHECK_FALSE(session.signedIn()); + CHECK(client.bridge.defaultSession().principal.empty()); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_kanban_tests` +Expected: compile error, `'controllers/session_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/kanban/app/controllers/session_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "kanban/models/project_admin_model.hpp" + +namespace kanban::client { + +/// @brief The sign-in screen: a schema-driven `Login` form whose success installs the minted token as the bridge's +/// default session. +/// +/// The reply the form shows is the server's `LoginResult` with the token cleared: the token is a credential, and +/// the screen has no use for it. +class SessionController { +public: + /// @param wiring The runtime, bridge and owner this controller uses. + explicit SessionController(morph::examples::Wiring wiring); + + /// @brief The `Login` form. + /// @return The form session; it lives as long as this controller. + [[nodiscard]] morph::forms::FormSession& loginForm() noexcept { return _login; } + + /// @brief Whether a sign-in succeeded. Tracked. + /// @return True once a session is installed. + [[nodiscard]] bool signedIn() const { return !_principal.get().empty(); } + + /// @brief The signed-in principal, as the server echoed it. Tracked. + /// @return The principal, empty before sign-in. + [[nodiscard]] std::string const& principal() const { return _principal.get(); } + + /// @brief The header line naming who is signed in. Tracked. + /// @return `"signed in as "` or `"not signed in"`. + [[nodiscard]] std::string headerText() const; + +private: + [[nodiscard]] morph::async::Completion signIn(std::string_view actionType, std::string body); + + morph::examples::Wiring _wiring; + morph::bridge::BridgeHandler _auth; + morph::reactive::Signal _principal; + morph::forms::FormSession _login; + // Last member, so it is destroyed first: a reply still in flight finds it stopped. + morph::async::CallbackScope _lifetime; +}; + +} // namespace kanban::client +``` + +Create `examples/kanban/app/controllers/session_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/session_controller.hpp" + +#include +#include +#include +#include +#include +#include + +#include "app/completion_map.hpp" + +namespace kanban::client { + +SessionController::SessionController(morph::examples::Wiring wiring) + : _wiring{wiring}, + _auth{wiring.bridge, &wiring.callbacks}, + _principal{wiring.runtime, std::string{}}, + _login{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { return signIn(actionType, std::move(body)); }, + {}} {} + +std::string SessionController::headerText() const { + std::string const& name = _principal.get(); + return name.empty() ? std::string{"not signed in"} : "signed in as " + name; +} + +morph::async::Completion SessionController::signIn(std::string_view actionType, std::string body) { + return morph::examples::mapCompletion( + _wiring.callbacks, _lifetime.token(), _auth.executeJson(actionType, body), + [this](std::string const& reply) { + LoginResult result; + if (glz::read_json(result, reply)) { + throw std::runtime_error{"sign-in succeeded but its reply could not be read"}; + } + morph::session::Context session; + session.principal = result.principal; + session.token = result.token.hasValue() ? *result.token : std::string{}; + _wiring.bridge.setDefaultSession(std::move(session)); + _principal.set(result.principal); + result.token = AuthToken{}; + return glz::write_json(result).value_or(std::string{"{}"}); + }, + [](std::exception_ptr const&) {}); +} + +} // namespace kanban::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client]" +``` + +Expected: PASS (7 cases so far). + +Mutation check: delete `result.token = AuthToken{};`. Expected FAIL in "the sign-in reply on screen carries no +token" (`reply->find(token)` finds it). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/kanban/app/controllers/session_controller.hpp examples/kanban/app/controllers/session_controller.cpp \ + examples/kanban/tests/client/kanban_client_support.hpp examples/kanban/tests/client/test_session_controller.cpp +git commit -m "wip(kanban): SessionController: sign-in that installs the token and keeps it off the screen + +Signed-off-by: Christian Parpart " +``` + +--- +### Task K4: `ProjectsController` — projects, the create form, and members with the role picker + +Re-expresses `ProjectAdminPresenter`/`ProjectAdminBridge` (`refreshProjects`, `createProject`, `listRoles`, +`setMemberRole`, `removeMember`, the `CreateProject` and `SetMemberRole` forms) and `ProjectListView.qml`'s and +`MembersView.qml`'s conditionals. The project list is a `Query` keyed on being signed in; the members +panel is a `Query` keyed on the project whose members are shown; the per-row role picker and +Remove are `Mutation`s invalidating it. + +**Files:** +- Create: `examples/kanban/app/controllers/projects_controller.hpp`, `examples/kanban/app/controllers/projects_controller.cpp` +- Test: `examples/kanban/tests/client/test_projects_controller.cpp` + +**Interfaces:** +- Consumes: `SessionController::signedIn()` (Task K3); `reactive::Query`, `Mutation`, `MutationOptions` (Part 1); + `projectLine` (Task K2); `morph::examples::mapCompletion` (Part 6); `forms::FormModel::forAction()`, + `FormSession::assign`, `reset` (Part 5). +- Produces: `kanban::client::ProjectRow{id, name, role, label}`, `MemberRow{principal, role}`; + `ProjectsController(examples::Wiring, SessionController const&)`: `refresh()`, `projects()`, `loading()`, + `createProjectForm()`, `showMembers(ProjectId, std::string name)`, `hideMembers()`, `membersShown()`, + `membersTitle()`, `members()`, `setRole(std::string principal, Role)`, `removeMember(std::string principal)`, + `addMemberForm()`, `errorText()`, static `roleNames() -> std::vector`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/kanban/tests/client/test_projects_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "controllers/projects_controller.hpp" +#include "controllers/session_controller.hpp" +#include "kanban_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using kanban::client::MemberRow; +using kanban::client::ProjectsController; +using kanban::client::SessionController; +using kanban::testing::IssuerScope; +using kanban::testing::LocalClient; +using morph::ladder::testkit::DbFixture; + +namespace { + +[[nodiscard]] bool hasMember(ProjectsController const& projects, std::string const& principal, kanban::Role role) { + auto const members = projects.members(); + return std::ranges::find(members, MemberRow{.principal = principal, .role = role}) != members.end(); +} + +} // namespace + +TEST_CASE("kanban::client::ProjectsController: nothing is listed before sign-in, then the caller's projects are", + "[kanban][client]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + SessionController session{client.wiring()}; + ProjectsController projects{client.wiring(), session}; + CHECK_FALSE(projects.loading()); + CHECK(projects.projects().empty()); + + kanban::testing::signIn(client, session, "alice"); + REQUIRE(client.settle([&projects] { return !projects.loading(); })); + CHECK(projects.projects().empty()); + + kanban::testing::submitForm(client, projects.createProjectForm(), R"({"name":"Sprint Board"})"); + REQUIRE(client.settle([&projects] { return projects.projects().size() == 1; })); + auto const row = projects.projects().front(); + CHECK(row.name == "Sprint Board"); + CHECK(row.role == kanban::Role::Manager); + CHECK(row.label == "Sprint Board · Manager"); + CHECK(row.id > 0); +} + +TEST_CASE("kanban::client::ProjectsController: members round-trip through the add form, the role picker and Remove", + "[kanban][client]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + SessionController session{client.wiring()}; + ProjectsController projects{client.wiring(), session}; + kanban::testing::signIn(client, session, "alice"); + kanban::testing::submitForm(client, projects.createProjectForm(), R"({"name":"Sprint Board"})"); + REQUIRE(client.settle([&projects] { return projects.projects().size() == 1; })); + auto const project = projects.projects().front(); + + projects.showMembers(kanban::ProjectId{project.id}, project.name); + CHECK(projects.membersShown()); + CHECK(projects.membersTitle() == "Members of Sprint Board"); + REQUIRE(client.settle([&] { return hasMember(projects, "alice", kanban::Role::Manager); })); + + kanban::testing::submitForm(client, projects.addMemberForm(), R"({"principal":"bob","role":"Member"})"); + REQUIRE(client.settle([&] { return hasMember(projects, "bob", kanban::Role::Member); })); + + projects.setRole("bob", kanban::Role::Manager); + REQUIRE(client.settle([&] { return hasMember(projects, "bob", kanban::Role::Manager); })); + + projects.removeMember("bob"); + REQUIRE(client.settle([&projects] { return projects.members().size() == 1; })); + CHECK(hasMember(projects, "alice", kanban::Role::Manager)); + CHECK(projects.errorText().empty()); + + projects.hideMembers(); + CHECK_FALSE(projects.membersShown()); + CHECK(projects.members().empty()); +} + +TEST_CASE("kanban::client::ProjectsController: a refused member change surfaces in errorText", "[kanban][client]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + SessionController session{client.wiring()}; + ProjectsController projects{client.wiring(), session}; + kanban::testing::signIn(client, session, "alice"); + kanban::testing::submitForm(client, projects.createProjectForm(), R"({"name":"Sprint Board"})"); + REQUIRE(client.settle([&projects] { return projects.projects().size() == 1; })); + auto const project = projects.projects().front(); + projects.showMembers(kanban::ProjectId{project.id}, project.name); + + projects.removeMember(""); + + REQUIRE(client.settle([&projects] { return !projects.errorText().empty(); })); +} + +TEST_CASE("kanban::client::ProjectsController::roleNames lists every role in rank order", "[kanban][client]") { + CHECK(ProjectsController::roleNames() == std::vector{"Viewer", "Member", "Manager"}); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_kanban_tests` +Expected: compile error, `'controllers/projects_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/kanban/app/controllers/projects_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/session_controller.hpp" +#include "kanban/models/project_admin_model.hpp" + +namespace kanban::client { + +/// @brief One project in the project list. +struct ProjectRow { + /// @brief The project's id. + std::int64_t id = 0; + /// @brief Its name. + std::string name; + /// @brief The caller's role on it. + Role role = Role::Viewer; + /// @brief The row's display line. + std::string label; + /// @brief Rows compare by value, so an unchanged row keeps its widgets. + bool operator==(ProjectRow const&) const = default; +}; + +/// @brief One member in the members panel. +struct MemberRow { + /// @brief The member's principal. + std::string principal; + /// @brief Their role. + Role role = Role::Viewer; + /// @brief Rows compare by value. + bool operator==(MemberRow const&) const = default; +}; + +/// @brief The project list screen and its members panel. +class ProjectsController { +public: + /// @param wiring The runtime, bridge and owner this controller uses. + /// @param session Gates the project list on being signed in. Borrowed: it must outlive this controller. + ProjectsController(morph::examples::Wiring wiring, SessionController const& session); + + /// @brief Fetches the project list again. + void refresh(); + + /// @brief The caller's projects. Tracked. + /// @return One row per project, in the server's order; empty before sign-in. + [[nodiscard]] std::vector projects() const; + + /// @brief Whether the project list is being fetched. Tracked. + /// @return True while a fetch is in flight. + [[nodiscard]] bool loading() const { return _projects.pending(); } + + /// @brief The `CreateProject` form. + /// @return The form session. + [[nodiscard]] morph::forms::FormSession& createProjectForm() noexcept { return _createProject; } + + /// @brief Shows the members of @p project. + /// @param project The project. + /// @param name Its name, for the panel's title. + void showMembers(ProjectId project, std::string name); + + /// @brief Hides the members panel. + void hideMembers(); + + /// @brief Whether the members panel is shown. Tracked. + /// @return True after `showMembers`, until `hideMembers`. + [[nodiscard]] bool membersShown() const { return _membersOf.get().has_value(); } + + /// @brief The members panel's title. Tracked. + /// @return `"Members of "`, or `"Members"` when no project is chosen. + [[nodiscard]] std::string membersTitle() const; + + /// @brief The shown project's members. Tracked. + /// @return One row per member; empty while hidden. + [[nodiscard]] std::vector members() const; + + /// @brief Changes a member's role on the shown project; the members list refreshes when it lands. + /// @param principal The member. + /// @param role The new role. + void setRole(std::string principal, Role role); + + /// @brief Removes a member from the shown project; the members list refreshes when it lands. + /// @param principal The member. + void removeMember(std::string principal); + + /// @brief The `SetMemberRole` form that adds a member; its `projectId` is the shown project. + /// @return The form session. + [[nodiscard]] morph::forms::FormSession& addMemberForm() noexcept { return _addMember; } + + /// @brief The first failure among the list, the members and the member changes. Tracked. + /// @return The message, or empty. + [[nodiscard]] std::string errorText() const; + + /// @brief The roles the role picker offers, lowest first. + /// @return `{"Viewer", "Member", "Manager"}`. + [[nodiscard]] static std::vector roleNames(); + +private: + struct MembersOf { + ProjectId project; + std::string name; + bool operator==(MembersOf const&) const = default; + }; + + [[nodiscard]] morph::async::Completion submit(std::string_view actionType, std::string body, + std::function after); + void assignMemberContext(); + + morph::examples::Wiring _wiring; + morph::bridge::BridgeHandler _admin; + morph::reactive::Signal> _membersOf; + morph::reactive::Query _projects; + morph::reactive::Query _roles; + morph::reactive::Mutation _setRole; + morph::reactive::Mutation _remove; + morph::forms::FormSession _createProject; + morph::forms::FormSession _addMember; + // Last member, so it is destroyed first: a form reply still in flight finds it stopped. + morph::async::CallbackScope _lifetime; +}; + +} // namespace kanban::client +``` + +Create `examples/kanban/app/controllers/projects_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/projects_controller.hpp" + +#include +#include +#include + +#include "app/completion_map.hpp" +#include "support/board_format.hpp" + +namespace kanban::client { + +ProjectsController::ProjectsController(morph::examples::Wiring wiring, SessionController const& session) + : _wiring{wiring}, + _admin{wiring.bridge, &wiring.callbacks}, + _membersOf{wiring.runtime, std::nullopt}, + _projects{wiring.runtime, _admin, + [&session]() -> std::optional { + if (!session.signedIn()) { + return std::nullopt; + } + return GetMyProjects{}; + }}, + _roles{wiring.runtime, _admin, + [this]() -> std::optional { + auto const& target = _membersOf.get(); + if (!target) { + return std::nullopt; + } + return GetProjectRoles{.projectId = target->project}; + }}, + _setRole{wiring.runtime, _admin, morph::reactive::MutationOptions{.invalidates = {&_roles}}}, + _remove{wiring.runtime, _admin, morph::reactive::MutationOptions{.invalidates = {&_roles}}}, + _createProject{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { + return submit(actionType, std::move(body), [this] { + _createProject.reset(); + _projects.refetch(); + }); + }, + {}}, + _addMember{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { + return submit(actionType, std::move(body), [this] { + _addMember.reset(); + assignMemberContext(); + _roles.refetch(); + }); + }, + {}} {} + +void ProjectsController::refresh() { _projects.refetch(); } + +std::vector ProjectsController::projects() const { + std::vector rows; + auto const& listed = _projects.value(); + if (!listed) { + return rows; + } + for (MyProjectSummary const& project : listed->projects) { + if (!project.id.hasValue()) { + continue; + } + rows.push_back(ProjectRow{ + .id = *project.id, .name = project.name, .role = project.myRole, .label = projectLine(project)}); + } + return rows; +} + +void ProjectsController::showMembers(ProjectId project, std::string name) { + _membersOf.set(MembersOf{.project = project, .name = std::move(name)}); + _addMember.reset(); + assignMemberContext(); +} + +void ProjectsController::hideMembers() { _membersOf.set(std::nullopt); } + +std::string ProjectsController::membersTitle() const { + auto const& target = _membersOf.get(); + return target ? "Members of " + target->name : std::string{"Members"}; +} + +std::vector ProjectsController::members() const { + std::vector rows; + auto const& listed = _roles.value(); + if (!listed) { + return rows; + } + for (MemberRole const& member : listed->roles) { + rows.push_back(MemberRow{.principal = member.principal, .role = member.role}); + } + return rows; +} + +void ProjectsController::setRole(std::string principal, Role role) { + auto const& target = _membersOf.peek(); + if (!target) { + return; + } + _setRole.run(SetMemberRole{.projectId = target->project, .principal = std::move(principal), .role = role}); +} + +void ProjectsController::removeMember(std::string principal) { + auto const& target = _membersOf.peek(); + if (!target) { + return; + } + _remove.run(RemoveMember{.projectId = target->project, .principal = std::move(principal)}); +} + +std::string ProjectsController::errorText() const { + for (std::exception_ptr const& error : {_projects.error(), _roles.error(), _setRole.error(), _remove.error()}) { + if (error != nullptr) { + return morph::reactive::errorMessage(error); + } + } + return {}; +} + +std::vector ProjectsController::roleNames() { + return {std::string{roleToString(Role::Viewer)}, std::string{roleToString(Role::Member)}, + std::string{roleToString(Role::Manager)}}; +} + +morph::async::Completion ProjectsController::submit(std::string_view actionType, std::string body, + std::function after) { + return morph::examples::mapCompletion( + _wiring.callbacks, _lifetime.token(), _admin.executeJson(actionType, body), + [after = std::move(after)](std::string const& reply) { + after(); + return reply; + }, + [](std::exception_ptr const&) {}); +} + +void ProjectsController::assignMemberContext() { + auto const& target = _membersOf.peek(); + if (target && target->project.hasValue()) { + _addMember.assign("projectId", std::to_string(*target->project)); + } +} + +} // namespace kanban::client +``` + +`hideMembers` makes the roles query idle, which clears its value — so `members()` is empty while hidden, with no +conditional of its own. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client]" +``` + +Expected: PASS. + +Mutation check: remove `_roles.refetch();` from the add-member form's success hook. Expected FAIL in "members +round-trip…" (bob never appears within the budget). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/kanban/app/controllers/projects_controller.hpp examples/kanban/app/controllers/projects_controller.cpp \ + examples/kanban/tests/client/test_projects_controller.cpp +git commit -m "wip(kanban): ProjectsController: project list, create form, members and the role picker + +Signed-off-by: Christian Parpart " +``` + +--- +### Task K5: `OfflineMoves` — the durable offline queue, Qt-free + +Re-expresses `BoardBridge::enableOfflineQueue`/`replayMoveTaskPosition` (`gui_lib/board_qml_bridge.cpp`) without a +nested `QEventLoop` and without running the reconnect sequence on the GUI thread. The queue and its drain run on a +strand over a private two-thread pool, and the reconnect sequence (with its retry sleeps) on the coordinator's own +strand over the same pool, as `ReconnectCoordinator`'s contract asks ("a worker pool", +`morph/offline/reconnect_coordinator.hpp:144`). A replay posts the move to the owner, where the bridge handler +belongs (`BridgeHandler`: "used and destroyed there", `bridge.hpp`), and waits for its outcome on the drain's +thread in short slices, so teardown never waits longer than one slice. The whole class is compiled only under +`MORPH_BUILD_OFFLINE_SQLITE`, as `BoardBridge`'s offline members were. + +**Files:** +- Create: `examples/kanban/app/controllers/offline_config.hpp` +- Create: `examples/kanban/app/controllers/offline_moves.hpp`, `examples/kanban/app/controllers/offline_moves.cpp` +- Modify: `examples/kanban/CMakeLists.txt` — inside the existing `if(MORPH_BUILD_OFFLINE_SQLITE)` block, before its + `if(TARGET ladder_kanban_tests)`, add the `ladder_kanban_app` lines below +- Modify: `examples/kanban/tests/client/kanban_client_support.hpp` — add ``, `` to the + includes and `ScopedQueueFile` (below) before the closing `} // namespace kanban::testing` +- Test: `examples/kanban/tests/client/test_offline_moves.cpp` + +**Interfaces:** +- Consumes: `morph::offline::SqliteOfflineQueue(IExecutor&, std::filesystem::path)`, `IOfflineQueue::enqueue(IExecutor&, + std::string, std::string)` and `size(IExecutor&) const` (`morph/offline/offline_queue.hpp:325`, `:346`), + `SyncWorker(IExecutor&, IOfflineQueue&, DetailedReplayFunction, DeadLetterSink)`, `SyncWorker::run(IExecutor&)`, + `stop()` (`sync_worker.hpp:216`, `:240`, `:257`), `ReplayOutcome` (`sync_worker.hpp:33`), + `ReconnectCoordinator(Deps, IExecutor&)`, `onOnline(IExecutor&)`, `onOffline()`, `strand()` + (`reconnect_coordinator.hpp:152`, `:179`, `:195`, `:207`), `NetworkMonitor(ProbeFunction, Callback, Callback, + Config)` (`network_monitor.hpp:97`), `morph::exec::OwnerStrand` (`morph/core/owner_strand.hpp:39`), + `CallbackToken::guard` (`callback_scope.hpp:126`). +- Produces: + - `kanban::client::OfflineConfig{queuePath, probe, probeInterval, failureThreshold, onlineThreshold}` (always). + - `kanban::client::OfflineMoves` (only under `MORPH_BUILD_OFFLINE_SQLITE`): `OfflineMoves(Runtime&, IExecutor& + owner, OfflineConfig const&, Replay, std::function onReplayed)`, `online()`, `enqueue(MoveTaskPosition + const&)`, `queueDepth()`, `deadLettered()`, `drainsCompleted()`. + - Test support: `kanban::testing::ScopedQueueFile(std::string name)` with `path()`. + +- [ ] **Step 1: Write the failing test** + +Add to `kanban_client_support.hpp` (with `#include ` and `#include `): + +```cpp +/// A queue file under the temp directory, removed with its WAL side files before and after the test. +class ScopedQueueFile { +public: + explicit ScopedQueueFile(std::string const& name) + : _path{std::filesystem::temp_directory_path() / ("kanban_client_" + name + ".db")} { + remove(); + } + ~ScopedQueueFile() { remove(); } + ScopedQueueFile(ScopedQueueFile const&) = delete; + ScopedQueueFile& operator=(ScopedQueueFile const&) = delete; + ScopedQueueFile(ScopedQueueFile&&) = delete; + ScopedQueueFile& operator=(ScopedQueueFile&&) = delete; + + [[nodiscard]] std::filesystem::path const& path() const noexcept { return _path; } + +private: + void remove() const { + std::error_code ignored; + std::filesystem::remove(_path, ignored); + std::filesystem::remove(_path.string() + "-wal", ignored); + std::filesystem::remove(_path.string() + "-shm", ignored); + } + + std::filesystem::path _path; +}; +``` + +Create `examples/kanban/tests/client/test_offline_moves.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#ifdef MORPH_BUILD_OFFLINE_SQLITE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "controllers/offline_moves.hpp" +#include "kanban/core/errors.hpp" +#include "kanban_client_support.hpp" + +namespace { + +using namespace std::chrono_literals; +using kanban::client::OfflineConfig; +using kanban::client::OfflineMoves; +using morph::async::Completion; +using morph::examples::testing::pumpUntil; + +// An OfflineMoves over a fake replay the test controls, with a probe the test flips. +class Harness { +public: + enum class Answer : std::uint8_t { Accept, Refuse, Hold }; + + explicit Harness(std::filesystem::path const& queuePath) + : moves{std::make_unique( + runtime, owner, + OfflineConfig{.queuePath = queuePath, + .probe = [this] { return reachable.load(); }, + .probeInterval = 20ms, + .failureThreshold = 1, + .onlineThreshold = 1}, + [this](kanban::MoveTaskPosition action) { return replay(std::move(action)); }, + [this] { ++drainsThatLanded; })} {} + + [[nodiscard]] bool settle(auto done, std::chrono::milliseconds budget = 5000ms) { + return pumpUntil(owner, std::move(done), budget); + } + + void goOffline() { + reachable.store(false); + REQUIRE(settle([this] { return !moves->online(); })); + } + + void goOnline() { reachable.store(true); } + + morph::exec::MainThreadExecutor owner; + morph::reactive::Runtime runtime{owner}; + std::atomic reachable{true}; + Answer answer = Answer::Accept; + std::vector replayed; + std::vector::Promise> held; + int drainsThatLanded = 0; + std::unique_ptr moves; + +private: + Completion replay(kanban::MoveTaskPosition action) { + replayed.push_back(action.opId); + auto settleable = Completion::makeSettleable(&owner); + switch (answer) { + case Answer::Accept: + settleable.second.resolve(kanban::GetBoardResult{}); + break; + case Answer::Refuse: + settleable.second.reject(std::make_exception_ptr(kanban::Conflict{"column is at its WIP limit"})); + break; + case Answer::Hold: + held.push_back(std::move(settleable.second)); + break; + default: + break; + } + return std::move(settleable.first); + } +}; + +[[nodiscard]] kanban::MoveTaskPosition move(std::string opId) { + return kanban::MoveTaskPosition{.taskId = kanban::TaskId{1}, + .columnId = kanban::ColumnId{2}, + .swimlaneId = kanban::SwimlaneId{3}, + .position = 0, + .opId = std::move(opId)}; +} + +} // namespace + +TEST_CASE("kanban::client::OfflineMoves: a move enqueued while offline waits, then replays once on reconnect", + "[kanban][client][offline]") { + kanban::testing::ScopedQueueFile const queueFile{"offline_moves_replay"}; + Harness harness{queueFile.path()}; + harness.goOffline(); + + harness.moves->enqueue(move("op-1")); + REQUIRE(harness.settle([&harness] { return harness.moves->queueDepth() == 1; })); + CHECK(harness.replayed.empty()); + + harness.goOnline(); + REQUIRE(harness.settle([&harness] { return harness.moves->queueDepth() == 0 && harness.drainsThatLanded > 0; })); + CHECK(harness.replayed == std::vector{"op-1"}); + CHECK(harness.moves->deadLettered() == 0); +} + +TEST_CASE("kanban::client::OfflineMoves: a move the server keeps refusing is dead-lettered", "[kanban][client][offline]") { + kanban::testing::ScopedQueueFile const queueFile{"offline_moves_dead_letter"}; + Harness harness{queueFile.path()}; + harness.answer = Harness::Answer::Refuse; + harness.goOffline(); + harness.moves->enqueue(move("op-refused")); + REQUIRE(harness.settle([&harness] { return harness.moves->queueDepth() == 1; })); + + for (int flap = 0; flap < 8 && harness.moves->deadLettered() == 0; ++flap) { + std::size_t const attempts = harness.replayed.size(); + harness.goOnline(); + REQUIRE(harness.settle([&] { return harness.replayed.size() > attempts || harness.moves->deadLettered() > 0; })); + harness.goOffline(); + } + + REQUIRE(harness.settle([&harness] { return harness.moves->deadLettered() == 1 && harness.moves->queueDepth() == 0; })); + CHECK(harness.drainsThatLanded == 0); +} + +TEST_CASE("kanban::client::OfflineMoves: destroying it while a replay waits returns promptly", + "[kanban][client][offline]") { + kanban::testing::ScopedQueueFile const queueFile{"offline_moves_teardown"}; + Harness harness{queueFile.path()}; + harness.answer = Harness::Answer::Hold; + harness.goOffline(); + harness.moves->enqueue(move("op-held")); + REQUIRE(harness.settle([&harness] { return harness.moves->queueDepth() == 1; })); + harness.goOnline(); + REQUIRE(harness.settle([&harness] { return harness.replayed.size() == 1; })); + + auto const started = std::chrono::steady_clock::now(); + harness.moves.reset(); + CHECK(std::chrono::steady_clock::now() - started < 2s); +} + +#endif // MORPH_BUILD_OFFLINE_SQLITE +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_kanban_tests` +Expected: compile error, `'controllers/offline_moves.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/kanban/app/controllers/offline_config.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +namespace kanban::client { + +/// @brief Where a board keeps the moves made while the network is down, and how it decides that it is. +/// +/// Honoured only in a build with `MORPH_BUILD_OFFLINE_SQLITE`; elsewhere a board ignores it and sends every move. +struct OfflineConfig { + /// @brief The SQLite file the queue lives in. + std::filesystem::path queuePath; + /// @brief Whether the server is reachable; empty means always. Called on the monitor's own thread. + std::function probe; + /// @brief How often the probe runs. + std::chrono::milliseconds probeInterval{5000}; + /// @brief Consecutive failed probes before moves are queued. + int failureThreshold = 3; + /// @brief Consecutive good probes before the queue replays. + int onlineThreshold = 1; +}; + +} // namespace kanban::client +``` + +Create `examples/kanban/app/controllers/offline_moves.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "controllers/offline_config.hpp" + +#ifdef MORPH_BUILD_OFFLINE_SQLITE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "kanban/dto/board_dto.hpp" + +namespace kanban::client { + +/// @brief Board moves made while the network is down: a durable queue, the monitor that decides "down", and the +/// reconnect sequence that replays the queue when the network comes back. +/// +/// The monitor probes on its own loop. The queue and its drain run on a strand over a private pool, the reconnect +/// sequence and its retry sleeps on the coordinator's strand over the same pool. A replay posts the move to the +/// owner, where the bridge handler belongs, and waits for its outcome on the drain's thread in short slices. +/// What this class shows (`queueDepth`, `deadLettered`) is written on the owner only. +class OfflineMoves { +public: + /// @brief Dispatches one queued move. Called on the owner. + using Replay = std::function(MoveTaskPosition)>; + + /// @param runtime The runtime the counters belong to. Borrowed. + /// @param owner The runtime's owner, where @p replay runs. Borrowed. + /// @param config Where the queue lives and how the network is probed. + /// @param replay Dispatches a queued move. + /// @param onReplayed Called on the owner after a drain that replayed at least one move. + OfflineMoves(morph::reactive::Runtime& runtime, morph::exec::IExecutor& owner, OfflineConfig const& config, + Replay replay, std::function onReplayed); + + /// @brief Stops the probe and the reconnect sequence and waits for a running drain; a replay that is waiting + /// for its outcome gives up within one slice. + ~OfflineMoves(); + + OfflineMoves(OfflineMoves const&) = delete; + OfflineMoves& operator=(OfflineMoves const&) = delete; + OfflineMoves(OfflineMoves&&) = delete; + OfflineMoves& operator=(OfflineMoves&&) = delete; + + /// @brief Whether the monitor last saw the network. Any thread. + /// @return False between the monitor's offline and online transitions. + [[nodiscard]] bool online() const noexcept { return _online->load(); } + + /// @brief Stores @p action for replay, deduplicated by its op id. On the owner. + /// @param action The move, its op id already minted. + void enqueue(MoveTaskPosition const& action); + + /// @brief Moves waiting in the queue. Tracked. + /// @return The depth as last read. + [[nodiscard]] int queueDepth() const { return _depth.get(); } + + /// @brief Moves dropped after the server refused them through their whole retry budget. Tracked. + /// @return The running total. + [[nodiscard]] int deadLettered() const { return _deadLettered.get(); } + + /// @brief Reconnect drains that have finished, whatever they replayed. Tracked. + /// @return The running total. + [[nodiscard]] int drainsCompleted() const { return _drains.get(); } + +private: + void runReplay(morph::async::CallbackToken const& token); + [[nodiscard]] morph::offline::ReplayOutcome replayOne(std::string const& payload, + morph::async::CallbackToken const& token); + void refreshDepth(); + + morph::exec::IExecutor* _owner; + Replay _replay; + std::function _onReplayed; + morph::reactive::Signal _depth; + morph::reactive::Signal _deadLettered; + morph::reactive::Signal _drains; + std::shared_ptr> _online = std::make_shared>(true); + std::shared_ptr> _stopping = std::make_shared>(false); + std::unique_ptr _pool; + std::unique_ptr _queueOwner; + std::unique_ptr _queue; + std::unique_ptr _coordinator; + std::unique_ptr _worker; + std::unique_ptr _monitor; + // Last member, so it is destroyed first: work posted to the owner after teardown finds it stopped. + morph::async::CallbackScope _lifetime; +}; + +} // namespace kanban::client + +#endif // MORPH_BUILD_OFFLINE_SQLITE +``` + +Create `examples/kanban/app/controllers/offline_moves.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/offline_moves.hpp" + +#ifdef MORPH_BUILD_OFFLINE_SQLITE + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace kanban::client { + +namespace { + +// How long a queued move may take to come back from the bridge before its replay counts as refused. +constexpr std::chrono::milliseconds kReplayBudget{10000}; +// How often a waiting replay, or a retry sleep, looks at the stop flag. +constexpr std::chrono::milliseconds kWaitSlice{20}; +// One thread for the queue's strand, one for the reconnect sequence and its retry sleeps. +constexpr std::size_t kOfflineThreads = 2; + +void sleepUnlessStopping(std::atomic const& stopping, std::chrono::milliseconds duration) { + auto const until = std::chrono::steady_clock::now() + duration; + while (!stopping.load()) { + auto const now = std::chrono::steady_clock::now(); + if (now >= until) { + return; + } + std::this_thread::sleep_for(std::min(kWaitSlice, until - now)); + } +} + +} // namespace + +OfflineMoves::OfflineMoves(morph::reactive::Runtime& runtime, morph::exec::IExecutor& owner, + OfflineConfig const& config, Replay replay, std::function onReplayed) + : _owner{&owner}, + _replay{std::move(replay)}, + _onReplayed{std::move(onReplayed)}, + _depth{runtime, 0}, + _deadLettered{runtime, 0}, + _drains{runtime, 0}, + _pool{std::make_unique(kOfflineThreads)}, + _queueOwner{std::make_unique(*_pool)}, + _queue{std::make_unique(*_queueOwner, config.queuePath)} { + morph::async::CallbackToken const token = _lifetime.token(); + _coordinator = std::make_unique( + morph::offline::ReconnectCoordinator::Deps{ + .tryReconnect = [] { return true; }, + .activatePrimary = [] {}, + .activateLocal = [] {}, + .bindContext = [] {}, + .replay = [this, token] { runReplay(token); }, + .shouldContinue = [online = _online, stopping = _stopping] { return online->load() && !stopping->load(); }, + .sleep = [stopping = _stopping](std::chrono::milliseconds duration) { + sleepUnlessStopping(*stopping, duration); + }, + }, + *_pool); + _worker = std::make_unique( + *_queueOwner, *_queue, + morph::offline::SyncWorker::DetailedReplayFunction{ + [this, token](std::string const& payload) { return replayOne(payload, token); }}, + [this, token](morph::offline::QueueItem const&) { + _owner->post(token.guard([this] { + _deadLettered.set(_deadLettered.peek() + 1); + refreshDepth(); + })); + }); + morph::offline::ReconnectCoordinator* const coordinator = _coordinator.get(); + _monitor = std::make_unique( + config.probe ? config.probe : morph::offline::NetworkMonitor::ProbeFunction{[] { return true; }}, + [online = _online, coordinator] { + online->store(false); + coordinator->onOffline(); + }, + [online = _online, coordinator] { + online->store(true); + static_cast(coordinator->onOnline(coordinator->strand())); + }, + morph::offline::NetworkMonitor::Config{.probeInterval = config.probeInterval, + .failureThreshold = config.failureThreshold, + .onlineThreshold = config.onlineThreshold}); + refreshDepth(); +} + +OfflineMoves::~OfflineMoves() { + // Order matters: no probe may start a sequence, no sequence may start a drain, and the drain's strand must be + // closed (which waits for a running drain) before the queue and the worker it uses are destroyed. + _stopping->store(true); + _worker->stop(); + _monitor.reset(); + _coordinator.reset(); + _queueOwner->close(); + _worker.reset(); + _queue.reset(); +} + +void OfflineMoves::enqueue(MoveTaskPosition const& action) { + morph::offline::IOfflineQueue& queue = *_queue; + queue.enqueue(*_owner, glz::write_json(action).value_or(std::string{"{}"}), action.opId) + .then(_lifetime, [this](std::uint64_t const&) { refreshDepth(); }) + .onError(_lifetime, [this](std::exception_ptr const&) { refreshDepth(); }); +} + +void OfflineMoves::refreshDepth() { + morph::offline::IOfflineQueue const& queue = *_queue; + queue.size(*_owner).then(_lifetime, [this](std::size_t const& depth) { _depth.set(static_cast(depth)); }); +} + +void OfflineMoves::runReplay(morph::async::CallbackToken const& token) { + // On the coordinator's strand: the drain's answer is delivered back here, and only the owner may touch the + // counters, so the outcome is posted on. + _worker->run(_coordinator->strand()).thenDetached([this, token](morph::offline::SyncResult const& result) { + bool const landed = result.successful > 0; + _owner->post(token.guard([this, landed] { + refreshDepth(); + _drains.set(_drains.peek() + 1); + if (landed) { + _onReplayed(); + } + })); + }); +} + +morph::offline::ReplayOutcome OfflineMoves::replayOne(std::string const& payload, + morph::async::CallbackToken const& token) { + MoveTaskPosition action; + if (glz::read_json(action, payload)) { + return morph::offline::ReplayOutcome::Rejected; + } + auto settled = std::make_shared>(); + std::future outcome = settled->get_future(); + _owner->post(token.guard([this, action = std::move(action), settled] { + _replay(action) + .thenDetached([settled](GetBoardResult const&) { settled->set_value(true); }) + .onErrorDetached([settled](std::exception_ptr const&) { settled->set_value(false); }); + })); + auto const deadline = std::chrono::steady_clock::now() + kReplayBudget; + while (outcome.wait_for(kWaitSlice) != std::future_status::ready) { + if (_stopping->load() || std::chrono::steady_clock::now() >= deadline) { + // The move may have reached the server: "unknown" must be Rejected, never Undelivered, or a poisonous + // payload is retried forever (sync_worker.hpp, ReplayOutcome). + return morph::offline::ReplayOutcome::Rejected; + } + } + try { + return outcome.get() ? morph::offline::ReplayOutcome::Succeeded : morph::offline::ReplayOutcome::Rejected; + } catch (std::future_error const&) { + // The owner dropped the posted dispatch because this object was being torn down: nothing was sent. + return morph::offline::ReplayOutcome::Undelivered; + } +} + +} // namespace kanban::client + +#endif // MORPH_BUILD_OFFLINE_SQLITE +``` + +In `examples/kanban/CMakeLists.txt`'s `if(MORPH_BUILD_OFFLINE_SQLITE)` block add: + +```cmake + if(TARGET ladder_kanban_app) + target_compile_definitions(ladder_kanban_app PUBLIC MORPH_BUILD_OFFLINE_SQLITE) + target_link_libraries(ladder_kanban_app PUBLIC morph::offline_sqlite) + endif() +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client][offline]" +``` + +Expected: PASS, 3 cases (the build/all configure has `MORPH_BUILD_OFFLINE_SQLITE=ON`). + +Mutation check: in `replayOne`'s wait loop drop `_stopping->load() ||`. Expected FAIL in "destroying it while a +replay waits returns promptly" (teardown waits out the 10 s budget). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/kanban/app/controllers/offline_config.hpp examples/kanban/app/controllers/offline_moves.hpp \ + examples/kanban/app/controllers/offline_moves.cpp examples/kanban/tests/client/kanban_client_support.hpp \ + examples/kanban/tests/client/test_offline_moves.cpp examples/kanban/CMakeLists.txt +git commit -m "wip(kanban): OfflineMoves: the durable offline move queue without a GUI event loop + +Signed-off-by: Christian Parpart " +``` + +--- +### Task K6: `BoardController` — attach, board forms, cards, moves and polling + +Re-expresses `BoardPresenter` and `BoardBridge` (`openBoard`, `refresh`, the `CreateColumn`/`CreateSwimlane`/ +`CreateTask` forms, `moveTask` with a fresh op id per call, `startPolling`/`onEventApplied`, `setMyRole`) and +`BoardView.qml`'s conditionals (lane headers only with more than one lane, the column header text, the per-cell +card lists). The board is one `Query` keyed on the open project — re-issuing the keyed attach is how it +refreshes (a handler re-pointed at the same key just executes, `docs/spec/core/shared_instances.md`, "Re-pointing, +not re-keying") — and every board write invalidates it. The activity panel is a `Query` keyed on +the board's presence. Polling is Part 6's `examples::Poller` over `GetEventsSince`, created when a board attaches +and destroyed when it detaches. + +**Files:** +- Create: `examples/kanban/app/controllers/board_controller.hpp`, `examples/kanban/app/controllers/board_controller.cpp` +- Create: `examples/kanban/tests/client/board_support.hpp` +- Test: `examples/kanban/tests/client/test_board_controller.cpp`, `examples/kanban/tests/client/test_board_polling.cpp`, + `examples/kanban/tests/client/test_board_socket.cpp` + +**Interfaces:** +- Consumes: `columnHeader`, `roleLabel`, `activityMeta`, `pendingSyncLine`, `deadLetterLine` (Task K2); + `morph::examples::Wiring`, `mapCompletion` (Part 6); `forms::FormModel::forAction()`, `FormSession::assign` + (Part 5); `OfflineConfig`, `OfflineMoves` (Task K5); + `morph::examples::Poller` and `morph::examples::newUuid()` (Part 6, shapes as stated + under "What this plan relies on"); `reactive::Query`, `Mutation`, `Computed`, `Effect`, `Runtime::batch`, + `Runtime::untracked` (Part 1); `testkit/backend_rig.hpp` (`BackendRig`, `Mode::Socket`), `testkit/fault_proxy.hpp` + (`FaultProxy::start`, `setRequestObserver`, `delayReply`), `testkit/pump.hpp` (`pumpUntil`, `awaitQt`). +- Produces (Tasks K7–K14 use exactly these): + - Rows: `CardRow{id, columnId, laneId, position, label}`, `ColumnHead{id, name}`, `LaneHead{id, label, + canAddTask}`, `BoardShape{columns, lanes, key}`, `ActivityRow{index, summary, meta}`, `BoardOptions{offline, + pollEvery}`. + - `BoardController(examples::Wiring, BoardOptions = {})`: `open(ProjectId, Role)`, `close()`, `refresh()`, `isOpen()`, + `loading()`, `board() -> std::optional const&`, `attachedProject()`, `title()`, `roleText()`, + `shapes() -> std::vector`, `headerOf(std::int64_t columnId)`, `cardsIn(std::int64_t columnId, + std::optional laneId)`, `activity()`, `errorText()`, `eventsSeen()`, `online()`, `queueDepth()`, + `deadLettered()`, `syncRuns()`, `syncText()`, `deadLetterText()`, `moveTask(TaskId, ColumnId, SwimlaneId, std::int64_t)`, + `dropAtEnd(TaskId, ColumnId, std::optional laneId)`, `movesPending()`, `lastMoveOpId()`, static + `taskOf(std::variant const&) -> std::optional`, `createColumnForm()`, + `createSwimlaneForm()`, `createTaskForm()`, `openNewTask(ColumnId, SwimlaneId)`, `closeNewTask()`, + `newTaskOpen()`, `newTaskTitle()`, `handler()`. + - Test support: `kanban::testing::SeededBoard{project, todo, done, lane, task}`, `columnNamed(board, name)`, + `seedBoard(client, board, project, doneWipLimit = 0)`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/kanban/tests/client/board_support.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +#include "controllers/board_controller.hpp" +#include "kanban_client_support.hpp" + +namespace kanban::testing { + +/// The ids a seeded board's tests act on. +struct SeededBoard { + kanban::ProjectId project; + std::int64_t todo = 0; + std::int64_t done = 0; + std::int64_t lane = 0; + std::int64_t task = 0; +}; + +/// The id of the column called @p name. +[[nodiscard]] inline std::int64_t columnNamed(kanban::GetBoardResult const& board, std::string_view name) { + for (kanban::ColumnView const& column : board.columns) { + if (column.name == name && column.id.hasValue()) { + return *column.id; + } + } + FAIL("the board has no column named " << name); + return 0; +} + +/// Opens @p project on @p board and gives it, through the board's own forms, two columns ("To Do", and "Done" +/// with @p doneWipLimit), one swimlane and one task "Fix bug" in To Do. +template +[[nodiscard]] SeededBoard seedBoard(Client& client, kanban::client::BoardController& board, kanban::ProjectId project, + std::int64_t doneWipLimit = 0) { + board.open(project, kanban::Role::Manager); + REQUIRE(client.settle([&board] { return board.board().has_value(); })); + submitForm(client, board.createColumnForm(), R"({"name":"To Do"})"); + submitForm(client, board.createColumnForm(), + R"({"name":"Done","wipLimit":)" + std::to_string(doneWipLimit) + "}"); + submitForm(client, board.createSwimlaneForm(), R"({"name":"Default"})"); + REQUIRE(client.settle([&board] { + return board.board() && board.board()->columns.size() == 2 && board.board()->swimlanes.size() == 1; + })); + SeededBoard seeded{.project = project, + .todo = columnNamed(*board.board(), "To Do"), + .done = columnNamed(*board.board(), "Done"), + .lane = *board.board()->swimlanes.front().id}; + board.openNewTask(kanban::ColumnId{seeded.todo}, kanban::SwimlaneId{seeded.lane}); + submitForm(client, board.createTaskForm(), R"({"title":"Fix bug"})"); + REQUIRE(client.settle([&] { return board.cardsIn(seeded.todo, seeded.lane).size() == 1; })); + seeded.task = board.cardsIn(seeded.todo, seeded.lane).front().id; + return seeded; +} + +} // namespace kanban::testing +``` + +Create `examples/kanban/tests/client/test_board_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +#include "board_support.hpp" +#include "controllers/board_controller.hpp" +#include "kanban_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using kanban::ColumnId; +using kanban::SwimlaneId; +using kanban::TaskId; +using kanban::client::BoardController; +using kanban::testing::LocalClient; +using morph::ladder::testkit::DbFixture; + +TEST_CASE("kanban::client::BoardController: open attaches and reports the board's empty initial state", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + CHECK_FALSE(board.isOpen()); + CHECK(board.title() == "Board"); + CHECK(board.roleText().empty()); + CHECK(board.shapes().empty()); + + board.open(project, kanban::Role::Manager); + + CHECK(board.isOpen()); + REQUIRE(client.settle([&board] { return board.board().has_value(); })); + CHECK(board.title() == "Sprint Board"); + CHECK(board.roleText() == "role: Manager"); + CHECK(board.attachedProject() == std::optional{project}); + REQUIRE(board.shapes().size() == 1); + CHECK(board.shapes().front().columns.empty()); + REQUIRE(board.shapes().front().lanes.size() == 1); + CHECK_FALSE(board.shapes().front().lanes.front().canAddTask); + CHECK(board.errorText().empty()); +} + +TEST_CASE("kanban::client::BoardController: the column, swimlane and task forms populate the board", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + auto const seeded = kanban::testing::seedBoard(client, board, project); + + auto const shape = board.shapes().front(); + REQUIRE(shape.columns.size() == 2); + CHECK(shape.columns.at(0).name == "To Do"); + CHECK(shape.columns.at(1).name == "Done"); + REQUIRE(shape.lanes.size() == 1); + CHECK(shape.lanes.front().label.empty()); + CHECK(shape.lanes.front().canAddTask); + CHECK(board.headerOf(seeded.todo) == "To Do (1)"); + auto const cards = board.cardsIn(seeded.todo, seeded.lane); + REQUIRE(cards.size() == 1); + CHECK(cards.front().label == "Fix bug"); + CHECK_FALSE(board.newTaskOpen()); + + board.openNewTask(ColumnId{seeded.done}, SwimlaneId{seeded.lane}); + CHECK(board.newTaskOpen()); + CHECK(board.newTaskTitle() == "New task in Done"); + board.closeNewTask(); + CHECK_FALSE(board.newTaskOpen()); +} + +TEST_CASE("kanban::client::BoardController: moveTask moves a card, every move with a fresh op id", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + auto const seeded = kanban::testing::seedBoard(client, board, project); + + board.moveTask(TaskId{seeded.task}, ColumnId{seeded.done}, SwimlaneId{seeded.lane}, 0); + std::string const first = board.lastMoveOpId(); + REQUIRE(client.settle([&] { return board.cardsIn(seeded.done, seeded.lane).size() == 1 && !board.movesPending(); })); + + board.moveTask(TaskId{seeded.task}, ColumnId{seeded.todo}, SwimlaneId{seeded.lane}, 0); + std::string const second = board.lastMoveOpId(); + REQUIRE(client.settle([&] { return board.cardsIn(seeded.todo, seeded.lane).size() == 1 && !board.movesPending(); })); + + CHECK(first.size() == 36); + CHECK(second.size() == 36); + CHECK(first != second); + CHECK(board.errorText().empty()); +} + +TEST_CASE("kanban::client::BoardController: dropAtEnd places the card after the cards already there", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + auto const seeded = kanban::testing::seedBoard(client, board, project); + board.openNewTask(ColumnId{seeded.done}, SwimlaneId{seeded.lane}); + kanban::testing::submitForm(client, board.createTaskForm(), R"({"title":"Ship it"})"); + REQUIRE(client.settle([&] { return board.cardsIn(seeded.done, seeded.lane).size() == 1; })); + + board.dropAtEnd(TaskId{seeded.task}, ColumnId{seeded.done}, std::nullopt); + + REQUIRE(client.settle([&] { return board.cardsIn(seeded.done, seeded.lane).size() == 2; })); + auto const cards = board.cardsIn(seeded.done, seeded.lane); + CHECK(cards.at(0).label == "Ship it"); + CHECK(cards.at(1).label == "Fix bug"); +} + +TEST_CASE("kanban::client::BoardController: a move the server refuses reports and leaves the card", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + auto const seeded = kanban::testing::seedBoard(client, board, project, /*doneWipLimit=*/1); + board.openNewTask(ColumnId{seeded.done}, SwimlaneId{seeded.lane}); + kanban::testing::submitForm(client, board.createTaskForm(), R"({"title":"Blocker"})"); + REQUIRE(client.settle([&] { return board.cardsIn(seeded.done, seeded.lane).size() == 1; })); + + board.moveTask(TaskId{seeded.task}, ColumnId{seeded.done}, SwimlaneId{seeded.lane}, 0); + + REQUIRE(client.settle([&board] { return !board.errorText().empty(); })); + CHECK(board.cardsIn(seeded.todo, seeded.lane).size() == 1); +} + +TEST_CASE("kanban::client::BoardController: a disengaged project id surfaces as an error, not a board", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + BoardController board{client.wiring()}; + + board.open(kanban::ProjectId{}, kanban::Role::Viewer); + + REQUIRE(client.settle([&board] { return !board.errorText().empty(); })); + CHECK_FALSE(board.board().has_value()); +} + +TEST_CASE("kanban::client::BoardController::taskOf accepts positive integer keys only", "[kanban][client]") { + using Key = std::variant; + CHECK(BoardController::taskOf(Key{std::int64_t{7}}) == std::optional{TaskId{7}}); + CHECK_FALSE(BoardController::taskOf(Key{std::int64_t{0}}).has_value()); + CHECK_FALSE(BoardController::taskOf(Key{std::string{"7"}}).has_value()); +} +``` + +Create `examples/kanban/tests/client/test_board_polling.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include "board_support.hpp" +#include "controllers/board_controller.hpp" +#include "kanban_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using kanban::ColumnId; +using kanban::SwimlaneId; +using kanban::TaskId; +using kanban::client::BoardController; +using kanban::testing::LocalClient; +using morph::ladder::testkit::DbFixture; +using namespace std::chrono_literals; + +TEST_CASE("kanban::client::BoardController: another client's move reaches an open board on the next poll", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController mover{client.wiring()}; + auto const seeded = kanban::testing::seedBoard(client, mover, project); + BoardController watcher{client.wiring()}; + watcher.open(project, kanban::Role::Member); + // The first poll replays every event since the board began, so the watcher refreshes once on its own; wait + // until that refresh has landed before anything else happens. + REQUIRE(client.settle([&watcher] { return watcher.eventsSeen() > 0 && !watcher.loading(); })); + REQUIRE(watcher.cardsIn(seeded.todo, seeded.lane).size() == 1); + + mover.moveTask(TaskId{seeded.task}, ColumnId{seeded.done}, SwimlaneId{seeded.lane}, 0); + REQUIRE(client.settle([&] { return mover.cardsIn(seeded.done, seeded.lane).size() == 1; })); + CHECK(watcher.cardsIn(seeded.todo, seeded.lane).size() == 1); + + client.scheduler.advance(3000ms); + + REQUIRE(client.settle([&] { return watcher.cardsIn(seeded.done, seeded.lane).size() == 1; })); + CHECK(watcher.cardsIn(seeded.todo, seeded.lane).empty()); +} + +TEST_CASE("kanban::client::BoardController: closing the board stops polling", "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + board.open(project, kanban::Role::Manager); + REQUIRE(client.settle([&board] { return board.board().has_value(); })); + CHECK(client.scheduler.pendingTimers() > 0); + + board.close(); + + REQUIRE(client.settle([&board] { return !board.board().has_value(); })); + CHECK(client.scheduler.pendingTimers() == 0); + CHECK(board.eventsSeen() == 0); +} +``` + +Create `examples/kanban/tests/client/test_board_socket.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The board over a real socket whose attach reply is held back: the board must still appear once the reply lands, +// with nothing re-issued in between. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/board_controller.hpp" +#include "kanban/auth/kanban_authorizer.hpp" +#include "kanban_client_support.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/fault_proxy.hpp" +#include "testkit/pump.hpp" + +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::FaultProxy; +using morph::ladder::testkit::Mode; +using namespace std::chrono_literals; + +TEST_CASE("kanban::client::BoardController: a stalled Socket-mode attach still reports the board once it lands", + "[kanban][client][socket]") { + DbFixture const fixture; + auto const authorizer = std::make_shared(std::string{kanban::testing::kSecret}, + morph::session::hmacSha256); + BackendRig rig{Mode::Socket, 1, authorizer}; + rig.bridge(0).setDefaultSession(kanban::testing::signedContext("alice")); + morph::bridge::BridgeHandler creator{rig.bridge(0), rig.executor()}; + auto const project = + morph::ladder::testkit::awaitQt(creator.execute(kanban::CreateProject{.name = "Sprint Board"})).id; + + FaultProxy proxy{rig.url()}; + QUrl const proxyUrl = proxy.start(); + auto backend = std::make_unique( + proxyUrl, std::nullopt, morph::qt::QtWebSocketBackend::Config{.reconnectEnabled = false}); + REQUIRE(backend->waitForConnected()); + morph::qt::QtExecutor owner; + morph::bridge::Bridge bridge{std::move(backend), owner}; + bridge.setDefaultSession(kanban::testing::signedContext("alice")); + proxy.setRequestObserver([](std::uint64_t callId, FaultProxy& self) { self.delayReply(callId, 300ms); }); + morph::reactive::Runtime runtime{owner}; + morph::reactive::testing::ManualScheduler scheduler; + kanban::client::BoardController board{ + morph::examples::Wiring{.runtime = runtime, .scheduler = scheduler, .bridge = bridge, .callbacks = owner}}; + + board.open(project, kanban::Role::Manager); + + REQUIRE(morph::ladder::testkit::pumpUntil( + [&board] { return board.board().has_value() || !board.errorText().empty(); }, 5000ms)); + CHECK(board.errorText().empty()); + REQUIRE(board.board().has_value()); + CHECK(board.board()->name == "Sprint Board"); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake build/all && cmake --build build/all --target ladder_kanban_tests` +Expected: compile error, `'controllers/board_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/kanban/app/controllers/board_controller.hpp`: + +```cpp +// 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 "app/poller.hpp" +#include "app/wiring.hpp" +#include "controllers/offline_config.hpp" +#include "kanban/models/board_model.hpp" + +namespace kanban::client { + +/// @brief One card on the board. +struct CardRow { + /// @brief The task's id; also the card's drag key. + std::int64_t id = 0; + /// @brief The column it is in. + std::int64_t columnId = 0; + /// @brief The swimlane it is in. + std::int64_t laneId = 0; + /// @brief Its position within that cell. + std::int64_t position = 0; + /// @brief The title, with its tags in brackets when it has any. + std::string label; + /// @brief Rows compare by value, so an unchanged card keeps its widget. + bool operator==(CardRow const&) const = default; +}; + +/// @brief One column of the board's grid. +struct ColumnHead { + /// @brief The column's id. + std::int64_t id = 0; + /// @brief Its name. + std::string name; + /// @brief Compared by value. + bool operator==(ColumnHead const&) const = default; +}; + +/// @brief One swimlane row of the board's grid. +struct LaneHead { + /// @brief The swimlane's id; empty while the board has no swimlane, when the one row holds every card. + std::optional id; + /// @brief The lane's name when the board has more than one lane, else empty. + std::string label; + /// @brief Whether a task can be created in this row (a task needs a swimlane). + bool canAddTask = false; + /// @brief Compared by value. + bool operator==(LaneHead const&) const = default; +}; + +/// @brief The grid's structure: which columns and lanes exist. Cards are not part of it. +struct BoardShape { + /// @brief The columns, left to right. + std::vector columns; + /// @brief The lanes, top to bottom; never empty while a board is open. + std::vector lanes; + /// @brief Changes exactly when the columns or lanes change; the view rebuilds its grid on it. + std::string key; + /// @brief Compared by value. + bool operator==(BoardShape const&) const = default; +}; + +/// @brief One entry in the activity panel. +struct ActivityRow { + /// @brief The entry's place in the stream; its key. + std::int64_t index = 0; + /// @brief What happened. + std::string summary; + /// @brief Who did it, and with which action. + std::string meta; + /// @brief Compared by value. + bool operator==(ActivityRow const&) const = default; +}; + +/// @brief How a board runs besides its wiring. +struct BoardOptions { + /// @brief Engaged: moves made while the network is down are queued and replayed (builds with + /// `MORPH_BUILD_OFFLINE_SQLITE` only). + std::optional offline; + /// @brief How often an open board polls for other clients' changes. + std::chrono::milliseconds pollEvery{3000}; +}; + +class OfflineMoves; + +/// @brief The board screen: attach, the board and its activity, the board forms, moving cards, and polling for +/// other clients' changes. +class BoardController { +public: + /// @param wiring The runtime, scheduler, bridge and owner this controller uses. + /// @param options Offline queue and poll period. + explicit BoardController(morph::examples::Wiring wiring, BoardOptions options = {}); + ~BoardController(); + BoardController(BoardController const&) = delete; + BoardController& operator=(BoardController const&) = delete; + BoardController(BoardController&&) = delete; + BoardController& operator=(BoardController&&) = delete; + + /// @brief Attaches to @p project's board and starts showing it. + /// @param project The project. + /// @param myRole The caller's role there, as the project list reported it. + void open(ProjectId project, Role myRole); + /// @brief Detaches: the board, its activity and its polling go idle. + void close(); + /// @brief Fetches the board (and with it the activity) again. + void refresh(); + /// @brief Whether a project is open. Tracked. + /// @return True between `open` and `close`. + [[nodiscard]] bool isOpen() const { return _project.get().has_value(); } + /// @brief Whether the board is being fetched. Tracked. + /// @return True while a fetch is in flight. + [[nodiscard]] bool loading() const { return _open.pending(); } + /// @brief The attached board. Tracked. + /// @return Its state, kept while a refresh is in flight; empty while closed. + [[nodiscard]] std::optional const& board() const { return _open.value(); } + /// @brief The project whose board is attached. Tracked, equality-gated. + /// @return The project, or empty. + [[nodiscard]] std::optional attachedProject() const { return _attached.get(); } + /// @brief The board's title. Tracked. + /// @return The project name, or `"Board"` before one is attached. + [[nodiscard]] std::string title() const; + /// @brief The caller's role line. Tracked. + /// @return `"role: "` while open, else empty. + [[nodiscard]] std::string roleText() const; + /// @brief The grid's structure, as a list of zero or one shapes so a view can key on it. Tracked. + /// @return One shape while a board is attached, else none. + [[nodiscard]] std::vector shapes() const; + /// @brief A column's header text. Tracked. + /// @param columnId The column. + /// @return Its header, or empty when it is gone. + [[nodiscard]] std::string headerOf(std::int64_t columnId) const; + /// @brief The cards in one cell, by position. Tracked. + /// @param columnId The column. + /// @param laneId The swimlane; empty matches every lane. + /// @return The cards. + [[nodiscard]] std::vector cardsIn(std::int64_t columnId, std::optional laneId) const; + /// @brief The activity stream, oldest first. Tracked. + /// @return One row per entry. + [[nodiscard]] std::vector activity() const; + /// @brief The first failure among attach, moves and activity, else why polling stopped. Tracked. + /// @return The message, or empty. + [[nodiscard]] std::string errorText() const; + /// @brief How many events the open board's poller has applied. Tracked while the poller lives. + /// @return The count, zero without a poller. + [[nodiscard]] std::size_t eventsSeen() const; + /// @brief Whether moves are sent (true) or queued (false). Any thread. + /// @return True without an offline queue. + [[nodiscard]] bool online() const; + /// @brief Moves waiting in the offline queue. Tracked. + /// @return The depth; zero without a queue. + [[nodiscard]] int queueDepth() const; + /// @brief Moves the server refused through their whole retry budget. Tracked. + /// @return The total; zero without a queue. + [[nodiscard]] int deadLettered() const; + /// @brief Reconnect drains of the offline queue that have finished. Tracked. + /// @return The total; zero without a queue. + [[nodiscard]] int syncRuns() const; + /// @brief The pending-sync banner. Tracked. + /// @return The line, or empty. + [[nodiscard]] std::string syncText() const; + /// @brief The dead-letter banner. Tracked. + /// @return The line, or empty. + [[nodiscard]] std::string deadLetterText() const; + /// @brief Moves @p task to @p position in (@p column, @p lane) under a fresh op id: sent now, or queued while + /// offline. + /// @param task The task. + /// @param column The destination column. + /// @param lane The destination swimlane. + /// @param position The destination position. + void moveTask(TaskId task, ColumnId column, SwimlaneId lane, std::int64_t position); + /// @brief Moves @p task to the end of (@p column, @p laneId). + /// @param task The task. + /// @param column The destination column. + /// @param laneId The destination swimlane; empty keeps the task's own. + void dropAtEnd(TaskId task, ColumnId column, std::optional laneId); + /// @brief Whether a sent move is in flight. Tracked. + /// @return True while any is. + [[nodiscard]] bool movesPending() const { return _move.pending(); } + /// @brief The op id the last move was minted with. + /// @return The id; empty before the first move. + [[nodiscard]] std::string const& lastMoveOpId() const noexcept { return _lastMoveOpId; } + /// @brief The task a drag key names. + /// @param key A drag key (`ui::Key`'s alternatives). + /// @return The task for a positive integer key, else empty. + [[nodiscard]] static std::optional taskOf(std::variant const& key) noexcept; + /// @brief The `CreateColumn` form. + /// @return The form session. + [[nodiscard]] morph::forms::FormSession& createColumnForm() noexcept { return _createColumn; } + /// @brief The `CreateSwimlane` form. + /// @return The form session. + [[nodiscard]] morph::forms::FormSession& createSwimlaneForm() noexcept { return _createSwimlane; } + /// @brief The `CreateTask` form; `openNewTask` fills its hidden ids. + /// @return The form session. + [[nodiscard]] morph::forms::FormSession& createTaskForm() noexcept { return _createTask; } + /// @brief Opens the new-task dialog for one cell. + /// @param column The cell's column. + /// @param lane The cell's swimlane. + void openNewTask(ColumnId column, SwimlaneId lane); + /// @brief Closes the new-task dialog. + void closeNewTask(); + /// @brief Whether the new-task dialog is open. Tracked. + /// @return True between `openNewTask` and its success or `closeNewTask`. + [[nodiscard]] bool newTaskOpen() const { return _newTask.get().has_value(); } + /// @brief The new-task dialog's title. Tracked. + /// @return `"New task in "`, or `"New task"`. + [[nodiscard]] std::string newTaskTitle() const; + /// @brief The board's handler; the rules and task-detail controllers act on the same attached instance. + /// @return The handler. + [[nodiscard]] morph::bridge::BridgeHandler& handler() noexcept { + return _handler; + } + +private: + class Polling; + struct NewTaskTarget { + ColumnId column; + SwimlaneId lane; + bool operator==(NewTaskTarget const&) const = default; + }; + + [[nodiscard]] morph::async::Completion submitBoardForm(std::string_view actionType, std::string body, + std::function after); + [[nodiscard]] morph::async::Completion> eventsSince( + BoardEventId cursor); + [[nodiscard]] std::optional laneOf(std::int64_t taskId) const; + void followAttach(); + + morph::examples::Wiring _wiring; + BoardOptions _options; + morph::bridge::BridgeHandler _handler; + morph::reactive::Signal> _project; + morph::reactive::Signal _role; + morph::reactive::Signal> _newTask; + morph::reactive::Signal _pollingStopped; + morph::reactive::Query _open; + morph::reactive::Computed> _attached; + morph::reactive::Query _activity; + morph::reactive::Mutation _move; + morph::forms::FormSession _createColumn; + morph::forms::FormSession _createSwimlane; + morph::forms::FormSession _createTask; + std::unique_ptr _offline; + std::optional _pollingProject; + std::unique_ptr _polling; + std::unique_ptr _follow; + std::string _lastMoveOpId; + // Last member, so it is destroyed first: a reply still in flight finds it stopped. + morph::async::CallbackScope _lifetime; +}; + +} // namespace kanban::client +``` + +Create `examples/kanban/app/controllers/board_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/board_controller.hpp" + +#include +#include +#include +#include + +#include "app/completion_map.hpp" +#include "app/uuid.hpp" +#include "support/board_format.hpp" + +#ifdef MORPH_BUILD_OFFLINE_SQLITE +#include "controllers/offline_moves.hpp" +#else +namespace kanban::client { +// Stands in for the offline queue in a build without MORPH_BUILD_OFFLINE_SQLITE; never constructed. +class OfflineMoves {}; +} // namespace kanban::client +#endif + +namespace kanban::client { + +namespace { + +[[nodiscard]] std::string cardLabel(TaskView const& task) { + if (task.tags.empty()) { + return task.title; + } + std::string label = task.title + " ["; + for (std::size_t i = 0; i < task.tags.size(); ++i) { + label += (i == 0 ? "" : ", ") + task.tags.at(i); + } + return label + "]"; +} + +} // namespace + +// Polls the attached board for events and refreshes the board when new ones arrive. One exists per attached +// board; it starts from the beginning of the board's event stream, so its first poll refreshes once. The poller +// applies a page's events one by one; counting them and refreshing from a separate effect refreshes once per page, +// not once per event. +class BoardController::Polling { +public: + explicit Polling(BoardController& board) + : _board{&board}, + _seen{board._wiring.runtime, std::size_t{0}}, + _poller{board._wiring.runtime, + board._wiring.scheduler, + [&board](BoardEventId const& cursor) { return board.eventsSince(cursor); }, + BoardEventId{}, + [this](BoardEvent const&) { _seen.set(_seen.peek() + 1); }, + morph::examples::PollerOptions{.interval = board._options.pollEvery}}, + _onEvents{board._wiring.runtime, [this] { onEvents(); }} {} + + [[nodiscard]] std::size_t seen() const { return _seen.get(); } + +private: + void onEvents() { + std::size_t const count = _seen.get(); + std::exception_ptr const failure = _poller.stoppedBy(); + _board->_wiring.runtime.untracked([&] { + if (count > _refreshedAt) { + _refreshedAt = count; + _board->refresh(); + } + if (failure != nullptr) { + _board->_pollingStopped.set("polling stopped: " + morph::reactive::errorMessage(failure)); + } + }); + } + + BoardController* _board; + morph::reactive::Signal _seen; + std::size_t _refreshedAt = 0; + morph::examples::Poller _poller; + morph::reactive::Effect _onEvents; +}; + +BoardController::BoardController(morph::examples::Wiring wiring, BoardOptions options) + : _wiring{wiring}, + _options{std::move(options)}, + _handler{wiring.bridge, &wiring.callbacks}, + _project{wiring.runtime, std::nullopt}, + _role{wiring.runtime, Role::Viewer}, + _newTask{wiring.runtime, std::nullopt}, + _pollingStopped{wiring.runtime, std::string{}}, + _open{wiring.runtime, _handler, + [this]() -> std::optional { + auto const& project = _project.get(); + if (!project) { + return std::nullopt; + } + return OpenBoard{.projectId = *project}; + }}, + _attached{wiring.runtime, + [this]() -> std::optional { + auto const& current = _open.value(); + if (!current) { + return std::nullopt; + } + return current->projectId; + }}, + _activity{wiring.runtime, _handler, + [this]() -> std::optional { + if (!_open.value()) { + return std::nullopt; + } + return GetActivity{}; + }}, + _move{wiring.runtime, _handler, morph::reactive::MutationOptions{.invalidates = {&_open}}}, + _createColumn{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { + return submitBoardForm(actionType, std::move(body), [this] { _createColumn.reset(); }); + }, + {}}, + _createSwimlane{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { + return submitBoardForm(actionType, std::move(body), [this] { _createSwimlane.reset(); }); + }, + {}}, + _createTask{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { + return submitBoardForm(actionType, std::move(body), [this] { _newTask.set(std::nullopt); }); + }, + {}} { +#ifdef MORPH_BUILD_OFFLINE_SQLITE + if (_options.offline) { + _offline = std::make_unique( + _wiring.runtime, _wiring.callbacks, *_options.offline, + [this](MoveTaskPosition action) { return _handler.execute(std::move(action)); }, [this] { refresh(); }); + } +#endif + _follow = std::make_unique(_wiring.runtime, [this] { followAttach(); }); +} + +BoardController::~BoardController() = default; + +void BoardController::open(ProjectId project, Role myRole) { + _wiring.runtime.batch([&] { + _role.set(myRole); + _pollingStopped.set(std::string{}); + _project.set(project); + }); +} + +void BoardController::close() { + _wiring.runtime.batch([&] { + _newTask.set(std::nullopt); + _project.set(std::nullopt); + }); +} + +void BoardController::refresh() { _open.refetch(); } + +std::string BoardController::title() const { + auto const& current = _open.value(); + return current ? current->name : std::string{"Board"}; +} + +std::string BoardController::roleText() const { return isOpen() ? "role: " + roleLabel(_role.get()) : std::string{}; } + +std::vector BoardController::shapes() const { + auto const& current = _open.value(); + if (!current) { + return {}; + } + BoardShape shape; + shape.key = "columns"; + for (ColumnView const& column : current->columns) { + if (column.id.hasValue()) { + shape.columns.push_back(ColumnHead{.id = *column.id, .name = column.name}); + shape.key += ":" + std::to_string(*column.id); + } + } + shape.key += "|lanes"; + bool const named = current->swimlanes.size() > 1; + for (SwimlaneView const& lane : current->swimlanes) { + if (lane.id.hasValue()) { + shape.lanes.push_back( + LaneHead{.id = *lane.id, .label = named ? lane.name : std::string{}, .canAddTask = true}); + shape.key += ":" + std::to_string(*lane.id); + } + } + if (shape.lanes.empty()) { + shape.lanes.push_back(LaneHead{.id = std::nullopt, .label = std::string{}, .canAddTask = false}); + } + return std::vector{std::move(shape)}; +} + +std::string BoardController::headerOf(std::int64_t columnId) const { + auto const& current = _open.value(); + if (current) { + for (ColumnView const& column : current->columns) { + if (column.id == ColumnId{columnId}) { + return columnHeader(column); + } + } + } + return {}; +} + +std::vector BoardController::cardsIn(std::int64_t columnId, std::optional laneId) const { + std::vector cards; + auto const& current = _open.value(); + if (!current) { + return cards; + } + for (TaskView const& task : current->tasks) { + if (!task.id.hasValue() || task.columnId != ColumnId{columnId}) { + continue; + } + std::int64_t const taskLane = task.swimlaneId.hasValue() ? *task.swimlaneId : 0; + if (laneId && taskLane != *laneId) { + continue; + } + cards.push_back(CardRow{.id = *task.id, + .columnId = columnId, + .laneId = taskLane, + .position = task.position, + .label = cardLabel(task)}); + } + std::ranges::sort(cards, {}, &CardRow::position); + return cards; +} + +std::vector BoardController::activity() const { + std::vector rows; + auto const& listed = _activity.value(); + if (!listed) { + return rows; + } + for (ActivityEvent const& event : listed->events) { + rows.push_back(ActivityRow{.index = static_cast(rows.size()), + .summary = event.summary, + .meta = activityMeta(event)}); + } + return rows; +} + +std::string BoardController::errorText() const { + for (std::exception_ptr const& error : {_open.error(), _move.error(), _activity.error()}) { + if (error != nullptr) { + return morph::reactive::errorMessage(error); + } + } + return _pollingStopped.get(); +} + +std::size_t BoardController::eventsSeen() const { return _polling != nullptr ? _polling->seen() : 0; } + +bool BoardController::online() const { +#ifdef MORPH_BUILD_OFFLINE_SQLITE + if (_offline != nullptr) { + return _offline->online(); + } +#endif + return true; +} + +int BoardController::queueDepth() const { +#ifdef MORPH_BUILD_OFFLINE_SQLITE + if (_offline != nullptr) { + return _offline->queueDepth(); + } +#endif + return 0; +} + +int BoardController::deadLettered() const { +#ifdef MORPH_BUILD_OFFLINE_SQLITE + if (_offline != nullptr) { + return _offline->deadLettered(); + } +#endif + return 0; +} + +int BoardController::syncRuns() const { +#ifdef MORPH_BUILD_OFFLINE_SQLITE + if (_offline != nullptr) { + return _offline->drainsCompleted(); + } +#endif + return 0; +} + +std::string BoardController::syncText() const { return pendingSyncLine(queueDepth()); } + +std::string BoardController::deadLetterText() const { return deadLetterLine(deadLettered()); } + +void BoardController::moveTask(TaskId task, ColumnId column, SwimlaneId lane, std::int64_t position) { + // A fresh op id per gesture: MoveTaskPosition is exactly-once per op id, so a reused id would replay the + // first move's stored result instead of moving. + MoveTaskPosition action{.taskId = task, + .columnId = column, + .swimlaneId = lane, + .position = position, + .opId = morph::examples::newUuid()}; + _lastMoveOpId = action.opId; +#ifdef MORPH_BUILD_OFFLINE_SQLITE + if (_offline != nullptr && !_offline->online()) { + _offline->enqueue(action); + return; + } +#endif + _move.run(std::move(action)); +} + +void BoardController::dropAtEnd(TaskId task, ColumnId column, std::optional laneId) { + if (!task.hasValue() || !column.hasValue()) { + return; + } + std::optional const lane = laneId ? laneId : laneOf(*task); + if (!lane) { + return; + } + std::int64_t position = 0; + for (CardRow const& card : cardsIn(*column, lane)) { + if (card.id != *task) { + ++position; + } + } + moveTask(task, column, SwimlaneId{*lane}, position); +} + +std::optional BoardController::taskOf(std::variant const& key) noexcept { + if (auto const* const taskId = std::get_if(&key); taskId != nullptr && *taskId > 0) { + return TaskId{*taskId}; + } + return std::nullopt; +} + +void BoardController::openNewTask(ColumnId column, SwimlaneId lane) { + if (!column.hasValue() || !lane.hasValue()) { + return; + } + _createTask.reset(); + _createTask.assign("columnId", std::to_string(*column)); + _createTask.assign("swimlaneId", std::to_string(*lane)); + _newTask.set(NewTaskTarget{.column = column, .lane = lane}); +} + +void BoardController::closeNewTask() { _newTask.set(std::nullopt); } + +std::string BoardController::newTaskTitle() const { + auto const& target = _newTask.get(); + auto const& current = _open.value(); + if (target && current) { + for (ColumnView const& column : current->columns) { + if (column.id == target->column) { + return "New task in " + column.name; + } + } + } + return "New task"; +} + +morph::async::Completion BoardController::submitBoardForm(std::string_view actionType, std::string body, + std::function after) { + return morph::examples::mapCompletion( + _wiring.callbacks, _lifetime.token(), _handler.executeJson(actionType, body), + [this, after = std::move(after)](std::string const& reply) { + after(); + _open.refetch(); + return reply; + }, + [](std::exception_ptr const&) {}); +} + +morph::async::Completion> BoardController::eventsSince( + BoardEventId cursor) { + using Page = morph::examples::PollPage; + return morph::examples::mapCompletion( + _wiring.callbacks, _lifetime.token(), _handler.execute(GetEventsSince{.lastEventId = cursor}), + [cursor](GetEventsSinceResult const& result) { + return Page{.events = result.events, .next = result.events.empty() ? cursor : result.events.back().id}; + }, + [](std::exception_ptr const&) {}); +} + +std::optional BoardController::laneOf(std::int64_t taskId) const { + auto const& current = _open.value(); + if (current) { + for (TaskView const& task : current->tasks) { + if (task.id == TaskId{taskId} && task.swimlaneId.hasValue()) { + return *task.swimlaneId; + } + } + } + return std::nullopt; +} + +void BoardController::followAttach() { + std::optional const attached = _attached.get(); + _wiring.runtime.untracked([&] { + if (!attached) { + _polling.reset(); + _pollingProject.reset(); + return; + } + if (_polling != nullptr && _pollingProject == attached) { + return; + } + _polling.reset(); + _polling = std::make_unique(*this); + _pollingProject = attached; + }); +} + +} // namespace kanban::client +``` + +`followAttach` keys on `_attached`, an equality-gated `Computed` of the board's project: a refresh delivers a new +board but the same project, so polling is not restarted by every refresh — only by a different project or a close. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client]" +``` + +Expected: PASS (the board, polling and socket cases included). + +Mutation checks, one at a time, each restored afterwards: +- In `followAttach`, delete the `_polling.reset();` of the `!attached` branch. Expected FAIL in "closing the board + stops polling" (`pendingTimers()` stays non-zero). +- In `Polling::onEvents`, delete `_board->refresh();`. Expected FAIL in "another client's move reaches an open board + on the next poll". +- In `moveTask`, replace `morph::examples::newUuid()` with `std::string{"op"}`. Expected FAIL in "moveTask moves a + card, every move with a fresh op id" (the second move replays the first's stored result; the card stays in Done). + +- [ ] **Step 5: Commit** + +```bash +git add examples/kanban/app/controllers/board_controller.hpp examples/kanban/app/controllers/board_controller.cpp \ + examples/kanban/tests/client/board_support.hpp examples/kanban/tests/client/test_board_controller.cpp \ + examples/kanban/tests/client/test_board_polling.cpp examples/kanban/tests/client/test_board_socket.cpp +git commit -m "wip(kanban): BoardController: attach, board forms, cards, moves with fresh op ids, polling + +Signed-off-by: Christian Parpart " +``` + +--- +### Task K7: Concurrent drag over `BoardController` + +Re-expresses `tests/test_board_concurrent_drag.cpp`: four clients move their own tasks between two columns, every +move fired before any is awaited. The model proves exactly-once and dense positions for raw `MoveTaskPosition` +(`test_kanban_stress.cpp`); this proves the client's path — an op id minted per gesture, one `Mutation` per +controller, invalidation refetches racing each other — keeps both, and that no two of the 48 gestures shared an +op id (the hazard the old file named: a per-call value stashed in a shared field). + +**Files:** +- Modify (rewrite): `examples/kanban/tests/test_board_concurrent_drag.cpp` +- Test: the same file + +**Interfaces:** +- Consumes: `BoardController::moveTask`, `movesPending`, `lastMoveOpId`, `openNewTask`, `createTaskForm`, `refresh`, + `loading`, `board` (Task K6); `LocalClient`, `signedContext`, `seedProject`, `submitForm`, `seedBoard` (Tasks K3, + K6). +- Produces: nothing new. + +- [ ] **Step 1: Write the failing test** + +Replace the whole of `examples/kanban/tests/test_board_concurrent_drag.cpp` with: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// Four board controllers on one bridge move their own tasks concurrently, each move under its own op id, nothing +// awaited until every move is fired. The model's own guarantees (exactly-once per op id, dense positions per +// column) are proven for raw MoveTaskPosition calls in test_kanban_stress.cpp; this proves the client's path keeps +// them — and mints a distinct op id for every gesture, which is what exactly-once needs from a client. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "client/board_support.hpp" +#include "controllers/board_controller.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +[[nodiscard]] bool positionsAreDenseAndUnique(kanban::GetBoardResult const& board) { + for (kanban::ColumnView const& column : board.columns) { + std::vector positions; + for (kanban::TaskView const& task : board.tasks) { + if (task.columnId == column.id) { + positions.push_back(task.position); + } + } + std::ranges::sort(positions); + for (std::size_t i = 0; i < positions.size(); ++i) { + if (positions.at(i) != static_cast(i)) { + return false; + } + } + } + return true; +} + +[[nodiscard]] std::int64_t taskTitled(kanban::GetBoardResult const& board, std::string_view title) { + for (kanban::TaskView const& task : board.tasks) { + if (task.title == title && task.id.hasValue()) { + return *task.id; + } + } + FAIL("the board has no task titled " << title); + return 0; +} + +} // namespace + +TEST_CASE("Concurrent BoardController::moveTask calls (N=4) never desync positions", "[kanban][client][stress]") { + morph::ladder::testkit::DbFixture const fixture; + kanban::testing::LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Stress Board"); + constexpr std::size_t kClients = 4; + constexpr int kMovesPerClient = 12; + std::vector> boards; + for (std::size_t i = 0; i < kClients; ++i) { + boards.push_back(std::make_unique(client.wiring())); + } + auto& seeder = *boards.front(); + auto const seeded = kanban::testing::seedBoard(client, seeder, project); + std::vector taskIds{seeded.task}; + for (std::size_t i = 1; i < kClients; ++i) { + std::int64_t const column = (i % 2 == 0) ? seeded.todo : seeded.done; + std::string const title = "Task " + std::to_string(i); + seeder.openNewTask(kanban::ColumnId{column}, kanban::SwimlaneId{seeded.lane}); + kanban::testing::submitForm(client, seeder.createTaskForm(), R"({"title":")" + title + R"("})"); + REQUIRE(client.settle([&] { return seeder.board() && seeder.board()->tasks.size() == i + 1; })); + taskIds.push_back(taskTitled(*seeder.board(), title)); + } + for (std::size_t i = 1; i < kClients; ++i) { + boards.at(i)->open(project, kanban::Role::Manager); + REQUIRE(client.settle([&] { return boards.at(i)->board() && boards.at(i)->board()->tasks.size() == kClients; })); + } + + std::vector const columns{seeded.todo, seeded.done}; + std::set opIds; + for (std::size_t i = 0; i < kClients; ++i) { + for (int move = 0; move < kMovesPerClient; ++move) { + auto const position = (move * 3 + static_cast(i)) % static_cast(kClients); + boards.at(i)->moveTask(kanban::TaskId{taskIds.at(i)}, + kanban::ColumnId{columns.at(static_cast(move) % columns.size())}, + kanban::SwimlaneId{seeded.lane}, position); + opIds.insert(boards.at(i)->lastMoveOpId()); + } + } + REQUIRE(client.settle( + [&boards] { return std::ranges::none_of(boards, [](auto const& board) { return board->movesPending(); }); }, + std::chrono::milliseconds{20000})); + + CHECK(opIds.size() == kClients * static_cast(kMovesPerClient)); + seeder.refresh(); + REQUIRE(client.settle([&seeder] { return !seeder.loading(); }, std::chrono::milliseconds{20000})); + REQUIRE(seeder.board().has_value()); + auto const& finalBoard = *seeder.board(); + CHECK(positionsAreDenseAndUnique(finalBoard)); + REQUIRE(finalBoard.tasks.size() == taskIds.size()); + for (std::int64_t const taskId : taskIds) { + CHECK(std::ranges::count_if(finalBoard.tasks, [taskId](kanban::TaskView const& task) { + return task.id == kanban::TaskId{taskId}; + }) == 1); + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Before Task K6 this file did not compile against `BoardController`; with Task K6 in place, run the mutation first: +in `BoardController::moveTask` replace `morph::examples::newUuid()` with `std::string{"op"}`, then + +```bash +cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "Concurrent BoardController::moveTask calls (N=4) never desync positions" +``` + +Expected: FAIL at `CHECK(opIds.size() == …)` (one op id for 48 gestures). Restore `newUuid()`. + +- [ ] **Step 3: Implement** + +No production change: the test pins Task K6's behaviour under concurrency. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client][stress]" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/kanban/tests/test_board_concurrent_drag.cpp +git commit -m "wip(kanban): concurrent drag re-expressed over BoardController + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task K8: The board's offline queue, end to end + +Re-expresses `tests/test_board_offline_bridge.cpp` over `BoardController` with `BoardOptions::offline`: a move +made while the probe says "down" is queued and not sent, and replays when it says "up"; a move the server keeps +refusing (a full WIP column) is dead-lettered after its retry budget; and the reconnect path emits the +framework's offline metrics. + +**Files:** +- Create: `examples/kanban/tests/client/test_board_offline.cpp` +- Test: the same file + +**Interfaces:** +- Consumes: `BoardOptions{offline}`, `OfflineConfig`, `BoardController::online`, `queueDepth`, `deadLettered`, + `syncRuns`, `deadLetterText`, `moveTask`, `movesPending`, `cardsIn` (Tasks K5, K6); `ScopedQueueFile` (Task K5); + `morph::observe::ScopedObserveOverride`, `setMetricSink`, `Metric` (`morph/core/observability.hpp`). +- Produces: nothing new. + +- [ ] **Step 1: Write the failing test** + +Create `examples/kanban/tests/client/test_board_offline.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#ifdef MORPH_BUILD_OFFLINE_SQLITE + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "board_support.hpp" +#include "controllers/board_controller.hpp" +#include "kanban_client_support.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using namespace std::chrono_literals; +using kanban::ColumnId; +using kanban::SwimlaneId; +using kanban::TaskId; +using kanban::client::BoardController; +using kanban::client::BoardOptions; +using kanban::client::OfflineConfig; +using kanban::testing::LocalClient; + +[[nodiscard]] BoardOptions offlineOptions(std::filesystem::path const& queuePath, std::atomic& reachable) { + return BoardOptions{.offline = OfflineConfig{.queuePath = queuePath, + .probe = [&reachable] { return reachable.load(); }, + .probeInterval = 20ms, + .failureThreshold = 1, + .onlineThreshold = 1}}; +} + +} // namespace + +TEST_CASE("kanban::client::BoardController: a move made while offline is queued, not sent, and replays on reconnect", + "[kanban][client][offline]") { + morph::ladder::testkit::DbFixture const fixture; + kanban::testing::ScopedQueueFile const queueFile{"board_offline_replay"}; + std::atomic reachable{true}; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Offline Board"); + BoardController board{client.wiring(), offlineOptions(queueFile.path(), reachable)}; + auto const seeded = kanban::testing::seedBoard(client, board, project); + + board.moveTask(TaskId{seeded.task}, ColumnId{seeded.done}, SwimlaneId{seeded.lane}, 0); + REQUIRE(client.settle([&] { return board.cardsIn(seeded.done, seeded.lane).size() == 1 && !board.movesPending(); })); + + reachable.store(false); + REQUIRE(client.settle([&board] { return !board.online(); }, 2000ms)); + board.moveTask(TaskId{seeded.task}, ColumnId{seeded.todo}, SwimlaneId{seeded.lane}, 0); + REQUIRE(client.settle([&board] { return board.queueDepth() == 1; }, 2000ms)); + CHECK_FALSE(board.movesPending()); + CHECK(board.cardsIn(seeded.done, seeded.lane).size() == 1); + CHECK(board.syncText() == "1 changes pending sync"); + CHECK(board.deadLettered() == 0); + + reachable.store(true); + REQUIRE(client.settle( + [&] { return board.queueDepth() == 0 && board.cardsIn(seeded.todo, seeded.lane).size() == 1; }, 5000ms)); + CHECK(board.syncText().empty()); +} + +TEST_CASE("kanban::client::BoardController: a queued move the server keeps refusing is dead-lettered", + "[kanban][client][offline]") { + morph::ladder::testkit::DbFixture const fixture; + kanban::testing::ScopedQueueFile const queueFile{"board_offline_dead_letter"}; + std::atomic reachable{true}; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Offline Board"); + BoardController board{client.wiring(), offlineOptions(queueFile.path(), reachable)}; + auto const seeded = kanban::testing::seedBoard(client, board, project, /*doneWipLimit=*/1); + board.openNewTask(ColumnId{seeded.done}, SwimlaneId{seeded.lane}); + kanban::testing::submitForm(client, board.createTaskForm(), R"({"title":"Blocker"})"); + REQUIRE(client.settle([&] { return board.cardsIn(seeded.done, seeded.lane).size() == 1; })); + + reachable.store(false); + REQUIRE(client.settle([&board] { return !board.online(); }, 2000ms)); + board.moveTask(TaskId{seeded.task}, ColumnId{seeded.done}, SwimlaneId{seeded.lane}, 0); + REQUIRE(client.settle([&board] { return board.queueDepth() == 1; }, 2000ms)); + + for (int flap = 0; flap < 8 && board.deadLettered() == 0; ++flap) { + int const runs = board.syncRuns(); + reachable.store(true); + REQUIRE(client.settle([&] { return board.syncRuns() > runs; }, 5000ms)); + reachable.store(false); + REQUIRE(client.settle([&board] { return !board.online(); }, 2000ms)); + } + + REQUIRE(client.settle([&board] { return board.deadLettered() == 1 && board.queueDepth() == 0; }, 2000ms)); + CHECK(board.deadLetterText() == "1 changes could not be synced"); + CHECK(board.cardsIn(seeded.todo, seeded.lane).size() == 1); +} + +TEST_CASE("kanban::client::BoardController: the offline path emits the framework's offline metrics", + "[kanban][client][offline]") { + morph::ladder::testkit::DbFixture const fixture; + kanban::testing::ScopedQueueFile const queueFile{"board_offline_metrics"}; + std::atomic reachable{true}; + morph::observe::ScopedObserveOverride const observeOverride; + std::vector observed; + std::mutex observedMutex; + morph::observe::setMetricSink([&](morph::observe::MetricEvent const& event) { + std::scoped_lock const lock{observedMutex}; + observed.push_back(event.metric); + }); + auto const seen = [&](morph::observe::Metric metric) { + std::scoped_lock const lock{observedMutex}; + return std::ranges::find(observed, metric) != observed.end(); + }; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Offline Board"); + BoardController board{client.wiring(), offlineOptions(queueFile.path(), reachable)}; + auto const seeded = kanban::testing::seedBoard(client, board, project); + + reachable.store(false); + REQUIRE(client.settle([&board] { return !board.online(); }, 2000ms)); + board.moveTask(TaskId{seeded.task}, ColumnId{seeded.done}, SwimlaneId{seeded.lane}, 0); + REQUIRE(client.settle([&board] { return board.queueDepth() == 1; }, 2000ms)); + reachable.store(true); + REQUIRE(client.settle([&] { return board.queueDepth() == 0 && board.cardsIn(seeded.done, seeded.lane).size() == 1; }, + 5000ms)); + + CHECK(seen(morph::observe::Metric::queueDepth)); + CHECK(seen(morph::observe::Metric::reconnectAttempts)); + CHECK(seen(morph::observe::Metric::reconnectOutcome)); +} + +#endif // MORPH_BUILD_OFFLINE_SQLITE +``` + +- [ ] **Step 2: Run it to verify it fails** + +Apply the mutation first (Tasks K5/K6 already implement the behaviour): in `BoardController::moveTask` delete the +`#ifdef MORPH_BUILD_OFFLINE_SQLITE` branch that enqueues while offline. Then: + +```bash +cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client][offline]" +``` + +Expected: FAIL in "a move made while offline is queued, not sent, and replays on reconnect" — `queueDepth() == 1` +never holds, because the move went straight out. Restore the branch. + +- [ ] **Step 3: Implement** + +No production change. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client][offline]" +``` + +Expected: PASS (6 cases: Task K5's three and these three). + +- [ ] **Step 5: Commit** + +```bash +git add examples/kanban/tests/client/test_board_offline.cpp +git commit -m "wip(kanban): the board's offline queue end to end over BoardController + +Signed-off-by: Christian Parpart " +``` + +--- +### Task K9: `RulesController` — the rules dialog + +Re-expresses `BoardBridge::createRule`/`getRules`/`deleteRule`/`fetchOptions` and `RulesView.qml`: the rules are a +`Query` keyed on the dialog being shown and the attached project, on the board's own handler (rules belong +to the attached instance); the `CreateRule` form's `projectId` is assigned as context; its +`triggerColumnId` Choice fetches its options (`GetBoardState`) through `forms::handlerChoiceFetcher` over that same +handler, so an options action the board model does not serve is refused by name; Remove is a `Mutation` +invalidating the list. + +**Files:** +- Create: `examples/kanban/app/controllers/rules_controller.hpp`, `examples/kanban/app/controllers/rules_controller.cpp` +- Test: `examples/kanban/tests/client/test_rules_controller.cpp` + +**Interfaces:** +- Consumes: `BoardController::handler()`, `attachedProject()`, `board()` (Task K6); `ruleLine` (Task K2); + `morph::examples::mapCompletion` (Part 6); `forms::FormModel::forAction()`, `ChoiceFetcher`, + `handlerChoiceFetcher` (Part 5, ``). +- Produces: `kanban::client::RuleRow{id, text}`; `RulesController(examples::Wiring, BoardController&)`: `show()`, `hide()`, + `shown()`, `rules()`, `remove(RuleId)`, `createRuleForm()`, `errorText()`, + `fetchOptions(std::string_view optionsAction, std::string body) -> Completion`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/kanban/tests/client/test_rules_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "board_support.hpp" +#include "controllers/board_controller.hpp" +#include "controllers/rules_controller.hpp" +#include "kanban_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using kanban::client::BoardController; +using kanban::client::RulesController; +using kanban::testing::LocalClient; +using morph::ladder::testkit::DbFixture; + +TEST_CASE("kanban::client::RulesController: a rule created through the form lists by column name; Remove deletes it", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + auto const seeded = kanban::testing::seedBoard(client, board, project); + RulesController rules{client.wiring(), board}; + CHECK_FALSE(rules.shown()); + CHECK(rules.rules().empty()); + + rules.show(); + kanban::testing::submitForm(client, rules.createRuleForm(), + R"({"triggerColumnId":)" + std::to_string(seeded.done) + + R"(,"mutationType":"AddTag","mutationValue":"shipped"})"); + + REQUIRE(client.settle([&rules] { return rules.rules().size() == 1; })); + CHECK(rules.rules().front().text == R"(when moved to "Done": AddTag "shipped")"); + + rules.hide(); + REQUIRE(client.settle([&rules] { return rules.rules().empty(); })); + rules.show(); + REQUIRE(client.settle([&rules] { return rules.rules().size() == 1; })); + + rules.remove(kanban::RuleId{rules.rules().front().id}); + REQUIRE(client.settle([&rules] { return rules.rules().empty(); })); + CHECK(rules.errorText().empty()); +} + +TEST_CASE("kanban::client::RulesController: the trigger column's options come from the attached board's handler", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + auto const seeded = kanban::testing::seedBoard(client, board, project); + RulesController rules{client.wiring(), board}; + + std::string const options = kanban::testing::await(client, rules.fetchOptions("GetBoardState", "{}")); + CHECK(options.find(std::to_string(seeded.todo)) != std::string::npos); + CHECK(options.find(std::to_string(seeded.done)) != std::string::npos); + + // A project-admin action: the board's handler does not serve it, so the fetcher refuses it by name. + CHECK_THROWS_WITH(kanban::testing::await(client, rules.fetchOptions("GetMyProjects", "{}")), + Catch::Matchers::ContainsSubstring("GetMyProjects")); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_kanban_tests` +Expected: compile error, `'controllers/rules_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/kanban/app/controllers/rules_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/board_controller.hpp" + +namespace kanban::client { + +/// @brief One automation rule in the rules dialog. +struct RuleRow { + /// @brief The rule's id. + std::int64_t id = 0; + /// @brief Its display line. + std::string text; + /// @brief Compared by value. + bool operator==(RuleRow const&) const = default; +}; + +/// @brief The rules dialog: the attached board's automation rules, the `CreateRule` form and Remove. +class RulesController { +public: + /// @param wiring The runtime and owner this controller uses. + /// @param board The board whose attached instance owns the rules. Borrowed: it must outlive this controller. + RulesController(morph::examples::Wiring wiring, BoardController& board); + + /// @brief Opens the dialog; the rules are fetched and the form's project is filled in. + void show(); + /// @brief Closes the dialog; the rules go idle. + void hide(); + /// @brief Whether the dialog is open. Tracked. + /// @return True between `show` and `hide`. + [[nodiscard]] bool shown() const { return _shown.get(); } + /// @brief The rules, in creation order. Tracked. + /// @return One row per rule; empty while hidden. + [[nodiscard]] std::vector rules() const; + /// @brief Deletes a rule; the list refreshes when it lands. + /// @param rule The rule. + void remove(RuleId rule); + /// @brief The `CreateRule` form. + /// @return The form session. + [[nodiscard]] morph::forms::FormSession& createRuleForm() noexcept { return _createRule; } + /// @brief The first failure of the list or a removal. Tracked. + /// @return The message, or empty. + [[nodiscard]] std::string errorText() const; + /// @brief The `CreateRule` form's `ChoiceFetcher`: runs the options action on the attached board's handler, and + /// refuses by name an action that handler does not serve. + /// @param optionsAction The options action the schema names. + /// @param body Its JSON body. + /// @return The options reply, owned by the wiring's owner. + [[nodiscard]] morph::async::Completion fetchOptions(std::string_view optionsAction, std::string body) { + return _boardOptions(optionsAction, std::move(body)); + } + +private: + [[nodiscard]] morph::async::Completion submitRule(std::string_view actionType, std::string body); + void assignProject(); + + morph::examples::Wiring _wiring; + BoardController* _board; + morph::reactive::Signal _shown; + morph::reactive::Query _rules; + morph::reactive::Mutation _delete; + morph::forms::ChoiceFetcher _boardOptions; + morph::forms::FormSession _createRule; + // Last member, so it is destroyed first: a reply still in flight finds it stopped. + morph::async::CallbackScope _lifetime; +}; + +} // namespace kanban::client +``` + +Create `examples/kanban/app/controllers/rules_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/rules_controller.hpp" + +#include +#include +#include +#include + +#include "app/completion_map.hpp" +#include "support/board_format.hpp" + +namespace kanban::client { + +RulesController::RulesController(morph::examples::Wiring wiring, BoardController& board) + : _wiring{wiring}, + _board{&board}, + _shown{wiring.runtime, false}, + _rules{wiring.runtime, board.handler(), + [this]() -> std::optional { + if (!_shown.get()) { + return std::nullopt; + } + std::optional const project = _board->attachedProject(); + if (!project) { + return std::nullopt; + } + return GetRules{.projectId = *project}; + }}, + _delete{wiring.runtime, board.handler(), morph::reactive::MutationOptions{.invalidates = {&_rules}}}, + _boardOptions{morph::forms::handlerChoiceFetcher(wiring.callbacks, board.handler())}, + _createRule{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { + return submitRule(actionType, std::move(body)); + }, + _boardOptions} {} + +void RulesController::show() { + _shown.set(true); + _createRule.reset(); + assignProject(); +} + +void RulesController::hide() { _shown.set(false); } + +std::vector RulesController::rules() const { + std::vector rows; + auto const& listed = _rules.value(); + if (!listed) { + return rows; + } + auto const& board = _board->board(); + std::vector const noColumns; + std::vector const& columns = board ? board->columns : noColumns; + for (RuleView const& rule : listed->rules) { + if (rule.id.hasValue()) { + rows.push_back(RuleRow{.id = *rule.id, .text = ruleLine(rule, columns)}); + } + } + return rows; +} + +void RulesController::remove(RuleId rule) { _delete.run(DeleteRule{.ruleId = rule}); } + +std::string RulesController::errorText() const { + for (std::exception_ptr const& error : {_rules.error(), _delete.error()}) { + if (error != nullptr) { + return morph::reactive::errorMessage(error); + } + } + return {}; +} + +morph::async::Completion RulesController::submitRule(std::string_view actionType, std::string body) { + return morph::examples::mapCompletion( + _wiring.callbacks, _lifetime.token(), _board->handler().executeJson(actionType, body), + [this](std::string const& reply) { + _createRule.reset(); + assignProject(); + _rules.refetch(); + return reply; + }, + [](std::exception_ptr const&) {}); +} + +void RulesController::assignProject() { + std::optional const project = _board->attachedProject(); + if (project && project->hasValue()) { + _createRule.assign("projectId", std::to_string(**project)); + } +} + +} // namespace kanban::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client]" +``` + +Expected: PASS. + +Mutation check: in `submitRule`'s success hook delete `_rules.refetch();`. Expected FAIL in "a rule created through +the form lists by column name…" (the list never gains the rule). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/kanban/app/controllers/rules_controller.hpp examples/kanban/app/controllers/rules_controller.cpp \ + examples/kanban/tests/client/test_rules_controller.cpp +git commit -m "wip(kanban): RulesController: the rules dialog on the attached board + +Signed-off-by: Christian Parpart " +``` + +--- +### Task K10: `IAttachmentTransfer` and the HTTP/1.1 client over POSIX sockets + +Attachments move bytes over the HTTP side channel and metadata through actions (`kanban/dto/attachment_dto.hpp`). +The bytes part becomes an injected interface: upload a local file, download a stored blob to a local file. Listing is +metadata — `GetAttachments` through the bridge (Task K11) — because the side channel has no list route +(`src/server/include/kanban/http/attachment_server.hpp`: exactly `POST /attachments` and `GET /attachments/{storageKey}`). The TUI's +implementation is a minimal blocking HTTP/1.1 client on a worker thread, POSIX sockets only; on Windows it reports +itself unavailable and every request fails with the reason, so the view disables attachments there. The Qt Quick +implementation is Task K13's. + +**Files:** +- Create: `examples/kanban/app/attachments/attachment_transfer.hpp`, `examples/kanban/app/attachments/attachment_transfer.cpp` +- Create: `examples/kanban/app/http/http_client.hpp`, `examples/kanban/app/http/http_client.cpp` +- Create: `examples/kanban/app/http/http_attachment_transfer.hpp`, `examples/kanban/app/http/http_attachment_transfer.cpp` +- Create: `examples/kanban/tests/client/qt_client_support.hpp` +- Test: `examples/kanban/tests/client/test_http_client.cpp`, `examples/kanban/tests/client/test_http_attachment_transfer.cpp` + +**Interfaces:** +- Consumes: `Completion::makeSettleable`, `Promise::resolve/reject` (any thread); `exec::ThreadPoolExecutor`; + `kanban::http::AttachmentServer(TokenVerifier const&, Config)`, `listen()`, `port()` (Task K1); + `morph::ladder::testkit::pumpUntil`, `awaitQt` (`testkit/pump.hpp`). +- Produces: + - `kanban::client::UploadedBlob{storageKey, filename, contentType, sizeBytes}`, `UploadRequest{localPath, + bearerToken}`, `DownloadRequest{storageKey, localPath, bearerToken}`; `IAttachmentTransfer` with `available()`, + `upload(UploadRequest) -> Completion`, `download(DownloadRequest) -> Completion` (the + written path); `contentTypeFor(path)`, `localPathFrom(std::string_view)`, + `attachmentServerFor(std::string_view serverUrl) -> std::optional`. + - `kanban::client::http::{Endpoint, Header, Request, Response, HttpError, parseUrl, parseResponse, formatRequest, + socketsAvailable, send}`. + - `kanban::client::HttpAttachmentTransfer(std::string const& baseUrl, exec::IExecutor& callbacks)`. + - Test support: `kanban::testing::QtClient` (as `LocalClient`, over a `QtExecutor`, settling with the Qt pump), + `seedProject(QtClient&, name)`, `freshStorageDir(name)`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/kanban/tests/client/qt_client_support.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "kanban/models/project_admin_model.hpp" +#include "kanban_client_support.hpp" +#include "testkit/pump.hpp" + +namespace kanban::testing { + +/// One client whose owner is the Qt event loop: for tests whose subject also needs that loop (the attachment +/// server). Declare it before the controllers under test. +class QtClient { +public: + explicit QtClient(morph::session::Context session) { bridge.setDefaultSession(std::move(session)); } + ~QtClient() = default; + QtClient(QtClient const&) = delete; + QtClient& operator=(QtClient const&) = delete; + QtClient(QtClient&&) = delete; + QtClient& operator=(QtClient&&) = delete; + + /// Pumps the Qt event loop until @p done holds or the budget runs out. + template + [[nodiscard]] bool settle(Pred done, std::chrono::milliseconds budget = 5000ms) { + return morph::ladder::testkit::pumpUntil(std::move(done), budget); + } + + /// What a controller under test is built from. + [[nodiscard]] morph::examples::Wiring wiring() { + return morph::examples::Wiring{ + .runtime = runtime, .scheduler = scheduler, .bridge = bridge, .callbacks = owner}; + } + + morph::exec::ThreadPoolExecutor pool{4}; + morph::qt::QtExecutor owner; + morph::bridge::Bridge bridge{std::make_unique(pool), owner}; + morph::reactive::Runtime runtime{owner}; + morph::reactive::testing::ManualScheduler scheduler; +}; + +/// Creates a project as the client's principal. +[[nodiscard]] inline kanban::ProjectId seedProject(QtClient& client, std::string name) { + morph::bridge::BridgeHandler admin{client.bridge, &client.owner}; + return await(client, admin.execute(kanban::CreateProject{.name = std::move(name)})).id; +} + +/// An empty attachment storage directory under the temp directory. +[[nodiscard]] inline std::filesystem::path freshStorageDir(std::string const& name) { + auto path = std::filesystem::temp_directory_path() / ("kanban_client_attachments_" + name); + std::filesystem::remove_all(path); + return path; +} + +} // namespace kanban::testing +``` + +Create `examples/kanban/tests/client/test_http_client.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "attachments/attachment_transfer.hpp" +#include "http/http_client.hpp" + +namespace http = kanban::client::http; + +TEST_CASE("kanban::client::http::parseUrl reads http URLs and refuses everything else", "[kanban][client][http]") { + auto const local = http::parseUrl("http://127.0.0.1:8769"); + REQUIRE(local.has_value()); + CHECK(local->host == "127.0.0.1"); + CHECK(local->port == "8769"); + auto const defaulted = http::parseUrl("http://example.org/ignored/path"); + REQUIRE(defaulted.has_value()); + CHECK(defaulted->host == "example.org"); + CHECK(defaulted->port == "80"); + CHECK_FALSE(http::parseUrl("https://example.org").has_value()); + CHECK_FALSE(http::parseUrl("http://").has_value()); + CHECK_FALSE(http::parseUrl("http://host:notaport").has_value()); +} + +TEST_CASE("kanban::client::http::parseResponse reads a complete response and refuses a short or garbled one", + "[kanban][client][http]") { + std::string const raw = + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\ncontent-length: 18\r\nConnection: close\r\n\r\n" + R"({"storageKey":"k"})"; + auto const response = http::parseResponse(raw); + REQUIRE(response.has_value()); + CHECK(response->status == 200); + CHECK(response->body == R"({"storageKey":"k"})"); + CHECK_FALSE(http::parseResponse(raw.substr(0, raw.size() - 1)).has_value()); + CHECK_FALSE(http::parseResponse("not http at all\r\n\r\n").has_value()); + auto const notFound = http::parseResponse("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + REQUIRE(notFound.has_value()); + CHECK(notFound->status == 404); + CHECK(notFound->body.empty()); +} + +TEST_CASE("kanban::client::http::formatRequest writes the request line, Host, length, headers and body", + "[kanban][client][http]") { + std::string const wire = http::formatRequest( + http::Endpoint{.host = "127.0.0.1", .port = "8769"}, + http::Request{.method = "POST", + .target = "/attachments", + .headers = {http::Header{.name = "Authorization", .value = "Bearer t"}}, + .body = "bytes"}); + CHECK(wire.starts_with("POST /attachments HTTP/1.1\r\n")); + CHECK(wire.find("\r\nHost: 127.0.0.1:8769\r\n") != std::string::npos); + CHECK(wire.find("\r\nContent-Length: 5\r\n") != std::string::npos); + CHECK(wire.find("\r\nAuthorization: Bearer t\r\n") != std::string::npos); + CHECK(wire.ends_with("\r\n\r\nbytes")); +} + +TEST_CASE("kanban::client::contentTypeFor and localPathFrom", "[kanban][client][http]") { + CHECK(kanban::client::contentTypeFor("notes.TXT") == "text/plain"); + CHECK(kanban::client::contentTypeFor("report.pdf") == "application/pdf"); + CHECK(kanban::client::contentTypeFor("blob") == "application/octet-stream"); + CHECK(kanban::client::localPathFrom("/tmp/a.txt") == std::filesystem::path{"/tmp/a.txt"}); + CHECK(kanban::client::localPathFrom("file:///tmp/a.txt") == std::filesystem::path{"/tmp/a.txt"}); + CHECK(kanban::client::localPathFrom("file:///C:/data/a.txt") == std::filesystem::path{"C:/data/a.txt"}); +} + +TEST_CASE("kanban::client::attachmentServerFor names the side channel on the server's host", "[kanban][client][http]") { + CHECK(kanban::client::attachmentServerFor("ws://10.0.0.5:8765") == + std::optional{"http://10.0.0.5:8769"}); + CHECK(kanban::client::attachmentServerFor("wss://example.org/board") == + std::optional{"http://example.org:8769"}); + CHECK_FALSE(kanban::client::attachmentServerFor("not a url").has_value()); + CHECK_FALSE(kanban::client::attachmentServerFor("ws://:8765").has_value()); +} +``` + +Create `examples/kanban/tests/client/test_http_attachment_transfer.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#include "http/http_attachment_transfer.hpp" +#include "kanban/http/attachment_server.hpp" +#include "kanban_client_support.hpp" +#include "qt_client_support.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +using kanban::client::DownloadRequest; +using kanban::client::HttpAttachmentTransfer; +using kanban::client::UploadRequest; +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::DbFixture; + +namespace { + +[[nodiscard]] std::filesystem::path writeFile(std::filesystem::path const& dir, std::string const& name, + std::string const& bytes) { + std::filesystem::create_directories(dir); + auto const path = dir / name; + std::ofstream{path, std::ios::binary} << bytes; + return path; +} + +} // namespace + +TEST_CASE("kanban::client::HttpAttachmentTransfer uploads a file to the side channel", "[kanban][client][http]") { + DbFixture const fixture; + morph::session::TokenVerifier const verifier{std::string{kanban::testing::kSecret}, morph::session::hmacSha256}; + auto const storage = kanban::testing::freshStorageDir("http_upload"); + kanban::http::AttachmentServer server{verifier, kanban::http::AttachmentServer::Config{.storageDir = storage}}; + REQUIRE(server.listen()); + morph::qt::QtExecutor owner; + HttpAttachmentTransfer transfer{"http://127.0.0.1:" + std::to_string(server.port()), owner}; + REQUIRE(transfer.available()); + auto const source = writeFile(storage.parent_path() / "kanban_client_upload_src", "report.txt", "attachment bytes"); + + auto const blob = awaitQt( + transfer.upload(UploadRequest{.localPath = source, .bearerToken = kanban::testing::signedContext("alice").token})); + + CHECK(blob.filename == "report.txt"); + CHECK(blob.contentType == "text/plain"); + CHECK(blob.sizeBytes == 16); + CHECK(blob.storageKey.size() == 64); + CHECK(std::filesystem::exists(storage / blob.storageKey)); + std::filesystem::remove_all(storage); +} + +TEST_CASE("kanban::client::HttpAttachmentTransfer: the side channel's refusals come back as failures", + "[kanban][client][http]") { + DbFixture const fixture; + morph::session::TokenVerifier const verifier{std::string{kanban::testing::kSecret}, morph::session::hmacSha256}; + auto const storage = kanban::testing::freshStorageDir("http_refusals"); + kanban::http::AttachmentServer server{verifier, kanban::http::AttachmentServer::Config{.storageDir = storage}}; + REQUIRE(server.listen()); + morph::qt::QtExecutor owner; + HttpAttachmentTransfer transfer{"http://127.0.0.1:" + std::to_string(server.port()), owner}; + auto const source = writeFile(storage.parent_path() / "kanban_client_refusal_src", "a.txt", "x"); + auto const target = storage.parent_path() / "kanban_client_refusal_out.txt"; + std::filesystem::remove(target); + + CHECK_THROWS_WITH(awaitQt(transfer.upload(UploadRequest{.localPath = source, .bearerToken = "forged"})), + Catch::Matchers::ContainsSubstring("HTTP 401")); + CHECK_THROWS_WITH(awaitQt(transfer.download(DownloadRequest{.storageKey = std::string(64, 'a'), + .localPath = target, + .bearerToken = + kanban::testing::signedContext("alice").token})), + Catch::Matchers::ContainsSubstring("HTTP 404")); + CHECK_FALSE(std::filesystem::exists(target)); + CHECK_THROWS_WITH(awaitQt(transfer.download(DownloadRequest{.storageKey = "../../etc/passwd", + .localPath = target, + .bearerToken = "t"})), + Catch::Matchers::ContainsSubstring("storage key")); + std::filesystem::remove_all(storage); +} + +TEST_CASE("kanban::client::HttpAttachmentTransfer without a usable URL is unavailable and says why", + "[kanban][client][http]") { + morph::qt::QtExecutor owner; + HttpAttachmentTransfer transfer{"not a url", owner}; + CHECK_FALSE(transfer.available()); + CHECK_THROWS_WITH(awaitQt(transfer.upload(UploadRequest{.localPath = "missing.txt", .bearerToken = "t"})), + Catch::Matchers::ContainsSubstring("no attachment server")); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake build/all && cmake --build build/all --target ladder_kanban_tests` +Expected: compile error, `'attachments/attachment_transfer.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/kanban/app/attachments/attachment_transfer.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace kanban::client { + +/// @brief What the side channel stored for an upload: the key `AddAttachment` commits, and the file's metadata. +struct UploadedBlob { + /// @brief The opaque key the server minted. + std::string storageKey; + /// @brief The uploaded file's name, without its directory. + std::string filename; + /// @brief The content type sent with it. + std::string contentType; + /// @brief Its size in bytes. + std::int64_t sizeBytes = 0; +}; + +/// @brief One upload. +struct UploadRequest { + /// @brief The local file to send. + std::filesystem::path localPath; + /// @brief The caller's session token, sent as `Authorization: Bearer`. + std::string bearerToken; +}; + +/// @brief One download. +struct DownloadRequest { + /// @brief The blob's storage key. + std::string storageKey; + /// @brief Where to write it. + std::filesystem::path localPath; + /// @brief The caller's session token. + std::string bearerToken; +}; + +/// @brief The client side of the attachment side channel: bytes over HTTP, never through the bridge. +/// +/// Completions are owned by the executor the implementation was given, so a controller attaches to them on its +/// own owner. +class IAttachmentTransfer { +public: + IAttachmentTransfer() = default; + virtual ~IAttachmentTransfer() = default; + IAttachmentTransfer(IAttachmentTransfer const&) = delete; + IAttachmentTransfer& operator=(IAttachmentTransfer const&) = delete; + IAttachmentTransfer(IAttachmentTransfer&&) = delete; + IAttachmentTransfer& operator=(IAttachmentTransfer&&) = delete; + + /// @brief Whether this client can reach a side channel at all. + /// @return False without a server URL, or on a platform this implementation has no transport for. + [[nodiscard]] virtual bool available() const noexcept = 0; + + /// @brief Sends a local file. + /// @param request The file and the caller's token. + /// @return The stored blob's key and metadata, or the failure. + [[nodiscard]] virtual morph::async::Completion upload(UploadRequest request) = 0; + + /// @brief Fetches a stored blob into a local file. + /// @param request The key, the target file and the caller's token. + /// @return The written path, or the failure (a key the caller may not read fails like an unknown one). + [[nodiscard]] virtual morph::async::Completion download(DownloadRequest request) = 0; +}; + +/// @brief The content type sent for a file, from its extension. +/// @param path The file. +/// @return A media type; `application/octet-stream` for an extension not in the table. +[[nodiscard]] std::string contentTypeFor(std::filesystem::path const& path); + +/// @brief A local path from either a plain path or a `file://` URL (a file picker may hand back either). +/// @param pathOrFileUrl The path or URL. +/// @return The path. +[[nodiscard]] std::filesystem::path localPathFrom(std::string_view pathOrFileUrl); + +/// @brief The attachment side channel of a server: the server opens it on its own host, on port 8769. +/// @param serverUrl The server's WebSocket URL (`--server`), e.g. `ws://host:8765`. +/// @return `http://:8769`, or empty when @p serverUrl names no host. +[[nodiscard]] std::optional attachmentServerFor(std::string_view serverUrl); + +} // namespace kanban::client +``` + +Create `examples/kanban/app/attachments/attachment_transfer.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "attachments/attachment_transfer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace kanban::client { + +std::string contentTypeFor(std::filesystem::path const& path) { + static constexpr std::array, 9> kTypes{{ + {".txt", "text/plain"}, + {".md", "text/markdown"}, + {".csv", "text/csv"}, + {".json", "application/json"}, + {".pdf", "application/pdf"}, + {".png", "image/png"}, + {".jpg", "image/jpeg"}, + {".jpeg", "image/jpeg"}, + {".gif", "image/gif"}, + }}; + std::string extension = path.extension().string(); + std::ranges::transform(extension, extension.begin(), + [](unsigned char letter) { return static_cast(std::tolower(letter)); }); + for (auto const& [suffix, type] : kTypes) { + if (extension == suffix) { + return std::string{type}; + } + } + return "application/octet-stream"; +} + +std::filesystem::path localPathFrom(std::string_view pathOrFileUrl) { + constexpr std::string_view kScheme = "file://"; + if (!pathOrFileUrl.starts_with(kScheme)) { + return std::filesystem::path{pathOrFileUrl}; + } + std::string_view rest = pathOrFileUrl.substr(kScheme.size()); + // `file:///C:/x` names the Windows path `C:/x`; `file:///home/x` the POSIX path `/home/x`. + if (rest.size() >= 3 && rest.front() == '/' && std::isalpha(static_cast(rest.at(1))) != 0 && + rest.at(2) == ':') { + rest.remove_prefix(1); + } + return std::filesystem::path{rest}; +} + +std::optional attachmentServerFor(std::string_view serverUrl) { + // The port the server's side channel listens on beside its WebSocket port (src/server/main.cpp). + constexpr std::string_view kSideChannelPort = "8769"; + auto const scheme = serverUrl.find("://"); + if (scheme == std::string_view::npos) { + return std::nullopt; + } + std::string_view authority = serverUrl.substr(scheme + 3); + authority = authority.substr(0, authority.find('/')); + std::string_view const host = authority.substr(0, authority.rfind(':')); + if (host.empty()) { + return std::nullopt; + } + return "http://" + std::string{host} + ":" + std::string{kSideChannelPort}; +} + +} // namespace kanban::client +``` + +Create `examples/kanban/app/http/http_client.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace kanban::client::http { + +/// @brief Where a request goes. +struct Endpoint { + /// @brief Host name or address. + std::string host; + /// @brief Port, as text (what `getaddrinfo` takes). + std::string port; +}; + +/// @brief One header line. +struct Header { + /// @brief Its name. + std::string name; + /// @brief Its value. + std::string value; +}; + +/// @brief One request. `Host`, `Content-Length` and `Connection: close` are added by `formatRequest`. +struct Request { + /// @brief `GET` or `POST`. + std::string method; + /// @brief The request target, e.g. `/attachments`. + std::string target; + /// @brief Extra headers. + std::vector
headers; + /// @brief The body; may be empty. + std::string body; + /// @brief Bound on each socket read and write. + std::chrono::milliseconds timeout{10000}; +}; + +/// @brief One response. +struct Response { + /// @brief The status code. + int status = 0; + /// @brief The header lines, names as sent. + std::vector
headers; + /// @brief The body. + std::string body; +}; + +/// @brief A request that did not produce a response: resolution, connection, I/O or parse failure. +class HttpError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +/// @brief Reads `http://host[:port][/path]`; the path is ignored. +/// @param url The URL. +/// @return The endpoint (port 80 by default), or empty for anything else. +[[nodiscard]] std::optional parseUrl(std::string_view url); + +/// @brief Reads a complete HTTP/1.x response. +/// @param raw Everything the server sent. +/// @return The response, or empty when it is malformed or shorter than its `Content-Length`. +[[nodiscard]] std::optional parseResponse(std::string_view raw); + +/// @brief Writes @p request as it goes on the wire to @p endpoint. +/// @param endpoint The server, for the `Host` header. +/// @param request The request. +/// @return The bytes to send. +[[nodiscard]] std::string formatRequest(Endpoint const& endpoint, Request const& request); + +/// @brief Whether `send` has a transport on this platform. +/// @return True on POSIX; false on Windows. +[[nodiscard]] bool socketsAvailable() noexcept; + +/// @brief Sends @p request and reads the whole response; blocks the calling thread. +/// @param endpoint The server. +/// @param request The request. +/// @return The response, whatever its status. +/// @throws HttpError when no response could be read. +[[nodiscard]] Response send(Endpoint const& endpoint, Request const& request); + +} // namespace kanban::client::http +``` + +Create `examples/kanban/app/http/http_client.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "http/http_client.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#include +#include + +#include +#include +#include +#endif + +namespace kanban::client::http { + +namespace { + +[[nodiscard]] bool equalsIgnoringCase(std::string_view lhs, std::string_view rhs) { + return std::ranges::equal(lhs, rhs, [](unsigned char left, unsigned char right) { + return std::tolower(left) == std::tolower(right); + }); +} + +[[nodiscard]] bool isPort(std::string_view text) { + unsigned port = 0; + auto const [end, failure] = std::from_chars(text.data(), text.data() + text.size(), port); + return failure == std::errc{} && end == text.data() + text.size() && port > 0 && port <= 65535; +} + +} // namespace + +std::optional parseUrl(std::string_view url) { + constexpr std::string_view kScheme = "http://"; + if (!url.starts_with(kScheme)) { + return std::nullopt; + } + std::string_view authority = url.substr(kScheme.size()); + authority = authority.substr(0, authority.find('/')); + if (authority.empty()) { + return std::nullopt; + } + auto const colon = authority.rfind(':'); + if (colon == std::string_view::npos) { + return Endpoint{.host = std::string{authority}, .port = "80"}; + } + std::string_view const port = authority.substr(colon + 1); + if (colon == 0 || !isPort(port)) { + return std::nullopt; + } + return Endpoint{.host = std::string{authority.substr(0, colon)}, .port = std::string{port}}; +} + +std::optional parseResponse(std::string_view raw) { + auto const headerEnd = raw.find("\r\n\r\n"); + if (headerEnd == std::string_view::npos || !raw.starts_with("HTTP/1.")) { + return std::nullopt; + } + std::string_view const head = raw.substr(0, headerEnd); + auto const statusLineEnd = head.find("\r\n"); + std::string_view const statusLine = head.substr(0, statusLineEnd); + auto const firstSpace = statusLine.find(' '); + if (firstSpace == std::string_view::npos || statusLine.size() < firstSpace + 4) { + return std::nullopt; + } + Response response; + std::string_view const code = statusLine.substr(firstSpace + 1, 3); + if (auto const [end, failure] = std::from_chars(code.data(), code.data() + code.size(), response.status); + failure != std::errc{} || end != code.data() + code.size()) { + return std::nullopt; + } + std::optional contentLength; + std::string_view rest = statusLineEnd == std::string_view::npos ? std::string_view{} : head.substr(statusLineEnd + 2); + while (!rest.empty()) { + auto const lineEnd = rest.find("\r\n"); + std::string_view const line = rest.substr(0, lineEnd); + rest = lineEnd == std::string_view::npos ? std::string_view{} : rest.substr(lineEnd + 2); + auto const colon = line.find(':'); + if (colon == std::string_view::npos) { + return std::nullopt; + } + std::string_view value = line.substr(colon + 1); + while (!value.empty() && value.front() == ' ') { + value.remove_prefix(1); + } + Header header{.name = std::string{line.substr(0, colon)}, .value = std::string{value}}; + if (equalsIgnoringCase(header.name, "Content-Length")) { + std::size_t length = 0; + if (auto const [end, failure] = std::from_chars(value.data(), value.data() + value.size(), length); + failure != std::errc{} || end != value.data() + value.size()) { + return std::nullopt; + } + contentLength = length; + } + response.headers.push_back(std::move(header)); + } + std::string_view const body = raw.substr(headerEnd + 4); + if (contentLength) { + if (body.size() < *contentLength) { + return std::nullopt; + } + response.body = std::string{body.substr(0, *contentLength)}; + } else { + response.body = std::string{body}; + } + return response; +} + +std::string formatRequest(Endpoint const& endpoint, Request const& request) { + std::string wire = request.method + " " + request.target + " HTTP/1.1\r\n"; + wire += "Host: " + endpoint.host + ":" + endpoint.port + "\r\n"; + wire += "Connection: close\r\n"; + wire += "Content-Length: " + std::to_string(request.body.size()) + "\r\n"; + for (Header const& header : request.headers) { + wire += header.name + ": " + header.value + "\r\n"; + } + wire += "\r\n"; + wire += request.body; + return wire; +} + +#if defined(_WIN32) + +bool socketsAvailable() noexcept { return false; } + +Response send(Endpoint const& /*endpoint*/, Request const& /*request*/) { + throw HttpError{"attachments need POSIX sockets, which the terminal client does not have on Windows"}; +} + +#else + +namespace { + +#if defined(MSG_NOSIGNAL) +constexpr int kSendFlags = MSG_NOSIGNAL; +#else +constexpr int kSendFlags = 0; +#endif + +// Owns one socket descriptor. +class Socket { +public: + explicit Socket(int descriptor) noexcept : _descriptor{descriptor} {} + ~Socket() { + if (_descriptor >= 0) { + ::close(_descriptor); + } + } + Socket(Socket&& other) noexcept : _descriptor{std::exchange(other._descriptor, -1)} {} + Socket& operator=(Socket&&) = delete; + Socket(Socket const&) = delete; + Socket& operator=(Socket const&) = delete; + + [[nodiscard]] int descriptor() const noexcept { return _descriptor; } + +private: + int _descriptor; +}; + +[[nodiscard]] std::string lastError() { return std::system_category().message(errno); } + +void bound(int descriptor, std::chrono::milliseconds timeout) { + timeval const limit{.tv_sec = static_cast(timeout.count() / 1000), + .tv_usec = static_cast((timeout.count() % 1000) * 1000)}; + ::setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &limit, sizeof limit); + ::setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &limit, sizeof limit); +#if defined(SO_NOSIGPIPE) + int const enable = 1; + ::setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &enable, sizeof enable); +#endif +} + +[[nodiscard]] Socket connectTo(Endpoint const& endpoint, std::chrono::milliseconds timeout) { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + addrinfo* found = nullptr; + if (int const status = ::getaddrinfo(endpoint.host.c_str(), endpoint.port.c_str(), &hints, &found); + status != 0) { + throw HttpError{"cannot resolve " + endpoint.host + ": " + ::gai_strerror(status)}; + } + std::unique_ptr const addresses{found, &::freeaddrinfo}; + for (addrinfo const* address = addresses.get(); address != nullptr; address = address->ai_next) { + Socket candidate{::socket(address->ai_family, address->ai_socktype, address->ai_protocol)}; + if (candidate.descriptor() < 0) { + continue; + } + bound(candidate.descriptor(), timeout); + if (::connect(candidate.descriptor(), address->ai_addr, address->ai_addrlen) == 0) { + return candidate; + } + } + throw HttpError{"cannot connect to " + endpoint.host + ":" + endpoint.port + ": " + lastError()}; +} + +void writeAll(Socket const& socket, std::string_view bytes) { + while (!bytes.empty()) { + ssize_t const sent = ::send(socket.descriptor(), bytes.data(), bytes.size(), kSendFlags); + if (sent < 0) { + if (errno == EINTR) { + continue; + } + throw HttpError{"send failed: " + lastError()}; + } + bytes.remove_prefix(static_cast(sent)); + } +} + +[[nodiscard]] std::string readAll(Socket const& socket) { + std::string raw; + std::array chunk{}; + for (;;) { + ssize_t const received = ::recv(socket.descriptor(), chunk.data(), chunk.size(), 0); + if (received == 0) { + return raw; + } + if (received < 0) { + if (errno == EINTR) { + continue; + } + throw HttpError{"receive failed: " + lastError()}; + } + raw.append(chunk.data(), static_cast(received)); + } +} + +} // namespace + +bool socketsAvailable() noexcept { return true; } + +Response send(Endpoint const& endpoint, Request const& request) { + Socket const socket = connectTo(endpoint, request.timeout); + writeAll(socket, formatRequest(endpoint, request)); + auto response = parseResponse(readAll(socket)); + if (!response) { + throw HttpError{"malformed HTTP response from " + endpoint.host}; + } + return std::move(*response); +} + +#endif + +} // namespace kanban::client::http +``` + +The server answers each request and closes the connection (`Connection: close`, `src/server/app/attachment_server.cpp`'s +`respondAndClose`), so reading until end of stream reads exactly one response. + +Create `examples/kanban/app/http/http_attachment_transfer.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include "attachments/attachment_transfer.hpp" +#include "http/http_client.hpp" + +namespace kanban::client { + +/// @brief `IAttachmentTransfer` over the blocking HTTP client, one request at a time on a private worker thread. +/// +/// Available on POSIX only; elsewhere every request fails with the reason. +class HttpAttachmentTransfer final : public IAttachmentTransfer { +public: + /// @param baseUrl The side channel, e.g. `http://127.0.0.1:8769`. + /// @param callbacks Owns the returned completions: the caller's own executor. Borrowed. + HttpAttachmentTransfer(std::string const& baseUrl, morph::exec::IExecutor& callbacks); + /// @brief Waits for a request in flight (each is bounded by its socket timeout). + ~HttpAttachmentTransfer() override; + HttpAttachmentTransfer(HttpAttachmentTransfer const&) = delete; + HttpAttachmentTransfer& operator=(HttpAttachmentTransfer const&) = delete; + HttpAttachmentTransfer(HttpAttachmentTransfer&&) = delete; + HttpAttachmentTransfer& operator=(HttpAttachmentTransfer&&) = delete; + + /// @brief Whether the URL read and this platform has sockets. + /// @return True when requests can be sent. + [[nodiscard]] bool available() const noexcept override; + /// @brief Posts the file; see `IAttachmentTransfer::upload`. + /// @param request The file and the caller's token. + /// @return The stored blob, or the failure. + [[nodiscard]] morph::async::Completion upload(UploadRequest request) override; + /// @brief Fetches the blob; see `IAttachmentTransfer::download`. + /// @param request The key, target file and token. + /// @return The written path, or the failure. + [[nodiscard]] morph::async::Completion download(DownloadRequest request) override; + +private: + std::optional _endpoint; + morph::exec::IExecutor* _callbacks; + std::unique_ptr _io; +}; + +} // namespace kanban::client +``` + +Create `examples/kanban/app/http/http_attachment_transfer.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "http/http_attachment_transfer.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace kanban::client { + +namespace { + +constexpr std::chrono::milliseconds kTimeout{10000}; + +// The upload reply: `{"storageKey":"..."}`. +struct StorageKeyReply { + std::string storageKey; +}; + +// A storage key goes into the request line, so it is held to the shape the server mints (hex) plus the +// characters a key could safely carry; anything else is refused before a byte is sent. +[[nodiscard]] bool isPlausibleStorageKey(std::string_view key) { + return !key.empty() && key.size() <= 255 && std::ranges::all_of(key, [](unsigned char letter) { + return std::isalnum(letter) != 0 || letter == '-' || letter == '_'; + }); +} + +[[nodiscard]] UploadedBlob uploadNow(std::optional const& endpoint, UploadRequest const& request) { + if (!endpoint) { + throw http::HttpError{"no attachment server is configured"}; + } + std::ifstream file{request.localPath, std::ios::binary}; + if (!file) { + throw http::HttpError{"could not open '" + request.localPath.string() + "' for reading"}; + } + std::string const bytes{std::istreambuf_iterator{file}, std::istreambuf_iterator{}}; + std::string const contentType = contentTypeFor(request.localPath); + http::Response const response = + http::send(*endpoint, http::Request{.method = "POST", + .target = "/attachments", + .headers = {http::Header{.name = "X-Attachment-Content-Type", + .value = contentType}, + http::Header{.name = "Authorization", + .value = "Bearer " + request.bearerToken}}, + .body = bytes, + .timeout = kTimeout}); + if (response.status != 200) { + throw http::HttpError{"upload failed: HTTP " + std::to_string(response.status)}; + } + StorageKeyReply reply; + if (glz::read_json(reply, response.body) || reply.storageKey.empty()) { + throw http::HttpError{"upload failed: the server's reply carried no storage key"}; + } + return UploadedBlob{.storageKey = reply.storageKey, + .filename = request.localPath.filename().string(), + .contentType = contentType, + .sizeBytes = static_cast(bytes.size())}; +} + +[[nodiscard]] std::string downloadNow(std::optional const& endpoint, DownloadRequest const& request) { + if (!endpoint) { + throw http::HttpError{"no attachment server is configured"}; + } + if (!isPlausibleStorageKey(request.storageKey)) { + throw http::HttpError{"download refused: '" + request.storageKey + "' is not a storage key"}; + } + http::Response const response = + http::send(*endpoint, http::Request{.method = "GET", + .target = "/attachments/" + request.storageKey, + .headers = {http::Header{.name = "Authorization", + .value = "Bearer " + request.bearerToken}}, + .body = {}, + .timeout = kTimeout}); + if (response.status != 200) { + throw http::HttpError{"download failed: HTTP " + std::to_string(response.status)}; + } + std::ofstream file{request.localPath, std::ios::binary | std::ios::trunc}; + file.write(response.body.data(), static_cast(response.body.size())); + if (!file) { + throw http::HttpError{"could not write '" + request.localPath.string() + "'"}; + } + return request.localPath.string(); +} + +} // namespace + +HttpAttachmentTransfer::HttpAttachmentTransfer(std::string const& baseUrl, morph::exec::IExecutor& callbacks) + : _endpoint{http::parseUrl(baseUrl)}, + _callbacks{&callbacks}, + _io{std::make_unique(1)} {} + +HttpAttachmentTransfer::~HttpAttachmentTransfer() = default; + +bool HttpAttachmentTransfer::available() const noexcept { return _endpoint.has_value() && http::socketsAvailable(); } + +morph::async::Completion HttpAttachmentTransfer::upload(UploadRequest request) { + auto settleable = morph::async::Completion::makeSettleable(_callbacks); + auto promise = std::make_shared::Promise>(std::move(settleable.second)); + _io->post([endpoint = _endpoint, request = std::move(request), promise] { + try { + promise->resolve(uploadNow(endpoint, request)); + } catch (...) { + promise->reject(std::current_exception()); + } + }); + return std::move(settleable.first); +} + +morph::async::Completion HttpAttachmentTransfer::download(DownloadRequest request) { + auto settleable = morph::async::Completion::makeSettleable(_callbacks); + auto promise = std::make_shared::Promise>(std::move(settleable.second)); + _io->post([endpoint = _endpoint, request = std::move(request), promise] { + try { + promise->resolve(downloadNow(endpoint, request)); + } catch (...) { + promise->reject(std::current_exception()); + } + }); + return std::move(settleable.first); +} + +} // namespace kanban::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client][http]" +``` + +Expected: PASS, 8 cases. + +Mutation check: in `parseResponse`, delete the `if (body.size() < *contentLength) { return std::nullopt; }` block. +Expected FAIL in "parseResponse reads a complete response and refuses a short or garbled one". Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/kanban/app/attachments examples/kanban/app/http examples/kanban/tests/client/qt_client_support.hpp \ + examples/kanban/tests/client/test_http_client.cpp examples/kanban/tests/client/test_http_attachment_transfer.cpp +git commit -m "wip(kanban): IAttachmentTransfer and an HTTP/1.1 client over POSIX sockets + +Signed-off-by: Christian Parpart " +``` + +--- +### Task K11: `TaskDetailController` — comments and attachments + +Re-expresses `TaskDetailPopup.qml` with `BoardBridge::addComment`, `getAttachments`, `uploadAttachment`, +`downloadAttachment`: the open task's comments are a projection of the board; the `AddComment` form's `taskId` is +context; attachments are a `Query` keyed on the open task; an upload goes through the injected +`IAttachmentTransfer` and then commits its metadata with a `Mutation` invalidating the list; a +download writes through the transfer. Without a transfer (local mode, or the Windows terminal) attachments are +unavailable and the controller says so instead of failing. + +**Files:** +- Create: `examples/kanban/app/controllers/task_detail_controller.hpp`, `examples/kanban/app/controllers/task_detail_controller.cpp` +- Create: `examples/kanban/tests/client/fake_transfer.hpp` +- Test: `examples/kanban/tests/client/test_task_detail_controller.cpp`, + `examples/kanban/tests/client/test_task_detail_attachments.cpp` + +**Interfaces:** +- Consumes: `BoardController::handler()`, `board()`, `refresh()` (Task K6); `IAttachmentTransfer`, + `UploadRequest`, `DownloadRequest`, `UploadedBlob`, `localPathFrom`, `HttpAttachmentTransfer` (Task K10); + `attachmentLine` (Task K2); `morph::examples::mapCompletion` (Part 6); `forms::FormModel::forAction()`, + `FormSession::assign` (Part 5); `QtClient`, `freshStorageDir` (Task K10). +- Produces: `kanban::client::CommentRow{index, principal, body}`, `AttachmentRow{id, storageKey, line}`; + `TaskDetailController(examples::Wiring, BoardController&, std::shared_ptr)`: `openTask(TaskId)`, + `close()`, `isOpen()`, `title()`, `comments()`, `addCommentForm()`, `attachments()`, `attachmentsAvailable()`, + `upload(std::string const& localPathOrUrl)`, `download(std::int64_t attachmentId, std::string const& + localPathOrUrl)`, `transferPending()`, `statusText()`. + - Test support: `kanban::testing::FakeTransfer` (an `IAttachmentTransfer` whose requests wait for the test). + +- [ ] **Step 1: Write the failing tests** + +Create `examples/kanban/tests/client/fake_transfer.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include "attachments/attachment_transfer.hpp" + +namespace kanban::testing { + +/// An attachment transfer whose every request waits until the test settles it. +class FakeTransfer final : public kanban::client::IAttachmentTransfer { +public: + explicit FakeTransfer(morph::exec::IExecutor& owner) : _owner{&owner} {} + + [[nodiscard]] bool available() const noexcept override { return true; } + + [[nodiscard]] morph::async::Completion upload( + kanban::client::UploadRequest request) override { + uploads.push_back(std::move(request)); + auto settleable = morph::async::Completion::makeSettleable(_owner); + uploadReplies.push_back(std::move(settleable.second)); + return std::move(settleable.first); + } + + [[nodiscard]] morph::async::Completion download(kanban::client::DownloadRequest request) override { + downloads.push_back(std::move(request)); + auto settleable = morph::async::Completion::makeSettleable(_owner); + downloadReplies.push_back(std::move(settleable.second)); + return std::move(settleable.first); + } + + std::vector uploads; + std::vector::Promise> uploadReplies; + std::vector downloads; + std::vector::Promise> downloadReplies; + +private: + morph::exec::IExecutor* _owner; +}; + +} // namespace kanban::testing +``` + +Create `examples/kanban/tests/client/test_task_detail_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "attachments/attachment_transfer.hpp" +#include "board_support.hpp" +#include "controllers/board_controller.hpp" +#include "controllers/task_detail_controller.hpp" +#include "fake_transfer.hpp" +#include "kanban_client_support.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using kanban::TaskId; +using kanban::client::BoardController; +using kanban::client::TaskDetailController; +using kanban::client::UploadedBlob; +using kanban::testing::FakeTransfer; +using kanban::testing::LocalClient; +using morph::ladder::testkit::DbFixture; + +[[nodiscard]] UploadedBlob reportBlob() { + return UploadedBlob{ + .storageKey = "abc123", .filename = "report.pdf", .contentType = "application/pdf", .sizeBytes = 34}; +} + +} // namespace + +TEST_CASE("kanban::client::TaskDetailController: the open task's comments, and the form appends one", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + auto const seeded = kanban::testing::seedBoard(client, board, project); + TaskDetailController detail{client.wiring(), board, nullptr}; + CHECK_FALSE(detail.isOpen()); + + detail.openTask(TaskId{seeded.task}); + + CHECK(detail.isOpen()); + CHECK(detail.title() == "Fix bug"); + CHECK(detail.comments().empty()); + kanban::testing::submitForm(client, detail.addCommentForm(), R"({"body":"looks good"})"); + REQUIRE(client.settle([&detail] { return detail.comments().size() == 1; })); + CHECK(detail.comments().front().principal == "alice"); + CHECK(detail.comments().front().body == "looks good"); + detail.close(); + CHECK_FALSE(detail.isOpen()); +} + +TEST_CASE("kanban::client::TaskDetailController: an upload goes through the transfer, then records its metadata", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + auto const seeded = kanban::testing::seedBoard(client, board, project); + auto const transfer = std::make_shared(client.owner); + TaskDetailController detail{client.wiring(), board, transfer}; + detail.openTask(TaskId{seeded.task}); + CHECK(detail.attachmentsAvailable()); + + detail.upload("file:///tmp/report.pdf"); + + REQUIRE(transfer->uploads.size() == 1); + CHECK(transfer->uploads.front().localPath == std::filesystem::path{"/tmp/report.pdf"}); + CHECK(transfer->uploads.front().bearerToken == client.bridge.defaultSession().token); + CHECK(detail.transferPending()); + transfer->uploadReplies.front().resolve(reportBlob()); + REQUIRE(client.settle([&detail] { return detail.attachments().size() == 1 && !detail.transferPending(); })); + CHECK(detail.attachments().front().line == "report.pdf (34 bytes, alice)"); + CHECK(detail.attachments().front().storageKey == "abc123"); + CHECK(detail.statusText() == "attachment uploaded"); + + detail.download(detail.attachments().front().id, "/tmp/report-copy.pdf"); + REQUIRE(transfer->downloads.size() == 1); + CHECK(transfer->downloads.front().storageKey == "abc123"); + transfer->downloadReplies.front().resolve("/tmp/report-copy.pdf"); + REQUIRE(client.settle([&detail] { return detail.statusText() == "saved to /tmp/report-copy.pdf"; })); +} + +TEST_CASE("kanban::client::TaskDetailController: a failed upload reports and records nothing", "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + auto const seeded = kanban::testing::seedBoard(client, board, project); + auto const transfer = std::make_shared(client.owner); + TaskDetailController detail{client.wiring(), board, transfer}; + detail.openTask(TaskId{seeded.task}); + + detail.upload("/tmp/report.pdf"); + transfer->uploadReplies.front().reject(std::make_exception_ptr(std::runtime_error{"disk full"})); + + REQUIRE(client.settle([&detail] { return !detail.transferPending(); })); + CHECK(detail.statusText() == "upload failed: disk full"); + CHECK(detail.attachments().empty()); +} + +TEST_CASE("kanban::client::TaskDetailController: without a transfer, attachments are unavailable and say so", + "[kanban][client]") { + DbFixture const fixture; + LocalClient client{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + BoardController board{client.wiring()}; + auto const seeded = kanban::testing::seedBoard(client, board, project); + TaskDetailController detail{client.wiring(), board, nullptr}; + detail.openTask(TaskId{seeded.task}); + + CHECK_FALSE(detail.attachmentsAvailable()); + detail.upload("/tmp/report.pdf"); + + CHECK(detail.statusText() == "attachments are not available on this client"); + CHECK_FALSE(detail.transferPending()); +} +``` + +Create `examples/kanban/tests/client/test_task_detail_attachments.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The task dialog over the real side channel: a Qt-hosted AttachmentServer, the POSIX HTTP transfer, and the +// metadata committed through the bridge. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "board_support.hpp" +#include "controllers/board_controller.hpp" +#include "controllers/task_detail_controller.hpp" +#include "http/http_attachment_transfer.hpp" +#include "kanban/http/attachment_server.hpp" +#include "qt_client_support.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +namespace { + +using kanban::TaskId; +using kanban::client::BoardController; +using kanban::client::HttpAttachmentTransfer; +using kanban::client::TaskDetailController; +using kanban::testing::QtClient; + +[[nodiscard]] std::string readFile(std::filesystem::path const& path) { + std::ifstream file{path, std::ios::binary}; + return std::string{std::istreambuf_iterator{file}, std::istreambuf_iterator{}}; +} + +} // namespace + +TEST_CASE("kanban::client::TaskDetailController uploads over the side channel and downloads the bytes back", + "[kanban][client][http]") { + morph::ladder::testkit::DbFixture const fixture; + morph::session::TokenVerifier const verifier{std::string{kanban::testing::kSecret}, morph::session::hmacSha256}; + auto const storage = kanban::testing::freshStorageDir("detail_round_trip"); + kanban::http::AttachmentServer server{verifier, kanban::http::AttachmentServer::Config{.storageDir = storage}}; + REQUIRE(server.listen()); + std::string const url = "http://127.0.0.1:" + std::to_string(server.port()); + QtClient alice{kanban::testing::signedContext("alice")}; + auto const project = kanban::testing::seedProject(alice, "Sprint Board"); + BoardController board{alice.wiring()}; + auto const seeded = kanban::testing::seedBoard(alice, board, project); + TaskDetailController detail{alice.wiring(), board, std::make_shared(url, alice.owner)}; + detail.openTask(TaskId{seeded.task}); + auto const workDir = std::filesystem::temp_directory_path() / "kanban_client_detail_round_trip"; + std::filesystem::remove_all(workDir); + std::filesystem::create_directories(workDir); + std::ofstream{workDir / "report.txt", std::ios::binary} << "the attachment's own bytes"; + + detail.upload((workDir / "report.txt").string()); + REQUIRE(alice.settle([&detail] { return detail.attachments().size() == 1 && !detail.transferPending(); })); + CHECK(detail.statusText() == "attachment uploaded"); + + auto const copy = workDir / "report-downloaded.txt"; + detail.download(detail.attachments().front().id, copy.string()); + REQUIRE(alice.settle([&detail] { return !detail.transferPending() && !detail.statusText().empty(); })); + CHECK(detail.statusText() == "saved to " + copy.string()); + CHECK(readFile(copy) == "the attachment's own bytes"); + + // Authenticated is not authorized: another principal, with no role on alice's project, cannot read the blob. + QtClient mallory{kanban::testing::signedContext("mallory")}; + HttpAttachmentTransfer malloryTransfer{url, mallory.owner}; + auto const stolen = workDir / "mallory.txt"; + CHECK_THROWS_WITH( + morph::ladder::testkit::awaitQt(malloryTransfer.download( + kanban::client::DownloadRequest{.storageKey = detail.attachments().front().storageKey, + .localPath = stolen, + .bearerToken = kanban::testing::signedContext("mallory").token})), + Catch::Matchers::ContainsSubstring("HTTP 404")); + CHECK_FALSE(std::filesystem::exists(stolen)); + + std::filesystem::remove_all(workDir); + std::filesystem::remove_all(storage); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake build/all && cmake --build build/all --target ladder_kanban_tests` +Expected: compile error, `'controllers/task_detail_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/kanban/app/controllers/task_detail_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "attachments/attachment_transfer.hpp" +#include "controllers/board_controller.hpp" + +namespace kanban::client { + +/// @brief One comment in the task dialog. +struct CommentRow { + /// @brief Its place among the task's comments; its key. + std::int64_t index = 0; + /// @brief Who wrote it. + std::string principal; + /// @brief What they wrote. + std::string body; + /// @brief Compared by value. + bool operator==(CommentRow const&) const = default; +}; + +/// @brief One attachment in the task dialog. +struct AttachmentRow { + /// @brief The attachment's id. + std::int64_t id = 0; + /// @brief The key its bytes are stored under. + std::string storageKey; + /// @brief Its display line. + std::string line; + /// @brief Compared by value. + bool operator==(AttachmentRow const&) const = default; +}; + +/// @brief The task dialog: the open task's comments, the `AddComment` form, and its attachments. +class TaskDetailController { +public: + /// @param wiring The runtime, bridge and owner this controller uses. + /// @param board The board whose attached instance owns the task. Borrowed: it must outlive this controller. + /// @param transfer The side channel's client; null disables attachments. + TaskDetailController(morph::examples::Wiring wiring, BoardController& board, + std::shared_ptr transfer); + ~TaskDetailController(); + TaskDetailController(TaskDetailController const&) = delete; + TaskDetailController& operator=(TaskDetailController const&) = delete; + TaskDetailController(TaskDetailController&&) = delete; + TaskDetailController& operator=(TaskDetailController&&) = delete; + + /// @brief Opens the dialog on @p task. + /// @param task The task. + void openTask(TaskId task); + /// @brief Closes the dialog. + void close(); + /// @brief Whether the dialog is open. Tracked. + /// @return True between `openTask` and `close`. + [[nodiscard]] bool isOpen() const { return _task.get().has_value(); } + /// @brief The dialog's title. Tracked. + /// @return The task's title, or `"Task"`. + [[nodiscard]] std::string title() const; + /// @brief The open task's comments, oldest first. Tracked. + /// @return One row per comment. + [[nodiscard]] std::vector comments() const; + /// @brief The `AddComment` form; its `taskId` is the open task. + /// @return The form session. + [[nodiscard]] morph::forms::FormSession& addCommentForm() noexcept { return _addComment; } + /// @brief The open task's attachments, in upload order. Tracked. + /// @return One row per attachment. + [[nodiscard]] std::vector attachments() const; + /// @brief Whether this client can move attachment bytes at all. + /// @return False without a transfer, or with one that has no transport here. + [[nodiscard]] bool attachmentsAvailable() const noexcept; + /// @brief Uploads a local file and attaches it to the open task. + /// @param localPathOrUrl A path, or a `file://` URL. + void upload(std::string const& localPathOrUrl); + /// @brief Downloads one of the open task's attachments to a local file. + /// @param attachmentId The attachment. + /// @param localPathOrUrl Where to write it: a path, or a `file://` URL. + void download(std::int64_t attachmentId, std::string const& localPathOrUrl); + /// @brief Whether an upload, its commit or a download is in flight. Tracked. + /// @return True while any is. + [[nodiscard]] bool transferPending() const; + /// @brief The last transfer's outcome. Tracked. + /// @return E.g. `"attachment uploaded"`, `"saved to "`, a failure, or empty. + [[nodiscard]] std::string statusText() const; + +private: + [[nodiscard]] morph::async::Completion submitComment(std::string_view actionType, std::string body); + void assignTask(); + void finishTransfer(); + + morph::examples::Wiring _wiring; + BoardController* _board; + std::shared_ptr _transfer; + morph::reactive::Signal> _task; + morph::reactive::Signal _status; + morph::reactive::Signal _transfers; + morph::reactive::Query _attachments; + morph::reactive::Mutation _add; + morph::forms::FormSession _addComment; + std::unique_ptr _afterAdd; + // Last member, so it is destroyed first: a transfer or reply still in flight finds it stopped. + morph::async::CallbackScope _lifetime; +}; + +} // namespace kanban::client +``` + +Create `examples/kanban/app/controllers/task_detail_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/task_detail_controller.hpp" + +#include +#include +#include + +#include "app/completion_map.hpp" +#include "support/board_format.hpp" + +namespace kanban::client { + +TaskDetailController::TaskDetailController(morph::examples::Wiring wiring, BoardController& board, + std::shared_ptr transfer) + : _wiring{wiring}, + _board{&board}, + _transfer{std::move(transfer)}, + _task{wiring.runtime, std::nullopt}, + _status{wiring.runtime, std::string{}}, + _transfers{wiring.runtime, 0}, + _attachments{wiring.runtime, board.handler(), + [this]() -> std::optional { + auto const& task = _task.get(); + if (!task) { + return std::nullopt; + } + return GetAttachments{.taskId = *task}; + }}, + _add{wiring.runtime, board.handler(), morph::reactive::MutationOptions{.invalidates = {&_attachments}}}, + _addComment{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { + return submitComment(actionType, std::move(body)); + }, + {}} { + _afterAdd = std::make_unique(_wiring.runtime, [this] { + if (_add.lastResult().has_value()) { + _wiring.runtime.untracked([this] { _status.set("attachment uploaded"); }); + } + }); +} + +TaskDetailController::~TaskDetailController() = default; + +void TaskDetailController::openTask(TaskId task) { + _wiring.runtime.batch([&] { + _status.set(std::string{}); + _task.set(task); + }); + _addComment.reset(); + assignTask(); +} + +void TaskDetailController::close() { _task.set(std::nullopt); } + +std::string TaskDetailController::title() const { + auto const& task = _task.get(); + auto const& board = _board->board(); + if (task && board) { + for (TaskView const& candidate : board->tasks) { + if (candidate.id == *task) { + return candidate.title; + } + } + } + return "Task"; +} + +std::vector TaskDetailController::comments() const { + std::vector rows; + auto const& task = _task.get(); + auto const& board = _board->board(); + if (!task || !board) { + return rows; + } + for (CommentView const& comment : board->comments) { + if (comment.taskId == *task) { + rows.push_back(CommentRow{.index = static_cast(rows.size()), + .principal = comment.principal, + .body = comment.body}); + } + } + return rows; +} + +std::vector TaskDetailController::attachments() const { + std::vector rows; + auto const& listed = _attachments.value(); + if (!listed) { + return rows; + } + for (AttachmentView const& attachment : listed->attachments) { + if (attachment.id.hasValue()) { + rows.push_back(AttachmentRow{ + .id = *attachment.id, .storageKey = attachment.storageKey, .line = attachmentLine(attachment)}); + } + } + return rows; +} + +bool TaskDetailController::attachmentsAvailable() const noexcept { + return _transfer != nullptr && _transfer->available(); +} + +void TaskDetailController::upload(std::string const& localPathOrUrl) { + std::optional const task = _task.peek(); + if (!task) { + return; + } + if (!attachmentsAvailable()) { + _status.set("attachments are not available on this client"); + return; + } + _status.set(std::string{}); + _transfers.set(_transfers.peek() + 1); + _transfer + ->upload(UploadRequest{.localPath = localPathFrom(localPathOrUrl), + .bearerToken = _wiring.bridge.defaultSession().token}) + .then(_lifetime, + [this, taskId = *task](UploadedBlob const& blob) { + finishTransfer(); + _add.run(AddAttachment{.taskId = taskId, + .filename = blob.filename, + .contentType = blob.contentType, + .sizeBytes = blob.sizeBytes, + .storageKey = blob.storageKey}); + }) + .onError(_lifetime, [this](std::exception_ptr const& error) { + finishTransfer(); + _status.set("upload failed: " + morph::reactive::errorMessage(error)); + }); +} + +void TaskDetailController::download(std::int64_t attachmentId, std::string const& localPathOrUrl) { + if (!attachmentsAvailable()) { + _status.set("attachments are not available on this client"); + return; + } + std::string storageKey; + for (AttachmentRow const& row : attachments()) { + if (row.id == attachmentId) { + storageKey = row.storageKey; + } + } + if (storageKey.empty()) { + return; + } + _status.set(std::string{}); + _transfers.set(_transfers.peek() + 1); + _transfer + ->download(DownloadRequest{.storageKey = std::move(storageKey), + .localPath = localPathFrom(localPathOrUrl), + .bearerToken = _wiring.bridge.defaultSession().token}) + .then(_lifetime, + [this](std::string const& saved) { + finishTransfer(); + _status.set("saved to " + saved); + }) + .onError(_lifetime, [this](std::exception_ptr const& error) { + finishTransfer(); + _status.set("download failed: " + morph::reactive::errorMessage(error)); + }); +} + +bool TaskDetailController::transferPending() const { return _transfers.get() > 0 || _add.pending(); } + +std::string TaskDetailController::statusText() const { + if (std::exception_ptr const error = _add.error(); error != nullptr) { + return "attaching failed: " + morph::reactive::errorMessage(error); + } + return _status.get(); +} + +morph::async::Completion TaskDetailController::submitComment(std::string_view actionType, + std::string body) { + return morph::examples::mapCompletion( + _wiring.callbacks, _lifetime.token(), _board->handler().executeJson(actionType, body), + [this](std::string const& reply) { + _addComment.reset(); + assignTask(); + _board->refresh(); + return reply; + }, + [](std::exception_ptr const&) {}); +} + +void TaskDetailController::assignTask() { + std::optional const task = _task.peek(); + if (task && task->hasValue()) { + _addComment.assign("taskId", std::to_string(**task)); + } +} + +void TaskDetailController::finishTransfer() { _transfers.set(_transfers.peek() - 1); } + +} // namespace kanban::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client]" +``` + +Expected: PASS. + +Mutation checks, each restored afterwards: +- In `openTask`, delete `assignTask();`. Expected FAIL in "the open task's comments, and the form appends one" + (`REQUIRE(form.ready())` fails: the hidden `taskId` is never filled). +- In `upload`'s success handler, delete the `_add.run(...)` call. Expected FAIL in "an upload goes through the + transfer, then records its metadata". + +- [ ] **Step 5: Commit** + +```bash +git add examples/kanban/app/controllers/task_detail_controller.hpp \ + examples/kanban/app/controllers/task_detail_controller.cpp examples/kanban/tests/client/fake_transfer.hpp \ + examples/kanban/tests/client/test_task_detail_controller.cpp \ + examples/kanban/tests/client/test_task_detail_attachments.cpp +git commit -m "wip(kanban): TaskDetailController: comments and attachments through an injected transfer + +Signed-off-by: Christian Parpart " +``` + +--- +### Task K12: `AppController` and the views — the board as a grid with drag-and-drop + +The five controllers come together in `AppController`, whose route is a `Computed` (signed out → sign-in; a board +open → board; else projects) — Main.qml's `StackView` pushes, as state. The views are bindings only. The board is a +`Grid` of swimlanes × columns (spec 4 §5): a `forEach` keyed on the board's *shape* rebuilds the grid only when a +column or lane is added; inside, each cell is a `forEach` over that cell's cards, so a move updates cards in place. +A card is a `Button` whose `dragKey` is its task id; it is also a drop target (insert before it), and the cell is a +drop target (append). Every drop target's `accepts` takes only positive integer keys. + +**Files:** +- Create: `examples/kanban/app/app_controller.hpp`, `examples/kanban/app/app_controller.cpp` +- Create: `examples/kanban/app/views/sign_in_view.{hpp,cpp}`, `projects_view.{hpp,cpp}`, `board_view.{hpp,cpp}`, + `root_view.{hpp,cpp}` (all under `examples/kanban/app/views/`) +- Test: `examples/kanban/tests/client/test_app_controller.cpp`, `examples/kanban/tests/client/test_kanban_views.cpp` + +**Interfaces:** +- Consumes: every controller of Tasks K3–K11; `ui::column`, `row`, `grid`, `GridCell`, `text`, `button`, `select`, + `SelectOption`, `panel`, `scroll`, `spacer`, `dialog`, `filePicker`, `forEach`, `switchOn`, `Common`, + `Sizing`, `TextRole`, `Axis`, `FilePickerMode`, `Key` (Part 2, `morph/ui/view.hpp`); `ui::Mounted` + (`morph/ui/mount.hpp`); `ui::testing::RecordingBackend` (`find`, `all`, `prop`, `dump`, `click`, `drag`, `pick`; + a stack is `Column`/`Row`, a `Dialog`'s content exists only while it is open); `forms::formView`, + `FormViewOptions{.submitLabel}` (Part 5); `kanban::testing::fill` (Task K3). +- Produces: `kanban::client::Route{SignIn, Projects, Board}`, `AppOptions{attachments, board}`, + `AppController(examples::Wiring, AppOptions)` with `route()`, `session()`, `projects()`, `board()`, `rules()`, `detail()`, + `openProject(ProjectId, Role)`, `backToProjects()`; views `signInView(SessionController&)`, + `projectsView(AppController&)`, `boardView(AppController&)`, `rulesDialog(RulesController&)`, + `taskDetailDialog(TaskDetailController&)`, `rootView(AppController&)` — all `-> ui::Node`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/kanban/tests/client/test_app_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include "app_controller.hpp" +#include "kanban_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using kanban::client::AppController; +using kanban::client::Route; +using kanban::testing::IssuerScope; +using kanban::testing::LocalClient; + +TEST_CASE("kanban::client::AppController routes sign-in, projects and board from state", "[kanban][client]") { + morph::ladder::testkit::DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + AppController app{client.wiring(), {}}; + CHECK(app.route() == Route::SignIn); + + kanban::testing::signIn(client, app.session(), "alice"); + CHECK(app.route() == Route::Projects); + kanban::testing::submitForm(client, app.projects().createProjectForm(), R"({"name":"Sprint Board"})"); + REQUIRE(client.settle([&app] { return app.projects().projects().size() == 1; })); + auto const project = app.projects().projects().front(); + + app.openProject(kanban::ProjectId{project.id}, project.role); + CHECK(app.route() == Route::Board); + REQUIRE(client.settle([&app] { return app.board().board().has_value(); })); + app.rules().show(); + + app.backToProjects(); + CHECK(app.route() == Route::Projects); + CHECK_FALSE(app.rules().shown()); + REQUIRE(client.settle([&app] { return !app.board().board().has_value(); })); +} +``` + +Create `examples/kanban/tests/client/test_kanban_views.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// One RecordingBackend test per screen: the screen's tree carries what it must show, and its controls drive the +// controller. Form fields are filled through the controller's form session (the forms engine renders and tests +// its own fields); the screen's own Submit buttons are clicked here, once the flush that enables them has run. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app_controller.hpp" +#include "board_support.hpp" +#include "fake_transfer.hpp" +#include "kanban_client_support.hpp" +#include "testkit/db_fixture.hpp" +#include "views/board_view.hpp" +#include "views/projects_view.hpp" +#include "views/root_view.hpp" +#include "views/sign_in_view.hpp" + +namespace { + +using kanban::client::AppController; +using kanban::client::AppOptions; +using kanban::testing::IssuerScope; +using kanban::testing::LocalClient; +using morph::ladder::testkit::DbFixture; +using morph::ui::testing::RecordingBackend; + +[[nodiscard]] bool shows(RecordingBackend const& backend, std::string const& text) { + return backend.dump().find(text) != std::string::npos; +} + +[[nodiscard]] int button(RecordingBackend const& backend, std::string const& label) { + auto const found = backend.find("Button", "label", label); + REQUIRE(found.has_value()); + return *found; +} + +// Clicks the button labelled @p label once it is enabled. A form's Submit button follows `ready()` one flush after +// the fields change, and the backend ignores a click on a disabled widget. +void clickWhenEnabled(LocalClient& client, RecordingBackend& backend, std::string const& label) { + int const target = button(backend, label); + REQUIRE(client.settle([&backend, target] { return backend.prop(target, "enabled") != "false"; })); + backend.click(target); +} + +// A signed-in app with one project, opened on its board and seeded through the board's forms. +struct BoardScreen { + explicit BoardScreen(LocalClient& client, AppOptions options = {}) : app{client.wiring(), std::move(options)} { + kanban::testing::signIn(client, app.session(), "alice"); + auto const project = kanban::testing::seedProject(client, "Sprint Board"); + seeded = kanban::testing::seedBoard(client, app.board(), project); + app.openProject(project, kanban::Role::Manager); + } + + AppController app; + kanban::testing::SeededBoard seeded; +}; + +} // namespace + +TEST_CASE("kanban view: the sign-in screen's Sign in button submits the Login form", "[kanban][view]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + AppController app{client.wiring(), {}}; + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, kanban::client::signInView(app.session())}; + REQUIRE(client.settle([&] { return backend.find("Button", "label", "Sign in").has_value(); })); + CHECK(shows(backend, "Dev-mode login")); + + kanban::testing::fill(app.session().loginForm(), R"({"username":"alice"})"); + clickWhenEnabled(client, backend, "Sign in"); + + REQUIRE(client.settle([&app] { return app.session().signedIn(); })); +} + +TEST_CASE("kanban view: the root screen follows the route", "[kanban][view]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + AppController app{client.wiring(), {}}; + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, kanban::client::rootView(app)}; + REQUIRE(client.settle([&] { return shows(backend, "not signed in"); })); + + kanban::testing::signIn(client, app.session(), "alice"); + + REQUIRE(client.settle([&] { return shows(backend, "Your projects") && shows(backend, "signed in as alice"); })); +} + +TEST_CASE("kanban view: the projects screen creates, lists, opens and shows members", "[kanban][view]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + AppController app{client.wiring(), {}}; + kanban::testing::signIn(client, app.session(), "alice"); + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, kanban::client::projectsView(app)}; + REQUIRE(client.settle([&] { return backend.find("Button", "label", "Create project").has_value(); })); + + kanban::testing::fill(app.projects().createProjectForm(), R"({"name":"Sprint Board"})"); + clickWhenEnabled(client, backend, "Create project"); + REQUIRE(client.settle([&] { return shows(backend, "Sprint Board · Manager"); })); + + backend.click(button(backend, "Members")); + REQUIRE(client.settle([&] { return shows(backend, "Members of Sprint Board") && shows(backend, "alice"); })); + CHECK(backend.find("Button", "label", "Add member").has_value()); + // alice's own row: the per-row role picker shows her role (a string key, so quoted). + CHECK(backend.find("Select", "selected", R"("Manager")").has_value()); + + backend.click(button(backend, "Open")); + CHECK(app.route() == kanban::client::Route::Board); +} + +TEST_CASE("kanban view: the board is a grid of lanes by columns whose cards move by drag-and-drop", "[kanban][view]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + BoardScreen open{client}; + auto& board = open.app.board(); + board.openNewTask(kanban::ColumnId{open.seeded.done}, kanban::SwimlaneId{open.seeded.lane}); + kanban::testing::submitForm(client, board.createTaskForm(), R"({"title":"Ship it"})"); + REQUIRE(client.settle([&] { return board.cardsIn(open.seeded.done, open.seeded.lane).size() == 1; })); + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, kanban::client::boardView(open.app)}; + REQUIRE(client.settle([&] { return backend.find("Button", "label", "Fix bug").has_value(); })); + + // The board's grid: two columns plus the lane-header column. The column and swimlane forms lay their fields out + // in grids of their own, which have fewer columns. + CHECK(backend.find("Grid", "columns", "3").has_value()); + CHECK_FALSE(backend.all("Scroll").empty()); + CHECK(shows(backend, "To Do (1)")); + CHECK(shows(backend, "Done (1)")); + CHECK(shows(backend, "role: Manager")); + + CHECK(backend.drag(button(backend, "Fix bug"), button(backend, "Ship it"))); + + REQUIRE(client.settle([&] { return board.cardsIn(open.seeded.done, open.seeded.lane).size() == 2; })); + CHECK(board.cardsIn(open.seeded.done, open.seeded.lane).front().label == "Fix bug"); + REQUIRE(client.settle([&] { return shows(backend, "To Do (0)") && shows(backend, "Done (2)"); })); +} + +TEST_CASE("kanban view: a drop carrying a non-task key is refused and moves nothing", "[kanban][view]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + BoardScreen open{client}; + auto& board = open.app.board(); + RecordingBackend backend; + namespace ui = morph::ui; + morph::ui::Mounted const mounted{ + client.runtime, backend, + ui::column({.children = {kanban::client::boardView(open.app), + ui::button({.label = "foreign", + .common = {.dragKey = std::optional{ui::Key{std::string{"7"}}}}}), + ui::button({.label = "zero", + .common = {.dragKey = std::optional{ui::Key{std::int64_t{0}}}}})}})}; + REQUIRE(client.settle([&] { return backend.find("Button", "label", "Fix bug").has_value(); })); + + CHECK_FALSE(backend.drag(button(backend, "foreign"), button(backend, "Fix bug"))); + CHECK_FALSE(backend.drag(button(backend, "zero"), button(backend, "Fix bug"))); + + CHECK(board.lastMoveOpId().empty()); + CHECK_FALSE(board.movesPending()); +} + +TEST_CASE("kanban view: the rules dialog lists, adds and removes rules", "[kanban][view]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + BoardScreen open{client}; + auto& rules = open.app.rules(); + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, kanban::client::rulesDialog(rules)}; + + rules.show(); + REQUIRE(client.settle([&] { return backend.find("Button", "label", "Add rule").has_value(); })); + CHECK(shows(backend, "Automation rules")); + kanban::testing::fill(rules.createRuleForm(), R"({"triggerColumnId":)" + std::to_string(open.seeded.done) + + R"(,"mutationType":"AddTag","mutationValue":"shipped"})"); + clickWhenEnabled(client, backend, "Add rule"); + REQUIRE(client.settle([&] { return shows(backend, R"(when moved to "Done": AddTag "shipped")"); })); + + backend.click(button(backend, "Remove")); + REQUIRE(client.settle([&rules] { return rules.rules().empty(); })); + backend.click(button(backend, "Close")); + CHECK_FALSE(rules.shown()); +} + +TEST_CASE("kanban view: the task dialog adds a comment and hands a picked file to the transfer", "[kanban][view]") { + DbFixture const fixture; + IssuerScope const issuer; + LocalClient client; + auto const transfer = std::make_shared(client.owner); + BoardScreen open{client, AppOptions{.attachments = transfer, .board = {}}}; + auto& detail = open.app.detail(); + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, kanban::client::taskDetailDialog(detail)}; + + detail.openTask(kanban::TaskId{open.seeded.task}); + REQUIRE(client.settle([&] { return backend.find("Button", "label", "Add comment").has_value(); })); + CHECK(shows(backend, "Fix bug")); + kanban::testing::fill(detail.addCommentForm(), R"({"body":"looks good"})"); + clickWhenEnabled(client, backend, "Add comment"); + // A Text, not the form's TextInput, which held the typed body before the submission. + REQUIRE(client.settle([&] { return backend.find("Text", "text", "looks good").has_value(); })); + + auto const pickers = backend.all("FilePicker"); + REQUIRE(pickers.size() == 1); + backend.pick(pickers.front(), "/tmp/report.pdf"); + REQUIRE(transfer->uploads.size() == 1); + CHECK(transfer->uploads.front().localPath == std::filesystem::path{"/tmp/report.pdf"}); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake build/all && cmake --build build/all --target ladder_kanban_tests` +Expected: compile error, `'app_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/kanban/app/app_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include "app/wiring.hpp" +#include "attachments/attachment_transfer.hpp" +#include "controllers/board_controller.hpp" +#include "controllers/projects_controller.hpp" +#include "controllers/rules_controller.hpp" +#include "controllers/session_controller.hpp" +#include "controllers/task_detail_controller.hpp" + +namespace kanban::client { + +/// @brief The screen the app shows. +enum class Route : std::uint8_t { SignIn, Projects, Board }; + +/// @brief What the app is configured with besides its wiring. +struct AppOptions { + /// @brief The attachment side channel's client; null disables attachments. + std::shared_ptr attachments; + /// @brief The board's offline queue and poll period. + BoardOptions board; +}; + +/// @brief The kanban client: its five controllers, and which screen they add up to. +class AppController { +public: + /// @param wiring The runtime, scheduler, bridge and owner every controller uses. + /// @param options Attachments and board options. + AppController(morph::examples::Wiring wiring, AppOptions options); + + /// @brief The screen to show. Tracked: signed out is sign-in, an open board is the board, else projects. + /// @return The route. + [[nodiscard]] Route route() const { return _route.get(); } + /// @brief The sign-in controller. @return It. + [[nodiscard]] SessionController& session() noexcept { return _session; } + /// @brief The project list controller. @return It. + [[nodiscard]] ProjectsController& projects() noexcept { return _projects; } + /// @brief The board controller. @return It. + [[nodiscard]] BoardController& board() noexcept { return _board; } + /// @brief The rules dialog controller. @return It. + [[nodiscard]] RulesController& rules() noexcept { return _rules; } + /// @brief The task dialog controller. @return It. + [[nodiscard]] TaskDetailController& detail() noexcept { return _detail; } + + /// @brief Opens a project's board. + /// @param project The project. + /// @param myRole The caller's role there. + void openProject(ProjectId project, Role myRole); + /// @brief Leaves the board for the project list, closing its dialogs. + void backToProjects(); + +private: + SessionController _session; + ProjectsController _projects; + BoardController _board; + RulesController _rules; + TaskDetailController _detail; + morph::reactive::Computed _route; +}; + +} // namespace kanban::client +``` + +Create `examples/kanban/app/app_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "app_controller.hpp" + +#include + +namespace kanban::client { + +AppController::AppController(morph::examples::Wiring wiring, AppOptions options) + : _session{wiring}, + _projects{wiring, _session}, + _board{wiring, std::move(options.board)}, + _rules{wiring, _board}, + _detail{wiring, _board, std::move(options.attachments)}, + _route{wiring.runtime, [this] { + if (!_session.signedIn()) { + return Route::SignIn; + } + return _board.isOpen() ? Route::Board : Route::Projects; + }} {} + +void AppController::openProject(ProjectId project, Role myRole) { + _projects.hideMembers(); + _board.open(project, myRole); +} + +void AppController::backToProjects() { + _rules.hide(); + _detail.close(); + _board.close(); + _projects.refresh(); +} + +} // namespace kanban::client +``` + +Create `examples/kanban/app/views/sign_in_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "controllers/session_controller.hpp" + +namespace kanban::client { + +/// @brief The sign-in screen: the schema-driven `Login` form. +/// @param session Its controller; it outlives the mounted view. +/// @return The screen. +[[nodiscard]] morph::ui::Node signInView(SessionController& session); + +} // namespace kanban::client +``` + +Create `examples/kanban/app/views/sign_in_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "views/sign_in_view.hpp" + +#include + +namespace kanban::client { + +morph::ui::Node signInView(SessionController& session) { + namespace ui = morph::ui; + return ui::column({ + .children = + { + ui::text({.text = "Sign in", .role = ui::TextRole::Heading}), + ui::text({.text = "Dev-mode login: a username, no password. The token the server mints for it is " + "real, server-signed and checked on every later action.", + .role = ui::TextRole::Muted}), + morph::forms::formView(session.loginForm(), {.submitLabel = "Sign in"}), + }, + .gap = 1, + }); +} + +} // namespace kanban::client +``` + +Create `examples/kanban/app/views/projects_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "app_controller.hpp" + +namespace kanban::client { + +/// @brief The project list, the create-project form, and the members panel with its role pickers. +/// @param app The app; it outlives the mounted view. +/// @return The screen. +[[nodiscard]] morph::ui::Node projectsView(AppController& app); + +} // namespace kanban::client +``` + +Create `examples/kanban/app/views/projects_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "views/projects_view.hpp" + +#include +#include +#include +#include +#include +#include + +#include "support/board_format.hpp" + +namespace kanban::client { + +namespace { + +namespace ui = morph::ui; +using morph::reactive::Signal; + +ui::Node projectRowView(AppController& app, Signal const& row) { + return ui::row({ + .children = + { + ui::text({.text = [&row] { return row.get().label; }, + .common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::button({.label = "Open", + .onClick = + [&app, &row] { + ProjectRow const project = row.peek(); + app.openProject(ProjectId{project.id}, project.role); + }}), + ui::button({.label = "Members", + .onClick = + [&app, &row] { + ProjectRow const project = row.peek(); + app.projects().showMembers(ProjectId{project.id}, project.name); + }}), + }, + .gap = 1, + }); +} + +ui::Node memberRowView(ProjectsController& projects, Signal const& member) { + std::vector options; + for (std::string const& name : ProjectsController::roleNames()) { + options.push_back(ui::SelectOption{.key = ui::Key{name}, .label = name}); + } + return ui::row({ + .children = + { + ui::text({.text = [&member] { return member.get().principal; }, + .common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::select({.options = std::move(options), + .selected = [&member]() -> std::optional { + return ui::Key{roleLabel(member.get().role)}; + }, + .onSelect = + [&projects, &member](ui::Key const& key) { + if (auto const* const role = std::get_if(&key); role != nullptr) { + projects.setRole(member.peek().principal, roleFromString(*role)); + } + }}), + ui::button({.label = "Remove", + .onClick = [&projects, &member] { projects.removeMember(member.peek().principal); }}), + }, + .gap = 1, + }); +} + +ui::Node membersPanel(ProjectsController& projects) { + return ui::panel({ + .title = [&projects] { return projects.membersTitle(); }, + .padding = 1, + .child = ui::column({ + .children = + { + ui::forEach( + [&projects] { return projects.members(); }, + [](MemberRow const& member) { return ui::Key{member.principal}; }, + [&projects](Signal const& member) { return memberRowView(projects, member); }), + morph::forms::formView(projects.addMemberForm(), {.submitLabel = "Add member"}), + ui::button({.label = "Close", .onClick = [&projects] { projects.hideMembers(); }}), + }, + .gap = 1, + }), + .common = {.visible = [&projects] { return projects.membersShown(); }, + .layout = {.width = ui::Sizing::stretch()}}, + }); +} + +} // namespace + +ui::Node projectsView(AppController& app) { + ProjectsController& projects = app.projects(); + return ui::column({ + .children = + { + ui::text({.text = [&projects] { return projects.errorText(); }, .role = ui::TextRole::Error}), + ui::row({ + .children = + { + ui::column({ + .children = + { + ui::text({.text = "Your projects", .role = ui::TextRole::Heading}), + morph::forms::formView(projects.createProjectForm(), + {.submitLabel = "Create project"}), + ui::forEach( + [&projects] { return projects.projects(); }, + [](ProjectRow const& project) { return ui::Key{project.id}; }, + [&app](Signal const& row) { return projectRowView(app, row); }), + }, + .gap = 1, + .common = {.layout = {.width = ui::Sizing::stretch()}}, + }), + membersPanel(projects), + }, + .gap = 2, + }), + }, + .gap = 1, + }); +} + +} // namespace kanban::client +``` + +Create `examples/kanban/app/views/board_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "app_controller.hpp" + +namespace kanban::client { + +/// @brief The board screen: header, sync banners, the column and swimlane forms, the grid of lanes by columns with +/// draggable cards, the activity panel, and the new-task, rules and task dialogs. +/// @param app The app; it outlives the mounted view. +/// @return The screen. +[[nodiscard]] morph::ui::Node boardView(AppController& app); + +/// @brief The rules dialog. +/// @param rules Its controller; it outlives the mounted view. +/// @return The dialog. +[[nodiscard]] morph::ui::Node rulesDialog(RulesController& rules); + +/// @brief The task dialog: comments, the add-comment form, attachments with download and upload pickers. +/// @param detail Its controller; it outlives the mounted view. +/// @return The dialog. +[[nodiscard]] morph::ui::Node taskDetailDialog(TaskDetailController& detail); + +} // namespace kanban::client +``` + +Create `examples/kanban/app/views/board_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "views/board_view.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace kanban::client { + +namespace { + +namespace ui = morph::ui; +using morph::reactive::Signal; + +[[nodiscard]] bool acceptsTask(ui::Key const& key) { return BoardController::taskOf(key).has_value(); } + +ui::Node cardView(AppController& app, Signal const& card) { + BoardController& board = app.board(); + TaskDetailController& detail = app.detail(); + return ui::button({ + .label = [&card] { return card.get().label; }, + .onClick = [&detail, &card] { detail.openTask(TaskId{card.peek().id}); }, + .common = {.dragKey = [&card]() -> std::optional { return ui::Key{card.get().id}; }, + .accepts = acceptsTask, + .onDrop = + [&board, &card](ui::Key const& key) { + if (std::optional const task = BoardController::taskOf(key)) { + CardRow const target = card.peek(); + board.moveTask(*task, ColumnId{target.columnId}, SwimlaneId{target.laneId}, + target.position); + } + }}, + }); +} + +ui::Node cellView(AppController& app, ColumnHead const& column, LaneHead const& lane) { + BoardController& board = app.board(); + std::int64_t const columnId = column.id; + std::optional const laneId = lane.id; + return ui::column({ + .children = + { + ui::button({.label = "+ task", + .onClick = + [&board, columnId, laneId] { + if (laneId) { + board.openNewTask(ColumnId{columnId}, SwimlaneId{*laneId}); + } + }, + .common = {.enabled = lane.canAddTask}}), + ui::forEach([&board, columnId, laneId] { return board.cardsIn(columnId, laneId); }, + [](CardRow const& card) { return ui::Key{card.id}; }, + [&app](Signal const& card) { return cardView(app, card); }), + }, + .gap = 0, + .common = {.accepts = acceptsTask, + .onDrop = + [&board, columnId, laneId](ui::Key const& key) { + if (std::optional const task = BoardController::taskOf(key)) { + board.dropAtEnd(*task, ColumnId{columnId}, laneId); + } + }}, + }); +} + +ui::Node gridView(AppController& app, BoardShape const& shape) { + BoardController& board = app.board(); + std::vector cells; + cells.push_back(ui::GridCell{.node = ui::spacer()}); + for (ColumnHead const& column : shape.columns) { + std::int64_t const columnId = column.id; + cells.push_back(ui::GridCell{.node = ui::text({.text = [&board, columnId] { return board.headerOf(columnId); }, + .role = ui::TextRole::Heading})}); + } + for (LaneHead const& lane : shape.lanes) { + cells.push_back(ui::GridCell{.node = ui::text({.text = lane.label, .role = ui::TextRole::Heading})}); + for (ColumnHead const& column : shape.columns) { + cells.push_back(ui::GridCell{.node = cellView(app, column, lane)}); + } + } + return ui::grid({.columns = static_cast(shape.columns.size()) + 1, .cells = std::move(cells), .gap = 1}); +} + +ui::Node activityPanel(BoardController& board) { + return ui::panel({ + .title = "Activity", + .padding = 1, + .child = ui::forEach( + [&board] { return board.activity(); }, [](ActivityRow const& row) { return ui::Key{row.index}; }, + [](Signal const& row) { + return ui::column({.children = {ui::text({.text = [&row] { return row.get().summary; }}), + ui::text({.text = [&row] { return row.get().meta; }, + .role = ui::TextRole::Muted})}}); + }), + .common = {.layout = {.width = ui::Sizing::fixed(32)}}, + }); +} + +ui::Node newTaskDialog(BoardController& board) { + return ui::dialog({.open = [&board] { return board.newTaskOpen(); }, + .title = [&board] { return board.newTaskTitle(); }, + .child = morph::forms::formView(board.createTaskForm(), {.submitLabel = "Create task"}), + .onDismiss = [&board] { board.closeNewTask(); }}); +} + +} // namespace + +ui::Node boardView(AppController& app) { + BoardController& board = app.board(); + return ui::column({ + .children = + { + ui::row({.children = {ui::button({.label = "< Back", .onClick = [&app] { app.backToProjects(); }}), + ui::text({.text = [&board] { return board.title(); }, + .role = ui::TextRole::Heading}), + ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::button({.label = "Rules", .onClick = [&app] { app.rules().show(); }}), + ui::text({.text = [&board] { return board.roleText(); }, + .role = ui::TextRole::Muted})}, + .gap = 1}), + ui::text({.text = [&board] { return board.deadLetterText(); }, .role = ui::TextRole::Error}), + ui::text({.text = [&board] { return board.syncText(); }, .role = ui::TextRole::Muted}), + ui::row({.children = {ui::panel({.title = "New column", + .padding = 1, + .child = morph::forms::formView(board.createColumnForm(), + {.submitLabel = "Add column"})}), + ui::panel({.title = "New swimlane", + .padding = 1, + .child = morph::forms::formView(board.createSwimlaneForm(), + {.submitLabel = "Add swimlane"})})}, + .gap = 2}), + ui::text({.text = [&board] { return board.errorText(); }, .role = ui::TextRole::Error}), + ui::row({.children = {ui::forEach( + [&board] { return board.shapes(); }, + [](BoardShape const& shape) { return ui::Key{shape.key}; }, + [&app](Signal const& shape) { + return ui::scroll({.child = gridView(app, shape.peek()), + .axis = ui::Axis::Horizontal}); + }), + activityPanel(board)}, + .gap = 2}), + newTaskDialog(board), + rulesDialog(app.rules()), + taskDetailDialog(app.detail()), + }, + .gap = 1, + }); +} + +ui::Node rulesDialog(RulesController& rules) { + return ui::dialog({ + .open = [&rules] { return rules.shown(); }, + .title = "Automation rules", + .child = ui::column({ + .children = + { + ui::forEach( + [&rules] { return rules.rules(); }, [](RuleRow const& rule) { return ui::Key{rule.id}; }, + [&rules](Signal const& rule) { + return ui::row({.children = {ui::text({.text = [&rule] { return rule.get().text; }, + .common = {.layout = {.width = + ui::Sizing::stretch()}}}), + ui::button({.label = "Remove", .onClick = [&rules, &rule] { + rules.remove(RuleId{rule.peek().id}); + }})}, + .gap = 1}); + }), + morph::forms::formView(rules.createRuleForm(), {.submitLabel = "Add rule"}), + ui::text({.text = [&rules] { return rules.errorText(); }, .role = ui::TextRole::Error}), + ui::button({.label = "Close", .onClick = [&rules] { rules.hide(); }}), + }, + .gap = 1, + }), + .onDismiss = [&rules] { rules.hide(); }, + }); +} + +ui::Node taskDetailDialog(TaskDetailController& detail) { + bool const transfers = detail.attachmentsAvailable(); + return ui::dialog({ + .open = [&detail] { return detail.isOpen(); }, + .title = [&detail] { return detail.title(); }, + .child = ui::column({ + .children = + { + ui::text({.text = "Comments", .role = ui::TextRole::Heading}), + ui::forEach( + [&detail] { return detail.comments(); }, [](CommentRow const& row) { return ui::Key{row.index}; }, + [](Signal const& comment) { + return ui::column( + {.children = {ui::text({.text = [&comment] { return comment.get().principal; }, + .role = ui::TextRole::Muted}), + ui::text({.text = [&comment] { return comment.get().body; }})}}); + }), + morph::forms::formView(detail.addCommentForm(), {.submitLabel = "Add comment"}), + ui::text({.text = "Attachments", .role = ui::TextRole::Heading}), + ui::forEach( + [&detail] { return detail.attachments(); }, + [](AttachmentRow const& row) { return ui::Key{row.id}; }, + [&detail, transfers](Signal const& attachment) { + return ui::row( + {.children = {ui::text({.text = [&attachment] { return attachment.get().line; }, + .common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::filePicker({.path = std::string{}, + .mode = ui::FilePickerMode::Save, + .onPicked = + [&detail, &attachment](std::string const& path) { + detail.download(attachment.peek().id, path); + }, + .common = {.enabled = transfers}})}, + .gap = 1}); + }), + ui::filePicker({.path = std::string{}, + .mode = ui::FilePickerMode::Open, + .onPicked = [&detail](std::string const& path) { detail.upload(path); }, + .common = {.enabled = transfers}}), + ui::text({.text = [&detail] { return detail.statusText(); }, .role = ui::TextRole::Muted}), + ui::button({.label = "Close", .onClick = [&detail] { detail.close(); }}), + }, + .gap = 1, + }), + .onDismiss = [&detail] { detail.close(); }, + }); +} + +} // namespace kanban::client +``` + +Create `examples/kanban/app/views/root_view.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "app_controller.hpp" + +namespace kanban::client { + +/// @brief The whole app: the header line and the screen the route names. +/// @param app The app; it outlives the mounted view. +/// @return The root node. +[[nodiscard]] morph::ui::Node rootView(AppController& app); + +} // namespace kanban::client +``` + +Create `examples/kanban/app/views/root_view.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "views/root_view.hpp" + +#include "views/board_view.hpp" +#include "views/projects_view.hpp" +#include "views/sign_in_view.hpp" + +namespace kanban::client { + +morph::ui::Node rootView(AppController& app) { + namespace ui = morph::ui; + SessionController& session = app.session(); + return ui::column({ + .children = + { + ui::row({.children = {ui::text({.text = "kanban", .role = ui::TextRole::Heading}), + ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::text({.text = [&session] { return session.headerText(); }, + .role = ui::TextRole::Muted})}, + .gap = 1}), + ui::switchOn([&app] { return app.route(); }, + {{Route::SignIn, signInView(session)}, + {Route::Projects, projectsView(app)}, + {Route::Board, boardView(app)}}), + }, + .gap = 1, + }); +} + +} // namespace kanban::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][client],[kanban][view]" +git grep -n -E '#include " +``` + +--- +### Task K13: The application, the composition root, the Qt attachment transfer, and the frontend smoke tests + +`kanban::client::makeApplication(ctx, env)` connects (Part 6's `connect`: local or remote, on the frontend's +executor), installs the local-mode token issuer for as long as a local app lives, and builds the `AppController`. +Kanban's two knobs come from the environment, so `ui/main.cpp` is exactly spec 4 §3's composition root: + +- **The attachment transfer.** Without `--server` there is none (a local app has no side channel). With it, the side + channel is `KANBAN_ATTACHMENT_SERVER` when set, else `attachmentServerFor(*env.server)` (the server's host, port + 8769); on the `"qt"` frontend the transfer is Qt's network stack when the build has it, otherwise the POSIX HTTP + client. +- **The offline queue.** Only against a server (a local app has no network to lose), and only in a build with + `MORPH_BUILD_OFFLINE_SQLITE`: its file is `KANBAN_OFFLINE_QUEUE_DB`, as the Qt client read it, else + `kanban-offline-queue.db`. + +Both variables are read through `ui::processEnvironment()`. The Qt transfer stays out of `app/`'s headers: its own +header is Qt-free (a factory returning `IAttachmentTransfer`), and `qt/qt_attachment_transfer.cpp` is compiled into +`ladder_kanban_app` only when Qt Quick is built, with `KANBAN_HAS_QT_TRANSFER` defined to `0` or `1` so the +application knows whether it may call it — the same shape as Part 6's `MORPH_EXAMPLES_TRANSPORT_QT`. + +**Files:** +- Create: `examples/kanban/app/kanban_application.hpp`, `examples/kanban/app/kanban_application.cpp` +- Create: `examples/kanban/ui/main.cpp` +- Create: `examples/kanban/qt/qt_attachment_transfer.hpp`, `examples/kanban/qt/qt_attachment_transfer.cpp` +- Modify: `examples/kanban/CMakeLists.txt` — append the Qt-transfer block below +- Test: `examples/kanban/tests/smoke/test_kanban_frontends.cpp` + +**Interfaces:** +- Consumes: `morph::examples::AppEnvironment`, `connect`, `LocalSetup`, `Connection::bridge()`/`callbacks()`, + `Wiring` (Part 6); `ui::AppContext`, `ui::Application`, `ui::selectFrontend`, `ui::FrontendOption`, + `ui::processEnvironment()` (Part 2); `morph::tui::frontendOption()` (Part 3, ``); + `morph::qt_quick::frontendOption(int&, char**)` (Part 4, ``); + `morph::examples::testing::runFrontendSmoke`, `SmokeFrontend` (Part 6); `AppController`, `AppOptions`, + `rootView`, `HttpAttachmentTransfer`, `attachmentServerFor`, `OfflineConfig`, `BoardOptions` (Tasks K5–K12); + `kanban::db::setup`, `kanban::auth::setTokenIssuer`. +- Produces: `kanban::client::makeApplication(ui::AppContext&, examples::AppEnvironment const&) -> + std::unique_ptr`; `kanban::qt::makeQtAttachmentTransfer(std::string const& baseUrl, + exec::IExecutor& callbacks) -> std::shared_ptr`; the `kanban` binary. + +- [ ] **Step 1: Write the failing test** + +Create `examples/kanban/tests/smoke/test_kanban_frontends.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The application mounts and quits on every frontend this build has (spec 4 §7 rule 5). This file is +// ladder_kanban_smoke_tests, a binary of its own: its main owns no Qt application object. + +#if MORPH_EXAMPLE_HAS_TUI || MORPH_EXAMPLE_HAS_QT_QUICK + +#include +#include +#include + +#include "app/app_environment.hpp" +#include "kanban_application.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/frontend_smoke.hpp" + +namespace { + +using morph::examples::testing::SmokeFrontend; + +// The test binary's own database, so the app's local setup re-applies the schema the fixture already has. +[[nodiscard]] morph::examples::AppEnvironment smokeEnvironment() { + morph::examples::AppEnvironment env; + // NOLINTNEXTLINE(concurrency-mt-unsafe): read on the test thread before the app starts any thread. + env.db = morph::ladder::testkit::DbFixture::computeConnectionString(std::getenv("ODBC_CONNECTION_STRING")); + env.user = "alice"; + return env; +} + +void smoke(SmokeFrontend frontend) { + morph::ladder::testkit::DbFixture const fixture; + auto const env = smokeEnvironment(); + morph::examples::testing::runFrontendSmoke( + [&env](morph::ui::AppContext& ctx) { return kanban::client::makeApplication(ctx, env); }, frontend); +} + +} // namespace + +#if MORPH_EXAMPLE_HAS_TUI +TEST_CASE("kanban mounts and quits on the TUI", "[kanban][smoke]") { smoke(SmokeFrontend::Tui); } +#endif + +#if MORPH_EXAMPLE_HAS_QT_QUICK +TEST_CASE("kanban mounts and quits on Qt Quick", "[kanban][smoke]") { smoke(SmokeFrontend::QtQuick); } +#endif + +#endif +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_kanban_smoke_tests` (`morph_add_rung` builds +`tests/smoke/*.cpp` into that binary, Part 6). +Expected: compile error, `'kanban_application.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/kanban/app/kanban_application.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "app/app_environment.hpp" + +namespace kanban::client { + +/// @brief Builds the kanban application on a frontend's context: connects (local or `--server`), in local mode +/// installs the token issuer sign-in mints from, and against a server adds the attachment side channel +/// (`KANBAN_ATTACHMENT_SERVER`, else the server's host on port 8769) and, in a build with +/// `MORPH_BUILD_OFFLINE_SQLITE`, the offline move queue (`KANBAN_OFFLINE_QUEUE_DB`, else +/// `kanban-offline-queue.db`). +/// @param ctx The frontend's context. Borrowed: it outlives the application. +/// @param env `--server`, `--db`, `--user`. +/// @return The application. +/// @throws morph::examples::TransportError when `--server` is given and this frontend has no transport for it. +[[nodiscard]] std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env); + +} // namespace kanban::client +``` + +Create `examples/kanban/app/kanban_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "kanban_application.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/transport.hpp" +#include "app/wiring.hpp" +#include "app_controller.hpp" +#include "attachments/attachment_transfer.hpp" +#include "controllers/offline_config.hpp" +#include "http/http_attachment_transfer.hpp" +#include "kanban/auth/kanban_authorizer.hpp" +#include "kanban/db/database.hpp" +#include "views/root_view.hpp" + +#if KANBAN_HAS_QT_TRANSFER +#include "qt_attachment_transfer.hpp" +#endif + +namespace kanban::client { + +namespace { + +constexpr std::string_view kDefaultDatabase = "DRIVER=SQLite3;Database=kanban.db;Timeout=5000"; +constexpr std::string_view kDefaultOfflineQueue = "kanban-offline-queue.db"; +// A local app is its own server: it signs and checks its own tokens, so a fixed development secret serves. +constexpr std::string_view kLocalSecret = "local-mode-development-secret"; +constexpr std::size_t kLocalWorkers = 4; + +// Installs the issuer `Login` mints tokens from while a local app lives, and removes it after. +class LocalIssuer { +public: + explicit LocalIssuer(bool install) : _installed{install} { + if (_installed) { + auth::setTokenIssuer( + std::make_shared(std::string{kLocalSecret}, morph::session::hmacSha256)); + } + } + ~LocalIssuer() { + if (_installed) { + auth::setTokenIssuer(nullptr); + } + } + LocalIssuer(LocalIssuer const&) = delete; + LocalIssuer& operator=(LocalIssuer const&) = delete; + LocalIssuer(LocalIssuer&&) = delete; + LocalIssuer& operator=(LocalIssuer&&) = delete; + +private: + bool _installed; +}; + +[[nodiscard]] morph::examples::AppEnvironment withDatabase(morph::examples::AppEnvironment env) { + if (env.db.empty()) { + env.db = std::string{kDefaultDatabase}; + } + return env; +} + +// The side channel's client: none without a server; Qt's network stack on the "qt" frontend when this build has it, +// the POSIX HTTP client otherwise. +[[nodiscard]] std::shared_ptr transferFor(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env) { + if (!env.server) { + return nullptr; + } + std::optional url = morph::ui::processEnvironment()("KANBAN_ATTACHMENT_SERVER"); + if (!url) { + url = attachmentServerFor(*env.server); + } + if (!url) { + return nullptr; + } +#if KANBAN_HAS_QT_TRANSFER + if (ctx.frontendName() == "qt") { + return qt::makeQtAttachmentTransfer(*url, ctx.executor()); + } +#endif + return std::make_shared(*url, ctx.executor()); +} + +// The durable queue for moves made while the server is unreachable: only against a server, and only in a build +// that has the queue. +[[nodiscard]] std::optional offlineQueueFor(morph::examples::AppEnvironment const& env) { +#ifdef MORPH_BUILD_OFFLINE_SQLITE + if (env.server) { + std::optional const path = morph::ui::processEnvironment()("KANBAN_OFFLINE_QUEUE_DB"); + return OfflineConfig{.queuePath = path.value_or(std::string{kDefaultOfflineQueue})}; + } +#else + static_cast(env); +#endif + return std::nullopt; +} + +class KanbanApplication final : public morph::ui::Application { +public: + KanbanApplication(morph::ui::AppContext& ctx, morph::examples::AppEnvironment const& env) + : _issuer{!env.server.has_value()}, + _connection{morph::examples::connect( + ctx, withDatabase(env), + morph::examples::LocalSetup{.setupDatabase = [](std::string const& database) { db::setup(database); }, + .workers = kLocalWorkers})}, + _app{morph::examples::Wiring{.runtime = ctx.runtime(), + .scheduler = ctx.scheduler(), + .bridge = _connection->bridge(), + .callbacks = _connection->callbacks()}, + AppOptions{.attachments = transferFor(ctx, env), + .board = BoardOptions{.offline = offlineQueueFor(env), + .pollEvery = std::chrono::milliseconds{3000}}}} {} + + [[nodiscard]] morph::ui::Node view() override { return rootView(_app); } + +private: + LocalIssuer _issuer; + std::unique_ptr _connection; + AppController _app; +}; + +} // namespace + +std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env) { + return std::make_unique(ctx, env); +} + +} // namespace kanban::client +``` + +Create `examples/kanban/qt/qt_attachment_transfer.hpp` — Qt-free, so the application can include it: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include "attachments/attachment_transfer.hpp" + +namespace kanban::qt { + +/// @brief `IAttachmentTransfer` over `QNetworkAccessManager`, for the Qt Quick frontend: requests run on the Qt +/// event loop that frontend drives, so it needs no thread of its own and works on every Qt platform. Built +/// only with Qt Quick (`KANBAN_HAS_QT_TRANSFER`). +/// @param baseUrl The side channel, e.g. `http://127.0.0.1:8769`. +/// @param callbacks Owns the returned completions: the frontend's executor. Borrowed. +/// @return The transfer; destroying it aborts requests in flight, whose completions never settle. +[[nodiscard]] std::shared_ptr makeQtAttachmentTransfer(std::string const& baseUrl, + morph::exec::IExecutor& callbacks); + +} // namespace kanban::qt +``` + +Create `examples/kanban/qt/qt_attachment_transfer.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "qt_attachment_transfer.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace kanban::qt { + +namespace { + +template +[[nodiscard]] std::shared_ptr::Promise> sharedPromise( + typename morph::async::Completion::Promise promise) { + return std::make_shared::Promise>(std::move(promise)); +} + +[[nodiscard]] std::exception_ptr failure(std::string const& message) { + return std::make_exception_ptr(std::runtime_error{message}); +} + +class QtAttachmentTransfer final : public client::IAttachmentTransfer { +public: + QtAttachmentTransfer(std::string const& baseUrl, morph::exec::IExecutor& callbacks) + : _baseUrl{QString::fromStdString(baseUrl)}, + _callbacks{&callbacks}, + _network{std::make_unique()} {} + + // Qt carries HTTP on every platform it runs on. + [[nodiscard]] bool available() const noexcept override { return true; } + + [[nodiscard]] morph::async::Completion upload(client::UploadRequest request) override { + auto settleable = morph::async::Completion::makeSettleable(_callbacks); + auto promise = sharedPromise(std::move(settleable.second)); + QFile file{QString::fromStdString(request.localPath.string())}; + if (!file.open(QIODevice::ReadOnly)) { + promise->reject(failure("could not open '" + request.localPath.string() + "' for reading")); + return std::move(settleable.first); + } + QByteArray const bytes = file.readAll(); + std::string const contentType = client::contentTypeFor(request.localPath); + QNetworkRequest post{QUrl{_baseUrl + QStringLiteral("/attachments")}}; + post.setRawHeader("X-Attachment-Content-Type", QByteArray::fromStdString(contentType)); + post.setRawHeader("Authorization", QByteArray::fromStdString("Bearer " + request.bearerToken)); + QNetworkReply* const reply = _network->post(post, bytes); + QObject::connect(reply, &QNetworkReply::finished, reply, + [reply, promise, filename = request.localPath.filename().string(), contentType, + size = static_cast(bytes.size())] { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + promise->reject(failure("upload failed: " + reply->errorString().toStdString())); + return; + } + QString const storageKey = QJsonDocument::fromJson(reply->readAll()) + .object() + .value(QStringLiteral("storageKey")) + .toString(); + if (storageKey.isEmpty()) { + promise->reject(failure("upload failed: the server's reply carried no storage key")); + return; + } + promise->resolve(client::UploadedBlob{.storageKey = storageKey.toStdString(), + .filename = filename, + .contentType = contentType, + .sizeBytes = size}); + }); + return std::move(settleable.first); + } + + [[nodiscard]] morph::async::Completion download(client::DownloadRequest request) override { + auto settleable = morph::async::Completion::makeSettleable(_callbacks); + auto promise = sharedPromise(std::move(settleable.second)); + QNetworkRequest get{ + QUrl{_baseUrl + QStringLiteral("/attachments/") + QString::fromStdString(request.storageKey)}}; + get.setRawHeader("Authorization", QByteArray::fromStdString("Bearer " + request.bearerToken)); + QNetworkReply* const reply = _network->get(get); + QObject::connect(reply, &QNetworkReply::finished, reply, [reply, promise, target = request.localPath] { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + promise->reject(failure("download failed: " + reply->errorString().toStdString())); + return; + } + QFile file{QString::fromStdString(target.string())}; + if (!file.open(QIODevice::WriteOnly) || file.write(reply->readAll()) < 0) { + promise->reject(failure("could not write '" + target.string() + "'")); + return; + } + promise->resolve(target.string()); + }); + return std::move(settleable.first); + } + +private: + QString _baseUrl; + morph::exec::IExecutor* _callbacks; + std::unique_ptr _network; +}; + +} // namespace + +std::shared_ptr makeQtAttachmentTransfer(std::string const& baseUrl, + morph::exec::IExecutor& callbacks) { + return std::make_shared(baseUrl, callbacks); +} + +} // namespace kanban::qt +``` + +Create `examples/kanban/ui/main.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The kanban client: picks the Qt Quick or terminal frontend at runtime (--ui=qt|tui, MORPH_UI, or the first that +// can run here) and hands it the application. +// +// kanban [--ui=qt|tui] [--server ws://host:port] [--db ] [--user ] +// +// Environment: KANBAN_ATTACHMENT_SERVER names the attachment side channel (default: the server's host, port 8769); +// KANBAN_OFFLINE_QUEUE_DB names the offline move queue (builds with MORPH_BUILD_OFFLINE_SQLITE). + +#include +#include +#include +#include + +#include "app/app_environment.hpp" +#include "kanban_application.hpp" + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#endif +#if MORPH_EXAMPLE_HAS_TUI +#include +#endif + +int main(int argc, char** argv) { + try { + auto const env = morph::examples::AppEnvironment::fromArgs(argc, argv); + std::vector built; +#if MORPH_EXAMPLE_HAS_QT_QUICK + built.push_back(morph::qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_EXAMPLE_HAS_TUI + built.push_back(morph::tui::frontendOption()); +#endif + auto const frontend = morph::ui::selectFrontend(built, argc, argv); + return frontend->run([&env](morph::ui::AppContext& ctx) { return kanban::client::makeApplication(ctx, env); }); + } catch (std::exception const& error) { + std::cerr << "kanban: " << error.what() << '\n'; + return 1; + } +} +``` + +Append to `examples/kanban/CMakeLists.txt`: + +```cmake +# The Qt Quick frontend's attachment transfer uses Qt's network stack. It is compiled into ladder_kanban_app only +# when that frontend is built; its header is Qt-free, and KANBAN_HAS_QT_TRANSFER (always 0 or 1) tells the +# application whether it may pick it when the running frontend is "qt". +if(TARGET ladder_kanban_app) + if(TARGET morph_qt_quick) + find_package(Qt6 6.5 REQUIRED COMPONENTS Network) + target_sources(ladder_kanban_app PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/qt/qt_attachment_transfer.cpp") + target_include_directories(ladder_kanban_app PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/qt") + target_link_libraries(ladder_kanban_app PRIVATE Qt6::Network) + target_compile_definitions(ladder_kanban_app PRIVATE KANBAN_HAS_QT_TRANSFER=1) + else() + target_compile_definitions(ladder_kanban_app PRIVATE KANBAN_HAS_QT_TRANSFER=0) + endif() +endif() +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_smoke_tests kanban +./build/all/examples/kanban/ladder_kanban_smoke_tests +git grep -n -E '#include " +``` + +--- +### Task K14: The headless test client drives the board controller + +`tests/test_kanban_process_separation.cpp` spawns real client processes over a real WebSocket and SIGKILLs one. +The client binary stays a Qt-core process (its transport is `QtWebSocketBackend`), but it now runs the same +`BoardController` and `TaskDetailController` the app runs, on a `reactive::Runtime` over a `QtExecutor`, with a +`QTimer`-backed `Scheduler` for the board's polling. Its command line and exit codes do not change, so the +process-separation test does not change. It moves out of `src/headless/` (whose `morph_add_rung` convention needs a +`gui_lib`) into `headless/`, built by the rung's own `CMakeLists.txt`. + +**Files:** +- Move: `examples/kanban/src/headless/main.cpp` → `examples/kanban/headless/main.cpp` (then rewrite it) +- Modify: `examples/kanban/CMakeLists.txt` — append the headless block below +- Test: `examples/kanban/tests/test_kanban_process_separation.cpp` (unchanged) + +**Interfaces:** +- Consumes: `BoardController`, `TaskDetailController` (Tasks K6, K11); `morph::examples::Wiring` (Part 6); + `FormSession::assign` (Part 5); `morph::qt::QtExecutor`, + `morph::qt::QtWebSocketBackend` (`morph/qt/`); `reactive::Scheduler`, `TimerHandle(std::function)` + (Part 1). +- Produces: the `ladder_kanban_headless` target and `MORPH_LADDER_HEADLESS_BIN` on `ladder_kanban_tests`. + +- [ ] **Step 1: Write the failing check** + +The test exists; the failing state is the move: + +```bash +git mv examples/kanban/src/headless/main.cpp examples/kanban/headless/main.cpp +cmake build/all && cmake --build build/all --target ladder_kanban_tests +./build/all/examples/kanban/ladder_kanban_tests "[kanban][process]" +``` + +- [ ] **Step 2: Run it to verify it fails** + +Expected: no test case matches `[kanban][process]` (Catch2 reports "No test cases matched"), because +`MORPH_LADDER_HEADLESS_BIN` is no longer defined — `morph_add_rung` finds no `src/headless/*.cpp` — and the whole +file compiles to nothing. + +- [ ] **Step 3: Implement** + +Replace the whole of `examples/kanban/headless/main.cpp` with: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// Headless kanban client, spawned as a separate process by testkit/process_pool.hpp. +// +// Runs the board and task-dialog controllers the real client runs, over a real WebSocket: they are the layer where +// a lost connection has to be survivable (examples/TESTING.md). +// +// Usage: ladder_kanban_headless --url --project [options] +// --url server to connect to (required) +// --project project whose board to open (required) +// --token session token; the server's authorizer rejects us without one +// --principal

principal name to send alongside the token +// --comments add n comments to the board's first task, then exit 0 +// --hold attach, print ATTACHED, then block forever -- the crash test kills an attached client +// +// Exit codes name the step that failed: +// 10 connect failed 11 openBoard failed 12 openBoard timed out +// 13 no task to comment on 14 comment failed/timed out +// 2 bad arguments + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/board_controller.hpp" +#include "controllers/task_detail_controller.hpp" + +namespace { + +using namespace std::chrono_literals; + +// Timers on the Qt event loop this process runs: the board's polling arms one. +class QtTimerScheduler final : public morph::reactive::Scheduler { +public: + [[nodiscard]] morph::reactive::TimerHandle after(std::chrono::milliseconds delay, + std::function fn) override { + return arm(delay, std::move(fn), true); + } + [[nodiscard]] morph::reactive::TimerHandle every(std::chrono::milliseconds period, + std::function fn) override { + return arm(period, std::move(fn), false); + } + +private: + [[nodiscard]] static morph::reactive::TimerHandle arm(std::chrono::milliseconds interval, std::function fn, + bool singleShot) { + auto timer = std::make_shared(); + timer->setSingleShot(singleShot); + QObject::connect(timer.get(), &QTimer::timeout, timer.get(), std::move(fn)); + timer->start(interval); + return morph::reactive::TimerHandle{[timer] { timer->stop(); }}; + } +}; + +// Spins this process's event loop until @p done or the budget runs out. +bool pumpUntil(std::function const& done, std::chrono::milliseconds budget) { + auto const deadline = std::chrono::steady_clock::now() + budget; + while (!done() && std::chrono::steady_clock::now() < deadline) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + std::this_thread::sleep_for(5ms); + } + return done(); +} + +struct Options { + QString url; + std::string token; + std::string principal{"alice"}; + std::int64_t projectId = 0; + int comments = 0; + bool hold = false; +}; + +} // namespace + +int main(int argc, char* argv[]) { + QCoreApplication app{argc, argv}; + + Options opts; + for (int idx = 1; idx < argc; ++idx) { + std::string const arg{argv[idx]}; + auto const next = [&]() -> std::string { return idx + 1 < argc ? argv[++idx] : std::string{}; }; + if (arg == "--url") { + opts.url = QString::fromStdString(next()); + } else if (arg == "--token") { + opts.token = next(); + } else if (arg == "--principal") { + opts.principal = next(); + } else if (arg == "--project") { + opts.projectId = std::stoll(next()); + } else if (arg == "--comments") { + opts.comments = std::stoi(next()); + } else if (arg == "--hold") { + opts.hold = true; + } + } + if (opts.url.isEmpty() || opts.projectId == 0) { + std::cerr << "usage: ladder_kanban_headless --url --project [--token t] [--comments n] [--hold]\n"; + return 2; + } + + auto backend = + std::make_unique(QUrl{opts.url}, morph::model::detail::defaultDispatcher(), + morph::model::detail::defaultRegistry(), std::nullopt); + if (!backend->waitForConnected(5000)) { + std::cerr << "headless: connect failed\n"; + return 10; + } + morph::qt::QtExecutor executor; + morph::bridge::Bridge bridge{std::move(backend), executor}; + morph::session::Context session; + session.principal = opts.principal; + session.token = opts.token; + bridge.setDefaultSession(session); + morph::reactive::Runtime runtime{executor}; + QtTimerScheduler scheduler; + morph::examples::Wiring const wiring{ + .runtime = runtime, .scheduler = scheduler, .bridge = bridge, .callbacks = executor}; + kanban::client::BoardController board{wiring}; + kanban::client::TaskDetailController detail{wiring, board, nullptr}; + + board.open(kanban::ProjectId{opts.projectId}, kanban::Role::Member); + if (!pumpUntil([&board] { return board.board().has_value() || !board.errorText().empty(); }, 10s)) { + std::cerr << "headless: openBoard timed out\n"; + return 12; + } + if (!board.board().has_value()) { + std::cerr << "headless: openBoard failed: " << board.errorText() << "\n"; + return 11; + } + + if (opts.hold) { + // Attached, and staying attached: the parent kills us only after this line, so the kill lands on a client + // the server holds a live connection scope for. + std::cout << "ATTACHED\n"; + std::cout.flush(); + for (;;) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + std::this_thread::sleep_for(50ms); + } + } + + for (int done = 0; done < opts.comments; ++done) { + if (!board.board().has_value() || board.board()->tasks.empty()) { + std::cerr << "headless: --comments given but the board has no task\n"; + return 13; + } + detail.openTask(board.board()->tasks.front().id); + auto& form = detail.addCommentForm(); + form.assign("body", R"("from a separate process")"); + if (!form.ready()) { + std::cerr << "headless: the comment form is not ready\n"; + return 14; + } + form.submit(); + bool const settled = pumpUntil([&form] { return !form.pending(); }, 10s); + if (!settled || form.lastError() != nullptr || !form.lastReply().has_value()) { + std::cerr << "headless: addComment failed: " + << (settled ? morph::reactive::errorMessage(form.lastError()) : std::string{"timed out"}) << "\n"; + return 14; + } + } + return 0; +} +``` + +Append to `examples/kanban/CMakeLists.txt`: + +```cmake +# The headless test client: a Qt-core process that runs the board controller over a real WebSocket. +# tests/test_kanban_process_separation.cpp spawns it. +if(TARGET ladder_kanban_app AND TARGET morph_qt_impl AND NOT EMSCRIPTEN) + add_executable(ladder_kanban_headless "${CMAKE_CURRENT_SOURCE_DIR}/headless/main.cpp") + target_link_libraries(ladder_kanban_headless PRIVATE ladder_kanban_app morph::qt morph_qt_impl Qt6::Core) + target_compile_features(ladder_kanban_headless PRIVATE cxx_std_23) + if(COMMAND morph_suppress_test_dialogs) + morph_suppress_test_dialogs(ladder_kanban_headless) + endif() + apply_bigobj(ladder_kanban_headless) + if(AF_COVERAGE) + apply_coverage(ladder_kanban_headless TEST) + endif() + if(DEFINED AF_SANITIZER) + apply_sanitizers(ladder_kanban_headless ${AF_SANITIZER}) + endif() + if(TARGET ladder_kanban_tests) + target_compile_definitions(ladder_kanban_tests PRIVATE + MORPH_LADDER_HEADLESS_BIN="$") + add_dependencies(ladder_kanban_tests ladder_kanban_headless) + endif() +endif() +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests ladder_kanban_headless +./build/all/examples/kanban/ladder_kanban_tests "[kanban][process]" +``` + +Expected: PASS, 2 cases ("real client processes drive one shared board" sees 4 comments; "a killed client's +models are reclaimed"). + +Mutation check: in the headless comment loop, delete `form.submit();`. Expected FAIL in "real client processes +drive one shared board" (every child exits 14). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add -A examples/kanban/src/headless examples/kanban/headless examples/kanban/CMakeLists.txt +git commit -m "wip(kanban): the headless test client runs the board controller + +Signed-off-by: Christian Parpart " +``` + +--- +### Task K15: Retire kanban's QML stack; the README's client section + +Every claim the deleted files made now has a controller, view or smoke test (the table below). This task deletes +the QML, the bridges, the presenters and the old desktop main, the tests that existed only for them, and every +pointer to them outside `docs/superpowers/` (dated plans) and `CHANGELOG.md` (published entries do not move). + +| Deleted test file | What it pinned | Where that claim lives now | +|---|---|---| +| `test_project_admin_presenter.cpp` | refresh, create, list roles, set/remove a member, login installs the session, overlapping creates, failures routed | `test_projects_controller.cpp` (list, create, members round-trip, refusal in `errorText`); `test_session_controller.cpp` (session installed, token verifies); overlapping per-call results: Part 6's `mapCompletion` test "two overlapping completions each deliver their own value" (`examples/common`; every form submission is one `mapCompletion` with its own closure) | +| `test_project_admin_qml_bridge.cpp` | the same through property bags; `submitIfValid` login redacts the token; a refused foreign action; no raw token on any signal | `test_session_controller.cpp` "the sign-in reply on screen carries no token"; the surface audit and the refusal of foreign actions are gone with the routing table they guarded — each form's submitter serves its own action only | +| `test_board_presenter.cpp` | open reports the empty board; a stalled Socket-mode attach still lands; create column/lane/task; move; comment, activity and events round-trip; failures routed | `test_board_controller.cpp`, `test_board_socket.cpp`, `test_task_detail_controller.cpp` (comment), `test_board_polling.cpp` (events since a cursor) | +| `test_board_qml_bridge.cpp` | board property through forms; a fresh op id per move; comment; forms keep the board live; CreateRule re-lists rules; `fetchOptions` serves `GetBoardState` only; role text; rule round trip; `failed` on a bad project id; the poller applies another client's move; upload/download, 404, cross-project 404, no attachment server | `test_board_controller.cpp` (forms, fresh op id, bad project id, role text), `test_rules_controller.cpp` (rule round trip; the options come from the attached board's handler, and an action it does not serve is refused by name), `test_board_polling.cpp`, `test_task_detail_controller.cpp` (no transfer configured), `test_task_detail_attachments.cpp` and `test_http_attachment_transfer.cpp` (round trip, 404, cross-project 404) | +| `test_board_concurrent_drag.cpp` | N=4 concurrent moves keep positions dense; no shared per-call field | rewritten in place over `BoardController` (Task K7), plus "48 gestures, 48 op ids" | +| `test_board_offline_bridge.cpp` | queue while offline, replay on reconnect; dead-letter count; offline metrics | `test_board_offline.cpp` (Task K8) and `test_offline_moves.cpp` (Task K5) | +| `test_gui_forms_render.cpp` | each form renders through the shipped QML renderer and submits | `test_kanban_views.cpp`: every screen mounted on the `RecordingBackend`, each form's own Submit button clicked; the renderer itself is spec 2's to test | +| `test_board_layout.cpp` | the QML board keeps a usable area and every column reachable | `test_kanban_views.cpp` "the board is a grid…": every column header is a grid cell and the grid sits in a horizontal `Scroll`; pixel layout is each frontend's own (Parts 3 and 4) | +| `test_kanban_qml_surface.cpp` | bridges expose exactly what QML binds | replaced by construction: views call controller members, so a missing one fails to compile | +| `test_gui_qml_smoke.cpp` | Main.qml loads | `tests/smoke/test_kanban_frontends.cpp` (TUI and Qt Quick) | + +**Files:** +- Delete: `examples/kanban/gui/` (all), `examples/kanban/gui_lib/` (all), and in `examples/kanban/tests/`: + `test_board_presenter.cpp`, `test_board_qml_bridge.cpp`, `test_board_offline_bridge.cpp`, + `test_project_admin_presenter.cpp`, `test_project_admin_qml_bridge.cpp`, `test_gui_forms_render.cpp`, + `test_board_layout.cpp`, `test_kanban_qml_surface.cpp`, `test_gui_qml_smoke.cpp` +- Modify: `examples/kanban/CMakeLists.txt` (whole file, below), `examples/kanban/README.md`, `codecov.yml`, + `scripts/coverage.sh`, `docs/spec/offline/offline.md`, `examples/IMPLEMENTATION.md`, and the comments listed in + Step 3 (in `include/kanban/dto/`, `include/kanban/models/board_model.hpp`, `src/models/board_model.cpp`, + `tests/test_board_model.cpp`, `tests/test_kanban_offline.cpp`) +- Test: the whole kanban suite, plus the check below + +**Interfaces:** +- Consumes: everything above. +- Produces: nothing new. + +- [ ] **Step 1: Write the failing check** + +```bash +test ! -e examples/kanban/gui && test ! -e examples/kanban/gui_lib && \ + { git grep -n -E 'board_qml_bridge|BoardBridge|ProjectAdminBridge|BoardPresenter|kanban/gui_lib|kanban/gui/|gui/qml/' -- \ + examples/kanban docs/spec examples/IMPLEMENTATION.md codecov.yml; test $? -eq 1; } && \ + echo "kanban's QML stack is gone" +``` + +- [ ] **Step 2: Run it to verify it fails** + +Expected: no "gone" line (the directories exist and the grep lists `README.md`, `docs/spec/offline/offline.md:806`, +`examples/IMPLEMENTATION.md:62`, `codecov.yml:642`). + +- [ ] **Step 3: Implement** + +```bash +git rm -r -q examples/kanban/gui examples/kanban/gui_lib +git rm -q examples/kanban/tests/test_board_presenter.cpp examples/kanban/tests/test_board_qml_bridge.cpp \ + examples/kanban/tests/test_board_offline_bridge.cpp examples/kanban/tests/test_project_admin_presenter.cpp \ + examples/kanban/tests/test_project_admin_qml_bridge.cpp examples/kanban/tests/test_gui_forms_render.cpp \ + examples/kanban/tests/test_board_layout.cpp examples/kanban/tests/test_kanban_qml_surface.cpp \ + examples/kanban/tests/test_gui_qml_smoke.cpp +``` + +Replace the whole of `examples/kanban/CMakeLists.txt` with: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# +# kanban — rung 4 of the application ladder (examples/kanban/README.md). +# morph_add_rung() wires the standard targets: the domain library, the client app library (app/), the kanban +# binary (ui/main.cpp), the server and the tests. This file adds what is particular to this rung. + +cmake_minimum_required(VERSION 3.25) + +morph_add_rung(NAME kanban) + +# morph_add_rung() globs src/models, src/db and src/app into ladder_kanban_lib; the authorizer and Login's +# validation live beside them, in src/auth and src/dto. +if(TARGET ladder_kanban_lib) + target_sources(ladder_kanban_lib PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src/auth/kanban_authorizer.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/dto/auth_dto.cpp") +endif() + +# The attachment side channel (src/server/app/attachment_server.cpp) is a QTcpServer. It lives in +# ladder_kanban_server_app, which only the server and the tests link, so the domain library stays toolkit-free. +if(TARGET ladder_kanban_server_app) + find_package(Qt6 6.5 REQUIRED COMPONENTS Network) + target_link_libraries(ladder_kanban_server_app PUBLIC Qt6::Network) +endif() + +# The durable offline move queue (app/controllers/offline_moves.cpp) is optional, like every +# morph::offline_sqlite consumer: without MORPH_BUILD_OFFLINE_SQLITE the board sends every move. +if(MORPH_BUILD_OFFLINE_SQLITE) + if(TARGET ladder_kanban_app) + target_compile_definitions(ladder_kanban_app PUBLIC MORPH_BUILD_OFFLINE_SQLITE) + target_link_libraries(ladder_kanban_app PUBLIC morph::offline_sqlite) + endif() + if(TARGET ladder_kanban_tests) + target_compile_definitions(ladder_kanban_tests PRIVATE MORPH_BUILD_OFFLINE_SQLITE) + target_link_libraries(ladder_kanban_tests PRIVATE morph::offline_sqlite) + endif() +endif() + +# The Qt Quick frontend's attachment transfer uses Qt's network stack. It is compiled into ladder_kanban_app only +# when that frontend is built; its header is Qt-free, and KANBAN_HAS_QT_TRANSFER (always 0 or 1) tells the +# application whether it may pick it when the running frontend is "qt". +if(TARGET ladder_kanban_app) + if(TARGET morph_qt_quick) + find_package(Qt6 6.5 REQUIRED COMPONENTS Network) + target_sources(ladder_kanban_app PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/qt/qt_attachment_transfer.cpp") + target_include_directories(ladder_kanban_app PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/qt") + target_link_libraries(ladder_kanban_app PRIVATE Qt6::Network) + target_compile_definitions(ladder_kanban_app PRIVATE KANBAN_HAS_QT_TRANSFER=1) + else() + target_compile_definitions(ladder_kanban_app PRIVATE KANBAN_HAS_QT_TRANSFER=0) + endif() +endif() + +# The headless test client: a Qt-core process that runs the board controller over a real WebSocket. +# tests/test_kanban_process_separation.cpp spawns it. +if(TARGET ladder_kanban_app AND TARGET morph_qt_impl AND NOT EMSCRIPTEN) + add_executable(ladder_kanban_headless "${CMAKE_CURRENT_SOURCE_DIR}/headless/main.cpp") + target_link_libraries(ladder_kanban_headless PRIVATE ladder_kanban_app morph::qt morph_qt_impl Qt6::Core) + target_compile_features(ladder_kanban_headless PRIVATE cxx_std_23) + if(COMMAND morph_suppress_test_dialogs) + morph_suppress_test_dialogs(ladder_kanban_headless) + endif() + apply_bigobj(ladder_kanban_headless) + if(AF_COVERAGE) + apply_coverage(ladder_kanban_headless TEST) + endif() + if(DEFINED AF_SANITIZER) + apply_sanitizers(ladder_kanban_headless ${AF_SANITIZER}) + endif() + if(TARGET ladder_kanban_tests) + target_compile_definitions(ladder_kanban_tests PRIVATE + MORPH_LADDER_HEADLESS_BIN="$") + add_dependencies(ladder_kanban_tests ladder_kanban_headless) + endif() +endif() +``` + +`examples/kanban/README.md`: + +1. Insert this section immediately before `## Expected strain points`: + +```markdown +## The client + +One binary, `kanban` (`ui/main.cpp`), runs on Qt Quick or in a terminal: `--ui=qt|tui`, else `MORPH_UI`, else the +first frontend that can run here. `--server ws://host:port` connects to `ladder_kanban_server`, whose attachment +side channel is `KANBAN_ATTACHMENT_SERVER` when set, else port 8769 on the server's host; without `--server` the app +runs locally against `--db` (default `kanban.db`), signs its own tokens and has no attachments. Against a server, in +a build with `MORPH_BUILD_OFFLINE_SQLITE`, moves made while the server is unreachable wait in the queue +`KANBAN_OFFLINE_QUEUE_DB` names (default `kanban-offline-queue.db`). + +`app/` is `ladder_kanban_app`, which links no toolkit: + +- `app/controllers/`: `SessionController` (sign-in; the token reaches the bridge, never the screen), + `ProjectsController` (the list, create, members and the role picker), `BoardController` (attach, the board and + activity queries, the board forms, moves under a fresh op id, polling, the offline hook), `RulesController`, + `TaskDetailController` (comments, and attachments through an injected `IAttachmentTransfer`) and `OfflineMoves` + (the durable queue, its network monitor and the reconnect replay). +- `app/views/`: one `ui::Node` builder per screen, bindings only. +- `app/http/`: the terminal's attachment transfer, a minimal HTTP/1.1 client over POSIX sockets. On Qt Quick, + `qt/qt_attachment_transfer.cpp` (`QNetworkAccessManager`, compiled into the app library only when Qt Quick is + built, behind a Qt-free header) moves the bytes instead. + +`headless/main.cpp` is the process-separation test's client: a Qt-core process running the same `BoardController` +over `QtWebSocketBackend`. + +**What looks different from the old Qt Quick screens.** The behaviour is kept; the styling is not reproduced: + +- The board has no custom styling — no fixed-width bordered columns, no card rectangles, no blue or red drop + highlight at the WIP limit (a drop into a full column is refused by the server, and the error line says so), no + always-on scrollbar. It is a grid of lanes by columns in a horizontal scroll. +- A card dropped on another card lands before it; dropped on a cell, it lands at the end. The old board computed the + position from the drop's pixel offset. +- A task is created from the "+ task" button in its cell, which opens a dialog, instead of a form inline in every + cell; a project opens with its "Open" button instead of a click on the row. +- A form's outcome shows under the form (the forms engine's reply line); the screen's own status line shows errors. +- In a terminal a file is chosen by typing its path. On Windows the terminal client has no attachments and no + `--server`, because `morph::net` and the HTTP client are POSIX-only; Qt Quick on Windows has both. +``` + +2. In "morph subsystems exercised": in the forms table, replace the Screen column's `gui/qml/…` paths with + `app/views/sign_in_view.cpp` (Sign in), `app/views/projects_view.cpp` (Create project; Add member, "members + panel"), `app/views/board_view.cpp` (Add column; Add swimlane; New task, "a dialog from each cell's + task"; + Add comment, "task dialog"; Add rule, "rules dialog"). Replace the paragraph that begins "Not one of them has a + hand-written field" with: "Not one of them has a hand-written field: each is a runtime form read from + `schemaJson()` (`FormModel::forAction()`) and rendered by `formView`, and if any of those eight actions + grows a member, no view changes. Each form submits through the one handler that serves its action, so there is + no routing table to get wrong." In the next paragraph replace "the view that owns the form supplies them with + `setFieldValue`" with "the controller that owns the form prefills them", and "the renderer fetches its own + options for via `BoardBridge::fetchOptions`" with "whose options `RulesController::fetchOptions` serves from the + attached board". Replace the paragraph that begins "**This is rendered, not merely generated.**" with: + "`tests/client/test_kanban_views.cpp` mounts every screen on the `RecordingBackend` and clicks each form's own + Submit button; the controller tests pin what each submission does." +3. In "What is still hand-built, and why": the two table rows become `` `app/views/board_view.cpp`'s drag-and-drop + board `` and `` `app/views/projects_view.cpp`'s per-row role `Select` `` (the justification column unchanged + apart from "on selection change" → "on selection" and "the create-a-new-thing shape `DynamicForm` renders" → + "the create-a-new-thing shape a form renders"). In the paragraph beginning "**There is no enum-rendering + gap.**" replace "`DynamicForm` draws that as a combo box" with "the forms engine draws that as a `Select`", the + `gui/qml/MembersView.qml`/`gui/qml/RulesView.qml` names with "the members panel's add-member form and the rules + dialog's add-rule form", "are both `DynamicForm`s now" with "are both runtime forms", and "through `BoardBridge::fetchOptions`. Only `MembersView`'s **per-row** role picker" + with "through `RulesController::fetchOptions`. Only the members panel's **per-row** role picker". In the + drag-and-drop paragraph replace "(`DropArea.onDropped` computes them from the drop's own geometry)" with "(the card + or cell the drop lands on)". +4. In "Expected strain points" item 7's last sentence replace "`BoardBridge`'s `DeadLetterSink` turns that into a + `syncStatusChanged(queueDepth, deadLettered)` emission the GUI renders" with "`OfflineMoves`' dead-letter sink + turns that into the board's \"N changes could not be synced\" line"; in the queue-bound bullet replace + "`BoardBridge::enableOfflineQueue` constructs its" with "`OfflineMoves` (`app/controllers/offline_moves.cpp`) + constructs its" and "inside a `Q_INVOKABLE` move handler that has no failure path today" with "inside + `BoardController::moveTask`, which has no user-visible failure path today". +5. In "Findings", replace "`MembersView`/`RulesView`'s add-member/add-rule forms" with "the add-member and + add-rule forms". +6. In "Definition of done", replace "(`test_board_offline_bridge.cpp`)" with "(`tests/client/test_board_offline.cpp`)". + +`codecov.yml`: replace the three kanban lines (`examples/kanban/tests/**`, `examples/kanban/gui/**`, +`examples/kanban/gui_wasm/**`) with + +```yaml + # kanban's client shells: ui/ is main(), qt/ the Qt Quick-only transfer (exercised by hand), headless/ a + # spawned test process. app/ is measured. + - "examples/kanban/tests/**" + - "examples/kanban/ui/**" + - "examples/kanban/qt/**" + - "examples/kanban/headless/**" +``` + +`scripts/coverage.sh`: if the rung loop (`for _sub in include src gui_lib; do`) does not already name `app`, make +it `for _sub in include src app gui_lib; do`, and in the comment above it replace "gui_lib/ is its hand-written +presenter/adapter code" with "app/ (and, until the last rung moves, gui_lib/) is its hand-written client code". + +`docs/spec/offline/offline.md`: replace the bullet that begins "- `examples/kanban/gui_lib/board_qml_bridge.cpp` — +the transport-shaped half" (six lines, ending "belongs in a `FieldOutbox`-shaped class instead.") with: + +```markdown +- `examples/kanban/app/controllers/offline_moves.cpp` — the transport-shaped half only: probe, serialise, enqueue + under the move's op id, replay through the board's handler, report queue depth. Nothing in it is domain-shaped, + so it stands as client glue; anything with a domain invariant in it belongs in a `FieldOutbox`-shaped class + instead. +``` + +`examples/IMPLEMENTATION.md`: replace "kanban's copy in a QML bridge (`examples/kanban/gui_lib/board_qml_bridge.cpp`) +stands only because it is transport-shaped glue under rule 2's glue justification." with "kanban's copy +(`examples/kanban/app/controllers/offline_moves.cpp`) stands only because it is transport-shaped glue with no domain +invariant in it." + +Comments that name the deleted client (each states what the code does now; replace exactly the quoted text): + +- `include/kanban/dto/board_dto.hpp` (`CreateTask`): "the board view supplies both from the delegate that owns the + form (`gui/qml/BoardView.qml`)." → "the board controller prefills both when a cell's \"+ task\" dialog opens." + (`AddComment`): "whichever task's detail popup is open, and `gui/qml/TaskDetailPopup.qml` supplies it." → + "whichever task's detail dialog is open, and `TaskDetailController` prefills it." +- `include/kanban/dto/project_dto.hpp`: "Carries over the placeholder the hand-built `TextField` this form replaced + used to show (`gui/qml/ProjectListView.qml`)." → "The label and placeholder the projects screen's create form + shows."; "and `gui/qml/MembersView.qml` supplies it from the page the form is embedded in." → "and the projects + controller prefills it for the open members panel." +- `include/kanban/dto/rule_dto.hpp`: "and `gui/qml/RulesView.qml` supplies it from the attached `BoardBridge`." → + "and `RulesController` prefills it from the attached board."; "(no presenter/QML bridge ever constructs one + directly)" → "(no client controller ever constructs one directly)". +- `include/kanban/models/board_model.hpp`: "(test files, the QML/GUI bridge, app.cpp)" → "(test files, the client + app library, app.cpp)"; "which is what makes `BoardBridge::openBoard("not-a-number")` (parsed into a + default-constructed `ProjectId{}` by board_qml_bridge.cpp's `parseId`) a rejected `Completion`" → "which is what + makes an `OpenBoard` carrying a default-constructed `ProjectId{}` a rejected `Completion`"; "(`morph::ladder::gui:: + errorText` reads `what()` off any `std::exception`)" → "(the client's `reactive::errorMessage` reads `what()` off + any `std::exception`)". `src/models/board_model.cpp`: "(kanban's tests, the QML/GUI bridge, app.cpp)" → + "(kanban's tests, the client app library, app.cpp)". +- `tests/test_board_model.cpp`: the four lines "Regression test for the QML TaskDetailPopup gap: … looked identical + once serialized." → "Each comment names its task, so a client can show one task's comments from a board with + comments on several."; "What TaskDetailPopup.qml's own filter now expresses client-side:" → "What the task detail + dialog's filter expresses client-side:"; the paragraph "A regression test for a real crash: BoardBridge::openBoard() + parses … projectId\") expects." → + "An `OpenBoard` with a disengaged `projectId` (what a client makes of an id it cannot parse) reaches + `BridgeHandler::execute()` before `BoardModel::execute(OpenBoard)`'s own `hasValue()` check runs, because + `ActionKeyTraits::key()` computes the routing key first. Dereferencing the empty optional there is + undefined behaviour, which libstdc++'s hardened build turns into an abort of the whole process; the key must + refuse it instead."; and "`BoardBridge` routes it through `morph::ladder::gui::errorText()`, which reads" → "the + client shows it through `reactive::errorMessage`, which reads". +- `tests/test_kanban_offline.cpp`: "a real SqliteOfflineQueue-backed presenter would replay through" → "the client's + `OfflineMoves` replays through"; the paragraph "This scenario is driven at the backend level -- … multi-client-online + cases respectively." → + "Driven at the backend level -- two in-process `BoardModel` handles standing in for two clients' offline queues -- + because `SyncWorker::run()` drains a whole queue in one call, so a client's own `OfflineMoves` offers no seam to + alternate one item from each queue. Each queue is a plain `std::vector` (what + `SqliteOfflineQueue` persists while offline), replayed through `BoardModel::execute()` as the reconnect test above + does for one client. That still exercises the concurrency-sensitive code (the per-project strand and + `MoveTaskPosition`'s renumbering and ledger); the client plumbing around it is `tests/client/test_board_offline.cpp`'s + and `test_board_concurrent_drag.cpp`'s."; and "mirroring two distinct BoardBridge/SyncWorker instances" → + "mirroring two distinct clients' `OfflineMoves`". + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_kanban_tests ladder_kanban_server kanban ladder_kanban_headless +./build/all/examples/kanban/ladder_kanban_tests +``` + +Then the check from Step 1. Expected: every kanban case passes, the server, binary and headless client build, and +the check prints "kanban's QML stack is gone". + +Mutation check: restore `examples/kanban/tests/test_gui_qml_smoke.cpp` from `HEAD` +(`git checkout HEAD -- examples/kanban/tests/test_gui_qml_smoke.cpp`) and rebuild. Expected: the build fails +(`MORPH_LADDER_QML_URI` and the `ladder_kanban_qml` module no longer exist) — proof that the deleted QML stack is not +still being built somewhere. Delete it again. + +- [ ] **Step 5: Commit** + +```bash +git add -A examples/kanban codecov.yml scripts/coverage.sh docs/spec/offline/offline.md examples/IMPLEMENTATION.md +git commit -m "wip(kanban): retire kanban's QML, bridges and presenters; README client section + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task K16: Kanban group verification and squash + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Strict build and the full ladder suite** + +```bash +cmake --build build/all && ctest --test-dir build/all -L ladder-kanban --output-on-failure +ctest --test-dir build/all -L ladder --output-on-failure +``` + +Expected: every test passes, kanban's and every other rung's. + +- [ ] **Step 2: No frontend, no offline queue** + +```bash +cmake --build build/nofront --target ladder_kanban_tests ladder_kanban_app +./build/nofront/examples/kanban/ladder_kanban_tests "[kanban]" +``` + +Expected: builds (the `#else` of the offline hook compiles); every case passes. The smoke binary, if this +configure builds it, skips both cases (`runFrontendSmoke` skips a frontend the configure did not build). +`cmake --build build/nofront --target kanban` reports no such target — the binary needs a frontend (spec 4 §6). + +- [ ] **Step 3: Toolkit-free and UI-free checks** + +```bash +git grep -n -E '#include " +``` + +Skip the commit if there was nothing to fix, and say so in the hand-off. + +- [ ] **Step 7: Squash the group into its one commit** + +Follow the master plan's "Squashing a part" procedure with key `kanban` and this message: + +```text +examples/kanban: one app, any frontend + +The kanban client becomes a toolkit-free app library (ladder_kanban_app): +session, projects, board, rules and task-detail controllers built from +Query, Mutation and runtime forms, and views that are bindings only. The +board is a grid of swimlanes by columns whose cards move by drag-and-drop on +both frontends, each move under a fresh op id; polling, the optional offline +queue and the attachment transfer (Qt's network stack or an HTTP/1.1 client +over POSIX sockets) live behind the controllers. One binary picks Qt Quick or +the TUI at runtime; the headless test client runs the same board controller. + +Signed-off-by: Christian Parpart +``` + +The last line of the procedure must show `examples/kanban: one app, any frontend` directly after +`examples/polls: …`. + +--- +# Group `ledger` + +### Task L1: The domain library loses Qt — the report runner moves to the server + +`ledger::app::App` is a `QObject` with a `QTimer` that sweeps pending report jobs. It runs only in +`ladder_ledger_server` (and in the tests that drive it), so it moves to the server side (spec 4 §2: "Those classes +run only in the server, so they move into `src/server/` and the server target"), using Part 8's convention: +`src/server/app/*.cpp` with headers under `src/server/include/` become `ladder_ledger_server_app`, which the server +and `ladder_ledger_tests` link. The header keeps its include spelling, `"ledger/app/app.hpp"`, so no includer +changes. It still includes every model header, which is what pulls each model's object out of the static library +into the server binary. + +**Files:** +- Move: `examples/ledger/include/ledger/app/app.hpp` → `examples/ledger/src/server/include/ledger/app/app.hpp` +- Move: `examples/ledger/src/app/app.cpp` → `examples/ledger/src/server/app/app.cpp` +- Modify: `examples/ledger/include/ledger/core/types.hpp:112` — the path in `kReportRunnerPrincipal`'s comment +- Test: `examples/ledger/tests/test_app.cpp` (unchanged cases), plus the include check below + +**Interfaces:** +- Consumes: Part 8's `morph_add_rung` rule `src/server/app/*.cpp` + `src/server/include/` → `ladder__server_app` + (linked by `ladder__server` and `ladder__tests`), and its Qt-free `ladder__lib` once the rung has + `app/` (Task L2). +- Produces: `ledger::app::App`, unchanged, in `ladder_ledger_server_app`, still included as `"ledger/app/app.hpp"` + (Task L6's tests use it). + +- [ ] **Step 1: Write the failing check** + +```bash +git grep -n -E '#include ` and `:5:#include `, and +does not print "domain library is Qt-free". + +- [ ] **Step 3: Implement** + +```bash +mkdir -p examples/ledger/src/server/include/ledger/app examples/ledger/src/server/app +git mv examples/ledger/include/ledger/app/app.hpp examples/ledger/src/server/include/ledger/app/app.hpp +git mv examples/ledger/src/app/app.cpp examples/ledger/src/server/app/app.cpp +``` + +In `examples/ledger/include/ledger/core/types.hpp`, replace `(\`examples/ledger/include/ledger/app/app.hpp\`)` with +`(\`examples/ledger/src/server/include/ledger/app/app.hpp\`)`. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_ledger_tests ladder_ledger_server +./build/all/examples/ledger/ladder_ledger_tests "[ledger][app]" +git grep -n -E '#include " +``` + +--- +### Task L2: The ledger app library — display strings and `BootController` + +Ledger has no sign-in screen: its GUI ran as a dev principal (`gui/main.cpp`'s `kDevPrincipal`), installed with +`setDefaultSession` locally and through `Login` against a server. `BootController` does the same with `--user` +(spec 4 §3), and with `--seed` creates a "Personal" book for that principal and opens it; otherwise it opens the book +its options name — book 1, the id `Main.qml` hard-coded, in the application (Task L9). Controllers are built from +Part 6's `morph::examples::Wiring`; `morph_add_rung` builds `app/` into `ladder_ledger_app` and links it into +`ladder_ledger_tests`. + +**Files:** +- Create: `examples/ledger/app/support/ledger_format.hpp`, `examples/ledger/app/support/ledger_format.cpp` +- Create: `examples/ledger/app/controllers/boot_controller.hpp`, `examples/ledger/app/controllers/boot_controller.cpp` +- Create: `examples/ledger/tests/client/ledger_client_support.hpp` +- Test: `examples/ledger/tests/client/test_ledger_format.cpp`, `examples/ledger/tests/client/test_boot_controller.cpp` + +**Interfaces:** +- Consumes: `ledger::formatMoney` (`ledger/core/money.hpp:155`), `currencyToCode`/`codeToCurrency` + (`ledger/core/units.hpp:37`, `:61`), `ledger::auth::setTokenIssuer` (`ledger/auth/ledger_authorizer.hpp:169`), + `ledger::AuthModel`, `ledger::LedgerModel`, `CreateLedger`/`CreateLedgerResult` (`ledger/dto/account_dto.hpp:47`, + `:62`); `morph::examples::Wiring` (Part 6, `"app/wiring.hpp"`). +- Produces: + - `AccountRow{id, name, kind, currency, balanceText}`, `EntryRow{id, idText, description, dateText}`, + `StatementLine{currency, countText, amountText}`; `kindText(AccountKind)`, `kindFromText(std::string_view)`, + `kindNames()`, `parsePositive(std::string_view)`, `parseAtLeast(std::string_view, std::int64_t minimum)`, + `isMonth(std::string_view)`, `accountRow(AccountInfo const&)`, `entryRow(TransactionEntryInfo const&)`, + `statementLines(std::string_view) -> std::optional>`, `localOffsetMinutes()`. + - `BootState{SigningIn, Ready, Failed}`, `BootOptions{principal, remote, seed, ledger}`, + `BootController(examples::Wiring, BootOptions)`: `state()`, `ledger() -> std::optional`, `statusText()`. + - Test support (`ledger::testing`): `LocalClient`, `QtClient`, `await`, `IssuerScope`, `seedLedger(name)`, + `openAccountDirect(LedgerId, name, AccountKind) -> AccountId`, `currentMonth()`. + +- [ ] **Step 1: Write the failing tests** + +Create `examples/ledger/tests/client/ledger_client_support.hpp`: + +```cpp +// 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 +#include +#include +#include + +#include "app/wiring.hpp" +#include "ledger/auth/ledger_authorizer.hpp" +#include "ledger/db/ledger_entity.hpp" +#include "ledger/models/ledger_model.hpp" +#include "testkit/pump.hpp" +#include "testkit/wait.hpp" + +namespace ledger::testing { + +using namespace std::chrono_literals; + +inline constexpr std::string_view kSecret = "ledger-client-test-secret-32-bytes!!"; + +/// Installs the issuer ledger's `Login` mints from, for one test. +class IssuerScope { +public: + IssuerScope() { + ledger::auth::setTokenIssuer( + std::make_shared(std::string{kSecret}, morph::session::hmacSha256)); + } + ~IssuerScope() { ledger::auth::setTokenIssuer(nullptr); } + IssuerScope(IssuerScope const&) = delete; + IssuerScope& operator=(IssuerScope const&) = delete; + IssuerScope(IssuerScope&&) = delete; + IssuerScope& operator=(IssuerScope&&) = delete; +}; + +/// A client over a local backend on a MainThreadExecutor. Declare it before the controllers under test. +class LocalClient { +public: + LocalClient() = default; + explicit LocalClient(std::string principal) { + morph::session::Context session; + session.principal = std::move(principal); + bridge.setDefaultSession(std::move(session)); + } + ~LocalClient() = default; + LocalClient(LocalClient const&) = delete; + LocalClient& operator=(LocalClient const&) = delete; + LocalClient(LocalClient&&) = delete; + LocalClient& operator=(LocalClient&&) = delete; + + template + [[nodiscard]] bool settle(Pred done, std::chrono::milliseconds budget = 5000ms) { + return morph::examples::testing::pumpUntil(owner, std::move(done), budget); + } + [[nodiscard]] morph::examples::Wiring wiring() { + return morph::examples::Wiring{ + .runtime = runtime, .scheduler = scheduler, .bridge = bridge, .callbacks = owner}; + } + + morph::exec::ThreadPoolExecutor pool{4}; + morph::exec::MainThreadExecutor owner; + morph::bridge::Bridge bridge{std::make_unique(pool), owner}; + morph::reactive::Runtime runtime{owner}; + morph::reactive::testing::ManualScheduler scheduler; +}; + +/// A client whose owner is the Qt event loop, for tests that also run the Qt-hosted report runner. +class QtClient { +public: + explicit QtClient(std::string principal) { + morph::session::Context session; + session.principal = std::move(principal); + bridge.setDefaultSession(std::move(session)); + } + ~QtClient() = default; + QtClient(QtClient const&) = delete; + QtClient& operator=(QtClient const&) = delete; + QtClient(QtClient&&) = delete; + QtClient& operator=(QtClient&&) = delete; + + template + [[nodiscard]] bool settle(Pred done, std::chrono::milliseconds budget = 5000ms) { + return morph::ladder::testkit::pumpUntil(std::move(done), budget); + } + [[nodiscard]] morph::examples::Wiring wiring() { + return morph::examples::Wiring{ + .runtime = runtime, .scheduler = scheduler, .bridge = bridge, .callbacks = owner}; + } + + morph::exec::ThreadPoolExecutor pool{4}; + morph::qt::QtExecutor owner; + morph::bridge::Bridge bridge{std::make_unique(pool), owner}; + morph::reactive::Runtime runtime{owner}; + morph::reactive::testing::ManualScheduler scheduler; +}; + +/// Waits for @p completion on @p client's owner and returns its value, or rethrows its failure. +template +[[nodiscard]] T await(Client& client, morph::async::Completion completion) { + struct State { + std::optional value; + std::exception_ptr error; + }; + auto state = std::make_shared(); + completion.thenDetached([state](T const& value) { state->value = value; }) + .onErrorDetached([state](std::exception_ptr error) { state->error = std::move(error); }); + if (!client.settle([&state] { return state->value.has_value() || state->error != nullptr; })) { + throw std::runtime_error{"await: the completion did not settle"}; + } + if (state->error != nullptr) { + std::rethrow_exception(state->error); + } + return std::move(*state->value); +} + +/// A book with no owner, as the model tests seed one. +[[nodiscard]] inline ledger::LedgerId seedLedger(std::string name) { + Lightweight::DataMapper mapper; + ledger::db::LedgerRecord row; + row.name = Lightweight::SqlAnsiString<128>{std::move(name)}; + mapper.Create(row); + return ledger::LedgerId{static_cast(row.id.Value())}; +} + +/// Opens an account straight through the model, as "alice". +[[nodiscard]] inline ledger::AccountId openAccountDirect(ledger::LedgerId ledgerId, std::string name, + ledger::AccountKind kind) { + morph::session::Context context; + context.principal = "alice"; + morph::session::detail::ScopedContext const scope{context}; + ledger::LedgerModel model; + return model + .execute(ledger::OpenAccount{ + .ledgerId = ledgerId, .name = std::move(name), .kind = kind, .currency = ledger::Currency::USD}) + .id; +} + +/// This month as `YYYY-MM`, in UTC (what `Timestamp::now()` dates a transaction with). +[[nodiscard]] inline std::string currentMonth() { + std::chrono::year_month_day const today{std::chrono::floor(std::chrono::system_clock::now())}; + return std::format("{:04}-{:02}", static_cast(today.year()), static_cast(today.month())); +} + +} // namespace ledger::testing +``` + +Create `examples/ledger/tests/client/test_ledger_format.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "support/ledger_format.hpp" + +using ledger::AccountKind; + +TEST_CASE("ledger::client: account kinds round-trip through their display names", "[ledger][client]") { + for (AccountKind const kind : {AccountKind::Asset, AccountKind::Expense, AccountKind::Revenue, AccountKind::Liability}) { + CHECK(ledger::client::kindFromText(ledger::client::kindText(kind)) == kind); + } + CHECK(ledger::client::kindFromText("nonsense") == AccountKind::Asset); + CHECK(ledger::client::kindNames().size() == 4); +} + +TEST_CASE("ledger::client: ids and amounts parse whole and in range, or not at all", "[ledger][client]") { + CHECK(ledger::client::parsePositive("12") == std::optional{12}); + CHECK_FALSE(ledger::client::parsePositive("0").has_value()); + CHECK_FALSE(ledger::client::parsePositive("-3").has_value()); + CHECK_FALSE(ledger::client::parsePositive("12x").has_value()); + CHECK_FALSE(ledger::client::parsePositive("").has_value()); + CHECK(ledger::client::parseAtLeast("0", 0) == std::optional{0}); +} + +TEST_CASE("ledger::client::isMonth accepts YYYY-MM only", "[ledger][client]") { + CHECK(ledger::client::isMonth("2026-01")); + CHECK_FALSE(ledger::client::isMonth("2026-13")); + CHECK_FALSE(ledger::client::isMonth("2026/01")); + CHECK_FALSE(ledger::client::isMonth("26-01")); +} + +TEST_CASE("ledger::client::statementLines renders a report body exactly, and refuses garbage", "[ledger][client]") { + auto const lines = ledger::client::statementLines( + R"([{"currency":"USD","numerator":-5000,"denominator":1,"decimalPlaces":2,"transactionCount":1}])"); + REQUIRE(lines.has_value()); + REQUIRE(lines->size() == 1); + CHECK(lines->front() == ledger::client::StatementLine{.currency = "USD", .countText = "1 transactions", .amountText = "-50"}); + CHECK_FALSE(ledger::client::statementLines("not json").has_value()); +} + +TEST_CASE("ledger::client::localOffsetMinutes is a real UTC offset", "[ledger][client]") { + int const offset = ledger::client::localOffsetMinutes(); + CHECK(offset >= -14 * 60); + CHECK(offset <= 14 * 60); +} +``` + +Create `examples/ledger/tests/client/test_boot_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "controllers/boot_controller.hpp" +#include "ledger_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using ledger::client::BootController; +using ledger::client::BootOptions; +using ledger::client::BootState; +using ledger::testing::LocalClient; +using morph::ladder::testkit::DbFixture; + +TEST_CASE("ledger::client::BootController: local mode installs --user and opens the configured book", + "[ledger][client]") { + DbFixture const fixture; + LocalClient client; + BootController boot{client.wiring(), BootOptions{.principal = "alice", .remote = false, .seed = false, .ledger = 7}}; + + CHECK(boot.state() == BootState::Ready); + CHECK(boot.ledger() == std::optional{ledger::LedgerId{7}}); + CHECK(client.bridge.defaultSession().principal == "alice"); +} + +TEST_CASE("ledger::client::BootController: --seed creates a book for the principal and opens it", "[ledger][client]") { + DbFixture const fixture; + LocalClient client; + BootController boot{client.wiring(), BootOptions{.principal = "alice", .remote = false, .seed = true, .ledger = 1}}; + CHECK(boot.state() == BootState::SigningIn); + + REQUIRE(client.settle([&boot] { return boot.state() != BootState::SigningIn; })); + + REQUIRE(boot.state() == BootState::Ready); + REQUIRE(boot.ledger().has_value()); + morph::bridge::BridgeHandler handler{client.bridge, &client.owner}; + auto const state = ledger::testing::await(client, handler.execute(ledger::GetLedger{.ledgerId = *boot.ledger()})); + CHECK(state.accounts.empty()); +} + +TEST_CASE("ledger::client::BootController: remote mode signs in through Login and installs the token", + "[ledger][client]") { + DbFixture const fixture; + ledger::testing::IssuerScope const issuer; + LocalClient client; + BootController boot{client.wiring(), BootOptions{.principal = "alice", .remote = true, .seed = false, .ledger = 1}}; + + REQUIRE(client.settle([&boot] { return boot.state() != BootState::SigningIn; })); + + CHECK(boot.state() == BootState::Ready); + CHECK(client.bridge.defaultSession().principal == "alice"); + CHECK_FALSE(client.bridge.defaultSession().token.empty()); +} + +TEST_CASE("ledger::client::BootController: a refused sign-in fails boot with the reason", "[ledger][client]") { + DbFixture const fixture; + ledger::testing::IssuerScope const issuer; + LocalClient client; + BootController boot{client.wiring(), + BootOptions{.principal = "system:report-runner", .remote = true, .seed = false, .ledger = 1}}; + + REQUIRE(client.settle([&boot] { return boot.state() != BootState::SigningIn; })); + + CHECK(boot.state() == BootState::Failed); + CHECK_THAT(boot.statusText(), Catch::Matchers::ContainsSubstring("sign-in failed")); + CHECK_FALSE(boot.ledger().has_value()); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake build/all && cmake --build build/all --target ladder_ledger_tests` +Expected: compile error, `'support/ledger_format.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/ledger/app/support/ledger_format.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include "ledger/core/types.hpp" +#include "ledger/dto/account_dto.hpp" +#include "ledger/dto/transaction_dto.hpp" + +namespace ledger::client { + +/// @brief One account in the accounts table. +struct AccountRow { + /// @brief The account's id. + std::int64_t id = 0; + /// @brief Its name. + std::string name; + /// @brief Its kind's display name. + std::string kind; + /// @brief Its currency code. + std::string currency; + /// @brief Its balance, rendered exactly. + std::string balanceText; + /// @brief Compared by value. + bool operator==(AccountRow const&) const = default; +}; + +/// @brief One journal entry in the entries list. +struct EntryRow { + /// @brief The entry's journal id. + std::int64_t id = 0; + /// @brief That id as text. + std::string idText; + /// @brief Its description. + std::string description; + /// @brief Its date, ISO 8601, or empty. + std::string dateText; + /// @brief Compared by value. + bool operator==(EntryRow const&) const = default; +}; + +/// @brief One currency line of a monthly statement. +struct StatementLine { + /// @brief The currency code. + std::string currency; + /// @brief `" transactions"`. + std::string countText; + /// @brief The total, rendered exactly. + std::string amountText; + /// @brief Compared by value. + bool operator==(StatementLine const&) const = default; +}; + +/// @brief An account kind's display name. @param kind The kind. @return `"asset"`, `"expense"`, … . +[[nodiscard]] std::string kindText(AccountKind kind); +/// @brief The kind a display name names. @param text The name. @return The kind; `Asset` for anything unknown. +[[nodiscard]] AccountKind kindFromText(std::string_view text); +/// @brief Every kind's display name, in enum order. @return The names. +[[nodiscard]] std::vector kindNames(); +/// @brief A whole number no smaller than @p minimum. +/// @param text The text, digits only. +/// @param minimum The smallest accepted value. +/// @return The number, or empty. +[[nodiscard]] std::optional parseAtLeast(std::string_view text, std::int64_t minimum); +/// @brief A positive whole number (an id). @param text The text. @return The number, or empty. +[[nodiscard]] std::optional parsePositive(std::string_view text); +/// @brief Whether @p text is a `YYYY-MM` month. @param text The text. @return True for months 01–12. +[[nodiscard]] bool isMonth(std::string_view text); +/// @brief An account as a table row. @param account The account. @return The row. +[[nodiscard]] AccountRow accountRow(AccountInfo const& account); +/// @brief A journal entry as a list row. @param entry The entry. @return The row. +[[nodiscard]] EntryRow entryRow(TransactionEntryInfo const& entry); +/// @brief A finished statement's body as display lines. +/// @param resultJson The job's `result`: a JSON array of `ReportLine`. +/// @return The lines, or empty when the body does not read. +[[nodiscard]] std::optional> statementLines(std::string_view resultJson); +/// @brief This machine's offset from UTC right now, in minutes (east positive). +/// @return The offset. +[[nodiscard]] int localOffsetMinutes(); + +} // namespace ledger::client +``` + +Create `examples/ledger/app/support/ledger_format.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "support/ledger_format.hpp" + +#include +#include +#include +#include + +#include "ledger/core/money.hpp" +#include "ledger/core/units.hpp" +#include "ledger/dto/report_dto.hpp" + +namespace ledger::client { + +std::string kindText(AccountKind kind) { + switch (kind) { + case AccountKind::Asset: + return "asset"; + case AccountKind::Expense: + return "expense"; + case AccountKind::Revenue: + return "revenue"; + case AccountKind::Liability: + return "liability"; + default: + return "asset"; + } +} + +AccountKind kindFromText(std::string_view text) { + if (text == "expense") { + return AccountKind::Expense; + } + if (text == "revenue") { + return AccountKind::Revenue; + } + if (text == "liability") { + return AccountKind::Liability; + } + return AccountKind::Asset; +} + +std::vector kindNames() { + return {kindText(AccountKind::Asset), kindText(AccountKind::Expense), kindText(AccountKind::Revenue), + kindText(AccountKind::Liability)}; +} + +std::optional parseAtLeast(std::string_view text, std::int64_t minimum) { + std::int64_t value = 0; + auto const [end, failure] = std::from_chars(text.data(), text.data() + text.size(), value); + if (text.empty() || failure != std::errc{} || end != text.data() + text.size() || value < minimum) { + return std::nullopt; + } + return value; +} + +std::optional parsePositive(std::string_view text) { return parseAtLeast(text, 1); } + +bool isMonth(std::string_view text) { + if (text.size() != 7 || text.at(4) != '-') { + return false; + } + auto const year = parseAtLeast(text.substr(0, 4), 0); + auto const month = parseAtLeast(text.substr(5, 2), 1); + return year.has_value() && month.has_value() && *month <= 12; +} + +AccountRow accountRow(AccountInfo const& account) { + return AccountRow{.id = account.id.hasValue() ? *account.id : 0, + .name = account.name, + .kind = kindText(account.kind), + .currency = std::string{currencyToCode(account.currency)}, + .balanceText = formatMoney(account.currency, account.balance)}; +} + +EntryRow entryRow(TransactionEntryInfo const& entry) { + std::int64_t const journalId = entry.id.hasValue() ? *entry.id : 0; + return EntryRow{.id = journalId, + .idText = std::to_string(journalId), + .description = entry.description, + .dateText = entry.date.hasValue() ? entry.date.value->toIso8601() : std::string{}}; +} + +std::optional> statementLines(std::string_view resultJson) { + std::vector decoded; + if (glz::read_json(decoded, std::string{resultJson})) { + return std::nullopt; + } + std::vector lines; + for (ReportLine const& line : decoded) { + auto const total = morph::math::Rational{morph::math::Numerator{line.numerator}, + morph::math::Denominator{line.denominator}, + morph::math::DecimalPlaces{line.decimalPlaces}}; + lines.push_back(StatementLine{.currency = line.currency, + .countText = std::to_string(line.transactionCount) + " transactions", + .amountText = formatMoney(codeToCurrency(line.currency), total)}); + } + return lines; +} + +int localOffsetMinutes() { + std::time_t const now = std::time(nullptr); + std::tm local{}; + std::tm utc{}; +#if defined(_WIN32) + localtime_s(&local, &now); + gmtime_s(&utc, &now); +#else + localtime_r(&now, &local); + gmtime_r(&now, &utc); +#endif + // mktime reads both as local wall-clock time, so their difference is the offset; the same DST flag on both keeps + // a daylight-saving hour from being counted twice. + utc.tm_isdst = local.tm_isdst; + return static_cast(std::difftime(std::mktime(&local), std::mktime(&utc)) / 60.0); +} + +} // namespace ledger::client +``` + +Create `examples/ledger/app/controllers/boot_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "ledger/models/auth_model.hpp" +#include "ledger/models/ledger_model.hpp" + +namespace ledger::client { + +/// @brief Where startup is. +enum class BootState : std::uint8_t { SigningIn, Ready, Failed }; + +/// @brief Who the app runs as and which book it opens. +struct BootOptions { + /// @brief The principal (`--user`). + std::string principal; + /// @brief True against a server: sign in through `Login` for a signed token. Locally the principal is installed + /// as is. + bool remote = false; + /// @brief Create a "Personal" book for the principal and open it (`--seed`). + bool seed = false; + /// @brief The book to open without `seed`. + std::int64_t ledger = 1; +}; + +/// @brief Startup: installs the session, optionally seeds a book, and names the book every tab works on. +class BootController { +public: + /// @param wiring The runtime, bridge and owner this controller uses. + /// @param options The principal, mode, seed flag and book. + BootController(morph::examples::Wiring wiring, BootOptions options); + + /// @brief Where startup is. Tracked. + /// @return The state. + [[nodiscard]] BootState state() const { return _state.get(); } + /// @brief The book every tab works on. Tracked. + /// @return The book once ready, else empty. + [[nodiscard]] std::optional const& ledger() const { return _ledger.get(); } + /// @brief The boot screen's line. Tracked. + /// @return What startup is doing, or why it failed. + [[nodiscard]] std::string statusText() const; + +private: + void signedIn(); + void ready(LedgerId book); + void fail(std::string message); + + morph::examples::Wiring _wiring; + BootOptions _options; + morph::bridge::BridgeHandler _auth; + morph::bridge::BridgeHandler _creator; + morph::reactive::Signal _state; + morph::reactive::Signal> _ledger; + morph::reactive::Signal _failure; + // Last member, so it is destroyed first: a reply still in flight finds it stopped. + morph::async::CallbackScope _lifetime; +}; + +} // namespace ledger::client +``` + +Create `examples/ledger/app/controllers/boot_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/boot_controller.hpp" + +#include +#include +#include +#include + +namespace ledger::client { + +BootController::BootController(morph::examples::Wiring wiring, BootOptions options) + : _wiring{wiring}, + _options{std::move(options)}, + _auth{wiring.bridge, &wiring.callbacks}, + _creator{wiring.bridge, &wiring.callbacks}, + _state{wiring.runtime, BootState::SigningIn}, + _ledger{wiring.runtime, std::nullopt}, + _failure{wiring.runtime, std::string{}} { + if (_options.remote) { + _auth.execute(Login{.username = _options.principal}) + .then(_lifetime, + [this](LoginResult const& result) { + morph::session::Context session; + session.principal = result.principal; + session.token = result.token.hasValue() ? *result.token : std::string{}; + _wiring.bridge.setDefaultSession(std::move(session)); + signedIn(); + }) + .onError(_lifetime, [this](std::exception_ptr const& error) { + fail("sign-in failed: " + morph::reactive::errorMessage(error)); + }); + return; + } + morph::session::Context session; + session.principal = _options.principal; + _wiring.bridge.setDefaultSession(std::move(session)); + signedIn(); +} + +std::string BootController::statusText() const { + switch (_state.get()) { + case BootState::SigningIn: + return "signing in as " + _options.principal + "…"; + case BootState::Ready: + return "book " + std::to_string(_ledger.get().has_value() ? **_ledger.get() : 0); + case BootState::Failed: + return _failure.get(); + default: + return {}; + } +} + +void BootController::signedIn() { + if (!_options.seed) { + ready(LedgerId{_options.ledger}); + return; + } + _creator.execute(CreateLedger{.name = "Personal"}) + .then(_lifetime, [this](CreateLedgerResult const& created) { ready(created.id); }) + .onError(_lifetime, [this](std::exception_ptr const& error) { + fail("creating the demo book failed: " + morph::reactive::errorMessage(error)); + }); +} + +void BootController::ready(LedgerId book) { + _wiring.runtime.batch([&] { + _ledger.set(book); + _state.set(BootState::Ready); + }); +} + +void BootController::fail(std::string message) { + _wiring.runtime.batch([&] { + _failure.set(std::move(message)); + _state.set(BootState::Failed); + }); +} + +} // namespace ledger::client +``` + +Re-run the configure (`cmake build/all`) so `morph_add_rung`'s globs see the new `app/` and `tests/client/` files. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_ledger_tests +./build/all/examples/ledger/ladder_ledger_tests "[ledger][client]" +``` + +Expected: PASS, 9 cases. + +Mutation check: in `BootController`'s constructor, drop the local-mode `setDefaultSession` call. Expected FAIL in +"local mode installs --user and opens the configured book" and in "--seed creates a book…" (the model refuses an +empty principal). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ledger/app examples/ledger/tests/client +git commit -m "wip(ledger): app library foundations and BootController (--user, --seed) + +Signed-off-by: Christian Parpart " +``` + +--- +### Task L3: `AccountsController` — the accounts tab + +Re-expresses `LedgerPresenter`/`LedgerQmlBridge` and `LedgerView.qml`'s conditionals: the book is a +`Query` keyed on the boot's book; the month's entries a `Query` keyed on the book and +the listed month; open, store and undo are `Mutation`s invalidating what they change (so an undo refreshes a listed +month, as the bridge did by hand). A store mints its `ImportOpId` with Part 6's `newUuid()`. The text inputs and the +enabled-ness of each button live here, not in the view (`IntValidator`, `inputMask` and the `enabled:` expressions +of the QML). + +**Files:** +- Create: `examples/ledger/app/controllers/accounts_controller.hpp`, `examples/ledger/app/controllers/accounts_controller.cpp` +- Test: `examples/ledger/tests/client/test_accounts_controller.cpp` + +**Interfaces:** +- Consumes: `BootController::ledger()` (Task L2); `accountRow`, `entryRow`, `parsePositive`, `isMonth` (Task L2); + `morph::examples::newUuid()` (Part 6); `ledger::OpenAccount`, `GetLedger`, `StoreTransaction`, `TransactionLeg`, + `ListTransactions`, `UndoTransaction`, `ImportOpId`; `morph::time::Timestamp::now()`; `morph::math::Rational`. +- Produces: `ledger::client::Transfer{from, to, amountMinor, description}`, `AccountsInput{NewName, Amount, + Description, Month}`; `AccountsController(examples::Wiring, BootController const&)`: `refresh()`, `accounts()`, `entries()`, + `busy()`, `errorText()`, `input(AccountsInput)`, `setInput(AccountsInput, std::string)`, `newKind()`, + `setNewKind(AccountKind)`, `from()`, `setFrom(std::int64_t)`, `to()`, `setTo(std::int64_t)`, `canOpenAccount()`, + `canStore()`, `canList()`, `openAccount()`, `store()`, `storeTransfer(Transfer)`, `list()`, + `undo(std::int64_t journalId)`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/ledger/tests/client/test_accounts_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "controllers/accounts_controller.hpp" +#include "controllers/boot_controller.hpp" +#include "ledger_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using ledger::AccountKind; +using ledger::client::AccountRow; +using ledger::client::AccountsController; +using ledger::client::AccountsInput; +using ledger::client::BootController; +using ledger::client::BootOptions; +using ledger::testing::LocalClient; +using morph::ladder::testkit::DbFixture; + +namespace { + +[[nodiscard]] BootOptions bookOptions(ledger::LedgerId book) { + return BootOptions{.principal = "alice", .remote = false, .seed = false, .ledger = *book}; +} + +[[nodiscard]] std::string balanceOf(AccountsController const& accounts, std::string const& name) { + for (AccountRow const& row : accounts.accounts()) { + if (row.name == name) { + return row.balanceText; + } + } + return "missing"; +} + +} // namespace + +TEST_CASE("ledger::client::AccountsController: a fresh book is empty, then shows an opened account", + "[ledger][client]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + LocalClient client{"alice"}; + BootController boot{client.wiring(), bookOptions(book)}; + AccountsController accounts{client.wiring(), boot}; + REQUIRE(client.settle([&accounts] { return !accounts.busy(); })); + CHECK(accounts.accounts().empty()); + CHECK_FALSE(accounts.canOpenAccount()); + + accounts.setInput(AccountsInput::NewName, "Checking"); + accounts.setNewKind(AccountKind::Asset); + REQUIRE(accounts.canOpenAccount()); + accounts.openAccount(); + + CHECK(accounts.input(AccountsInput::NewName).empty()); + REQUIRE(client.settle([&accounts] { return accounts.accounts().size() == 1; })); + AccountRow const row = accounts.accounts().front(); + CHECK(row.name == "Checking"); + CHECK(row.kind == "asset"); + CHECK(row.currency == "USD"); + CHECK(row.id > 0); +} + +TEST_CASE("ledger::client::AccountsController: a transfer carries balances exactly, never as a float", + "[ledger][client]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + auto const checking = ledger::testing::openAccountDirect(book, "Checking", AccountKind::Asset); + auto const groceries = ledger::testing::openAccountDirect(book, "Groceries", AccountKind::Expense); + LocalClient client{"alice"}; + BootController boot{client.wiring(), bookOptions(book)}; + AccountsController accounts{client.wiring(), boot}; + REQUIRE(client.settle([&accounts] { return accounts.accounts().size() == 2; })); + + accounts.setFrom(*checking); + accounts.setTo(*groceries); + accounts.setInput(AccountsInput::Amount, "5000"); + accounts.setInput(AccountsInput::Description, "Weekly shop"); + REQUIRE(accounts.canStore()); + accounts.store(); + + REQUIRE(client.settle([&accounts] { return balanceOf(accounts, "Checking") == "-50"; })); + CHECK(balanceOf(accounts, "Groceries") == "50"); + CHECK(accounts.errorText().empty()); +} + +TEST_CASE("ledger::client::AccountsController: a model refusal surfaces on errorText", "[ledger][client]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + LocalClient client{"alice"}; + BootController boot{client.wiring(), bookOptions(book)}; + AccountsController accounts{client.wiring(), boot}; + + accounts.storeTransfer(ledger::client::Transfer{.from = ledger::AccountId{999999}, + .to = ledger::AccountId{999998}, + .amountMinor = 100, + .description = "nowhere"}); + + REQUIRE(client.settle([&accounts] { return !accounts.errorText().empty(); })); +} + +TEST_CASE("ledger::client::AccountsController: a listed month shows its entries, and Undo reverses one", + "[ledger][client]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + auto const checking = ledger::testing::openAccountDirect(book, "Checking", AccountKind::Asset); + auto const groceries = ledger::testing::openAccountDirect(book, "Groceries", AccountKind::Expense); + LocalClient client{"alice"}; + BootController boot{client.wiring(), bookOptions(book)}; + AccountsController accounts{client.wiring(), boot}; + accounts.storeTransfer( + ledger::client::Transfer{.from = checking, .to = groceries, .amountMinor = 5000, .description = "Weekly shop"}); + REQUIRE(client.settle([&accounts] { return balanceOf(accounts, "Checking") == "-50"; })); + + accounts.setInput(AccountsInput::Month, ledger::testing::currentMonth()); + REQUIRE(accounts.canList()); + accounts.list(); + REQUIRE(client.settle([&accounts] { return accounts.entries().size() == 1; })); + CHECK(accounts.entries().front().description == "Weekly shop"); + + accounts.undo(accounts.entries().front().id); + + REQUIRE(client.settle([&accounts] { return accounts.entries().size() == 2; })); + REQUIRE(client.settle([&accounts] { return balanceOf(accounts, "Checking") == "0"; })); + CHECK(balanceOf(accounts, "Groceries") == "0"); +} + +TEST_CASE("ledger::client::AccountsController: buttons are enabled only by valid input", "[ledger][client]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + LocalClient client{"alice"}; + BootController boot{client.wiring(), bookOptions(book)}; + AccountsController accounts{client.wiring(), boot}; + accounts.setFrom(1); + accounts.setTo(2); + + CHECK_FALSE(accounts.canStore()); + accounts.setInput(AccountsInput::Amount, "0"); + CHECK_FALSE(accounts.canStore()); + accounts.setInput(AccountsInput::Amount, "abc"); + CHECK_FALSE(accounts.canStore()); + accounts.setInput(AccountsInput::Amount, "250"); + CHECK(accounts.canStore()); + accounts.setInput(AccountsInput::Month, "2026-13"); + CHECK_FALSE(accounts.canList()); + accounts.setInput(AccountsInput::Month, "2026-01"); + CHECK(accounts.canList()); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_ledger_tests` +Expected: compile error, `'controllers/accounts_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/ledger/app/controllers/accounts_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/boot_controller.hpp" +#include "ledger/models/ledger_model.hpp" +#include "support/ledger_format.hpp" + +namespace ledger::client { + +/// @brief A transfer between two accounts of the open book. +struct Transfer { + /// @brief The account debited. + AccountId from; + /// @brief The account credited. + AccountId to; + /// @brief The amount in minor units (cents). + std::int64_t amountMinor = 0; + /// @brief The entry's description. + std::string description; +}; + +/// @brief The accounts tab's text inputs. +enum class AccountsInput : std::uint8_t { NewName, Amount, Description, Month }; + +/// @brief The accounts tab: the book's accounts, opening one, storing a transfer, a month's entries, and undo. +class AccountsController { +public: + /// @param wiring The runtime, bridge and owner this controller uses. + /// @param boot Names the book. Borrowed: it must outlive this controller. + AccountsController(morph::examples::Wiring wiring, BootController const& boot); + + /// @brief Fetches the book again. + void refresh(); + /// @brief The book's accounts. Tracked. @return One row per account. + [[nodiscard]] std::vector accounts() const; + /// @brief The listed month's entries, oldest first. Tracked. @return One row per entry. + [[nodiscard]] std::vector entries() const; + /// @brief Whether anything is in flight. Tracked. @return True while a fetch or a write is. + [[nodiscard]] bool busy() const; + /// @brief The first failure among writes and fetches. Tracked. @return The message, or empty. + [[nodiscard]] std::string errorText() const; + /// @brief A text input's value. Tracked. @param which The input. @return Its text. + [[nodiscard]] std::string const& input(AccountsInput which) const; + /// @brief Sets a text input. @param which The input. @param text Its new text. + void setInput(AccountsInput which, std::string text); + /// @brief The new account's kind. Tracked. @return The kind. + [[nodiscard]] AccountKind newKind() const { return _newKind.get(); } + /// @brief Sets the new account's kind. @param kind The kind. + void setNewKind(AccountKind kind) { _newKind.set(kind); } + /// @brief The account a transfer debits. Tracked. @return Its id, or empty. + [[nodiscard]] std::optional const& from() const { return _from.get(); } + /// @brief Chooses the account a transfer debits. @param accountId The account. + void setFrom(std::int64_t accountId) { _from.set(accountId); } + /// @brief The account a transfer credits. Tracked. @return Its id, or empty. + [[nodiscard]] std::optional const& to() const { return _to.get(); } + /// @brief Chooses the account a transfer credits. @param accountId The account. + void setTo(std::int64_t accountId) { _to.set(accountId); } + /// @brief Whether "Open account" can run. Tracked. @return True with a book and a name. + [[nodiscard]] bool canOpenAccount() const; + /// @brief Whether "Store" can run. Tracked. @return True with a book, both accounts and a positive amount. + [[nodiscard]] bool canStore() const; + /// @brief Whether "List" can run. Tracked. @return True with a book and a `YYYY-MM` month. + [[nodiscard]] bool canList() const; + /// @brief Opens an account from the inputs (USD) and clears the name. + void openAccount(); + /// @brief Stores a transfer from the inputs. + void store(); + /// @brief Stores @p transfer as one two-legged entry dated now, under a fresh import op id. + /// @param transfer The transfer. + void storeTransfer(Transfer transfer); + /// @brief Lists the input month's entries (again, when it is already listed). + void list(); + /// @brief Reverses an entry with a compensating one. + /// @param journalId The entry. + void undo(std::int64_t journalId); + +private: + static constexpr std::size_t kInputs = 4; + + morph::examples::Wiring _wiring; + BootController const* _boot; + morph::bridge::BridgeHandler _handler; + std::array>, kInputs> _inputs; + morph::reactive::Signal _newKind; + morph::reactive::Signal> _from; + morph::reactive::Signal> _to; + morph::reactive::Signal> _listedMonth; + morph::reactive::Query _ledger; + morph::reactive::Query _entries; + morph::reactive::Mutation _open; + morph::reactive::Mutation _store; + morph::reactive::Mutation _undo; +}; + +} // namespace ledger::client +``` + +Create `examples/ledger/app/controllers/accounts_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/accounts_controller.hpp" + +#include +#include +#include +#include + +#include "app/uuid.hpp" + +namespace ledger::client { + +namespace { + +// The legs are written in cents: numerator = minor units, scale = two decimal places (the old client's encoding). +constexpr std::uint32_t kMinorUnitPlaces = 2; + +using Text = morph::reactive::Signal; + +[[nodiscard]] std::array, 4> makeInputs(morph::reactive::Runtime& runtime) { + return {std::make_unique(runtime, std::string{}), std::make_unique(runtime, std::string{}), + std::make_unique(runtime, std::string{}), std::make_unique(runtime, std::string{})}; +} + +[[nodiscard]] morph::math::Rational cents(std::int64_t minor) { + return morph::math::Rational{morph::math::Numerator{minor}, morph::math::Denominator{1}, + morph::math::DecimalPlaces{kMinorUnitPlaces}}; +} + +} // namespace + +AccountsController::AccountsController(morph::examples::Wiring wiring, BootController const& boot) + : _wiring{wiring}, + _boot{&boot}, + _handler{wiring.bridge, &wiring.callbacks}, + _inputs{makeInputs(wiring.runtime)}, + _newKind{wiring.runtime, AccountKind::Asset}, + _from{wiring.runtime, std::nullopt}, + _to{wiring.runtime, std::nullopt}, + _listedMonth{wiring.runtime, std::nullopt}, + _ledger{wiring.runtime, _handler, + [this]() -> std::optional { + auto const& book = _boot->ledger(); + if (!book) { + return std::nullopt; + } + return GetLedger{.ledgerId = *book}; + }}, + _entries{wiring.runtime, _handler, + [this]() -> std::optional { + auto const& book = _boot->ledger(); + auto const& month = _listedMonth.get(); + if (!book || !month) { + return std::nullopt; + } + return ListTransactions{.ledgerId = *book, .month = *month}; + }}, + _open{wiring.runtime, _handler, morph::reactive::MutationOptions{.invalidates = {&_ledger}}}, + _store{wiring.runtime, _handler, morph::reactive::MutationOptions{.invalidates = {&_ledger, &_entries}}}, + _undo{wiring.runtime, _handler, morph::reactive::MutationOptions{.invalidates = {&_ledger, &_entries}}} {} + +void AccountsController::refresh() { _ledger.refetch(); } + +std::vector AccountsController::accounts() const { + std::vector rows; + if (auto const& book = _ledger.value()) { + for (AccountInfo const& account : book->accounts) { + rows.push_back(accountRow(account)); + } + } + return rows; +} + +std::vector AccountsController::entries() const { + std::vector rows; + if (auto const& listed = _entries.value()) { + for (TransactionEntryInfo const& entry : listed->entries) { + rows.push_back(entryRow(entry)); + } + } + return rows; +} + +bool AccountsController::busy() const { + return _ledger.pending() || _entries.pending() || _open.pending() || _store.pending() || _undo.pending(); +} + +std::string AccountsController::errorText() const { + for (std::exception_ptr const& error : + {_open.error(), _store.error(), _undo.error(), _ledger.error(), _entries.error()}) { + if (error != nullptr) { + return morph::reactive::errorMessage(error); + } + } + return {}; +} + +std::string const& AccountsController::input(AccountsInput which) const { + return _inputs.at(static_cast(which))->get(); +} + +void AccountsController::setInput(AccountsInput which, std::string text) { + _inputs.at(static_cast(which))->set(std::move(text)); +} + +bool AccountsController::canOpenAccount() const { + return _boot->ledger().has_value() && !input(AccountsInput::NewName).empty(); +} + +bool AccountsController::canStore() const { + return _boot->ledger().has_value() && _from.get().has_value() && _to.get().has_value() && + parsePositive(input(AccountsInput::Amount)).has_value(); +} + +bool AccountsController::canList() const { + return _boot->ledger().has_value() && isMonth(input(AccountsInput::Month)); +} + +void AccountsController::openAccount() { + auto const& book = _boot->ledger(); + if (!book || !canOpenAccount()) { + return; + } + _open.run(OpenAccount{ + .ledgerId = *book, .name = input(AccountsInput::NewName), .kind = _newKind.peek(), .currency = Currency::USD}); + setInput(AccountsInput::NewName, std::string{}); +} + +void AccountsController::store() { + std::optional const amount = parsePositive(input(AccountsInput::Amount)); + std::optional const debited = _from.peek(); + std::optional const credited = _to.peek(); + if (!amount || !debited || !credited) { + return; + } + storeTransfer(Transfer{.from = AccountId{*debited}, + .to = AccountId{*credited}, + .amountMinor = *amount, + .description = input(AccountsInput::Description)}); +} + +void AccountsController::storeTransfer(Transfer transfer) { + auto const& book = _boot->ledger(); + if (!book) { + return; + } + _store.run(StoreTransaction{ + .ledgerId = *book, + .description = std::move(transfer.description), + .date = morph::time::Timestamp::now(), + .legs = {TransactionLeg{.accountId = transfer.from, .amount = cents(-transfer.amountMinor)}, + TransactionLeg{.accountId = transfer.to, .amount = cents(transfer.amountMinor)}}, + .opId = ImportOpId{morph::examples::newUuid()}}); +} + +void AccountsController::list() { + if (!canList()) { + return; + } + std::string const month = input(AccountsInput::Month); + if (_listedMonth.peek() == month) { + _entries.refetch(); + return; + } + _listedMonth.set(month); +} + +void AccountsController::undo(std::int64_t journalId) { + auto const& book = _boot->ledger(); + if (!book) { + return; + } + _undo.run(UndoTransaction{.ledgerId = *book, .journalId = JournalId{journalId}}); +} + +} // namespace ledger::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_ledger_tests +./build/all/examples/ledger/ladder_ledger_tests "[ledger][client]" +``` + +Expected: PASS. + +Mutation check: remove `&_entries` from `_undo`'s `invalidates`. Expected FAIL in "a listed month shows its +entries, and Undo reverses one" (the entries list never gains the reversal). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ledger/app/controllers/accounts_controller.hpp \ + examples/ledger/app/controllers/accounts_controller.cpp \ + examples/ledger/tests/client/test_accounts_controller.cpp +git commit -m "wip(ledger): AccountsController: accounts, transfers, a month's entries, undo + +Signed-off-by: Christian Parpart " +``` + +--- +### Task L4: `BudgetController` — the budgets tab + +Re-expresses `BudgetPresenter`/`BudgetQmlBridge` and `BudgetView.qml`: category, account link, budget and limit +are `Mutation`s on the book-keyed `BudgetModel` handler; the report is a `Query` keyed on the last +"Report" request; the "id N" labels and the status line ("Category created (id N)", "Budget created (id N)", "Limit +set") are projections of the mutations' last results. A limit is written in minor units at the currency's scale, as +the bridge did. + +**Files:** +- Create: `examples/ledger/app/controllers/budget_controller.hpp`, `examples/ledger/app/controllers/budget_controller.cpp` +- Test: `examples/ledger/tests/client/test_budget_controller.cpp` + +**Interfaces:** +- Consumes: `BootController::ledger()`, `parsePositive`, `parseAtLeast`, `isMonth` (Task L2); + `ledger::BudgetModel`, `CreateCategory`, `LinkAccountToCategory`, `CreateBudget`, `SetBudgetLimit`, + `GetBudgetReport`, `GetBudgetReportResult`; `formatMoney`, `currencyDecimalPlaces`, `currencyToCode`. +- Produces: `ledger::client::BudgetInput{CategoryName, LinkAccount, LinkCategory, BudgetName, BudgetCategory, + LimitBudget, LimitMonth, LimitMinor}`; `BudgetController(examples::Wiring, BootController const&)`: `input`, `setInput`, + `canCreateCategory()`, `canLink()`, `canCreateBudget()`, `canSetLimit()`, `canReport()`, `createCategory()`, + `linkAccount()`, `createBudget()`, `setLimit()`, `report()`, `lastCategoryId()`, `lastBudgetId()`, + `lastCategoryText()`, `lastBudgetText()`, `statusText()`, `limitText()`, `spentText()`, `currencyText()`, `busy()`, + `errorText()`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/ledger/tests/client/test_budget_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include "controllers/boot_controller.hpp" +#include "controllers/budget_controller.hpp" +#include "ledger_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using ledger::client::BootController; +using ledger::client::BootOptions; +using ledger::client::BudgetController; +using ledger::client::BudgetInput; +using ledger::testing::LocalClient; +using morph::ladder::testkit::DbFixture; + +TEST_CASE("ledger::client::BudgetController: a category, a budget and a month's limit report exactly", + "[ledger][client]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + LocalClient client{"alice"}; + BootController boot{client.wiring(), BootOptions{.principal = "alice", .remote = false, .seed = false, .ledger = *book}}; + BudgetController budget{client.wiring(), boot}; + + budget.setInput(BudgetInput::CategoryName, "Groceries"); + REQUIRE(budget.canCreateCategory()); + budget.createCategory(); + // The status line is written by an effect, which runs in the flush after the reply: wait for it, not the id. + REQUIRE(client.settle([&budget] { return !budget.statusText().empty(); })); + REQUIRE(budget.lastCategoryId().has_value()); + std::string const category = std::to_string(*budget.lastCategoryId()); + CHECK(budget.lastCategoryText() == "id " + category); + CHECK(budget.statusText() == "Category created (id " + category + ")"); + + budget.setInput(BudgetInput::BudgetName, "Monthly groceries"); + budget.setInput(BudgetInput::BudgetCategory, category); + REQUIRE(budget.canCreateBudget()); + budget.createBudget(); + REQUIRE(client.settle([&budget] { return budget.lastBudgetId().has_value(); })); + std::string const budgetId = std::to_string(*budget.lastBudgetId()); + + budget.setInput(BudgetInput::LimitBudget, budgetId); + budget.setInput(BudgetInput::LimitMonth, "2026-01"); + budget.setInput(BudgetInput::LimitMinor, "30000"); + REQUIRE(budget.canSetLimit()); + budget.setLimit(); + REQUIRE(client.settle([&budget] { return budget.statusText() == "Limit set"; })); + + REQUIRE(budget.canReport()); + budget.report(); + REQUIRE(client.settle([&budget] { return budget.limitText() != "-"; })); + CHECK(budget.limitText() == "300"); + CHECK(budget.spentText() == "0"); + CHECK(budget.currencyText() == "USD"); + CHECK(budget.errorText().empty()); +} + +TEST_CASE("ledger::client::BudgetController: a refused report surfaces on errorText", "[ledger][client]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + LocalClient client{"alice"}; + BootController boot{client.wiring(), BootOptions{.principal = "alice", .remote = false, .seed = false, .ledger = *book}}; + BudgetController budget{client.wiring(), boot}; + budget.setInput(BudgetInput::LimitBudget, "999999"); + budget.setInput(BudgetInput::LimitMonth, "2026-01"); + + budget.report(); + + REQUIRE(client.settle([&budget] { return !budget.errorText().empty(); })); + CHECK(budget.limitText() == "-"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_ledger_tests` +Expected: compile error, `'controllers/budget_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/ledger/app/controllers/budget_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/boot_controller.hpp" +#include "ledger/models/budget_model.hpp" + +namespace ledger::client { + +/// @brief The budgets tab's text inputs. +enum class BudgetInput : std::uint8_t { + CategoryName, + LinkAccount, + LinkCategory, + BudgetName, + BudgetCategory, + LimitBudget, + LimitMonth, + LimitMinor +}; + +/// @brief The budgets tab: categories, account links, budgets, monthly limits and the month's report. +class BudgetController { +public: + /// @param wiring The runtime, bridge and owner this controller uses. + /// @param boot Names the book. Borrowed: it must outlive this controller. + BudgetController(morph::examples::Wiring wiring, BootController const& boot); + ~BudgetController(); + BudgetController(BudgetController const&) = delete; + BudgetController& operator=(BudgetController const&) = delete; + BudgetController(BudgetController&&) = delete; + BudgetController& operator=(BudgetController&&) = delete; + + /// @brief A text input's value. Tracked. @param which The input. @return Its text. + [[nodiscard]] std::string const& input(BudgetInput which) const; + /// @brief Sets a text input. @param which The input. @param text Its new text. + void setInput(BudgetInput which, std::string text); + /// @brief Tracked. @return Whether "Create category" can run (a book and a name). + [[nodiscard]] bool canCreateCategory() const; + /// @brief Tracked. @return Whether "Link" can run (an account id and a category id). + [[nodiscard]] bool canLink() const; + /// @brief Tracked. @return Whether "Create budget" can run (a book, a name and a category id). + [[nodiscard]] bool canCreateBudget() const; + /// @brief Tracked. @return Whether "Set limit" can run (a budget id, a month and a non-negative amount). + [[nodiscard]] bool canSetLimit() const; + /// @brief Tracked. @return Whether "Report" can run (a budget id and a month). + [[nodiscard]] bool canReport() const; + /// @brief Creates a category in the book from the inputs. + void createCategory(); + /// @brief Links an account to a category from the inputs. + void linkAccount(); + /// @brief Creates a budget for a category from the inputs. + void createBudget(); + /// @brief Sets a month's limit (USD, minor units) from the inputs. + void setLimit(); + /// @brief Fetches the month's report for the limit inputs' budget. + void report(); + /// @brief Tracked. @return The last created category's id, or empty. + [[nodiscard]] std::optional lastCategoryId() const; + /// @brief Tracked. @return The last created budget's id, or empty. + [[nodiscard]] std::optional lastBudgetId() const; + /// @brief Tracked. @return `"id "` for the last category, or empty. + [[nodiscard]] std::string lastCategoryText() const; + /// @brief Tracked. @return `"id "` for the last budget, or empty. + [[nodiscard]] std::string lastBudgetText() const; + /// @brief Tracked. @return What the last write did, or empty. + [[nodiscard]] std::string const& statusText() const { return _status.get(); } + /// @brief Tracked. @return The report's limit, exactly, or `"-"`. + [[nodiscard]] std::string limitText() const; + /// @brief Tracked. @return The report's spend, exactly, or `"-"`. + [[nodiscard]] std::string spentText() const; + /// @brief Tracked. @return The report's currency code, or `"-"`. + [[nodiscard]] std::string currencyText() const; + /// @brief Tracked. @return Whether a write or the report is in flight. + [[nodiscard]] bool busy() const; + /// @brief Tracked. @return The first failure, or empty. + [[nodiscard]] std::string errorText() const; + +private: + static constexpr std::size_t kInputs = 8; + + morph::examples::Wiring _wiring; + BootController const* _boot; + morph::bridge::BridgeHandler _handler; + std::array>, kInputs> _inputs; + morph::reactive::Signal _status; + morph::reactive::Signal> _reportRequest; + morph::reactive::Mutation _createCategory; + morph::reactive::Mutation _link; + morph::reactive::Mutation _createBudget; + morph::reactive::Mutation _setLimit; + morph::reactive::Query _report; + std::unique_ptr _onCategory; + std::unique_ptr _onBudget; + std::unique_ptr _onLimit; +}; + +} // namespace ledger::client +``` + +Create `examples/ledger/app/controllers/budget_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/budget_controller.hpp" + +#include +#include +#include + +#include "ledger/core/money.hpp" +#include "ledger/core/units.hpp" +#include "support/ledger_format.hpp" + +namespace ledger::client { + +namespace { + +using Text = morph::reactive::Signal; + +[[nodiscard]] std::array, 8> makeInputs(morph::reactive::Runtime& runtime) { + std::array, 8> inputs; + for (auto& input : inputs) { + input = std::make_unique(runtime, std::string{}); + } + return inputs; +} + +template +[[nodiscard]] std::optional idOf(std::optional const& created) { + if (!created || !created->hasValue()) { + return std::nullopt; + } + return **created; +} + +} // namespace + +BudgetController::BudgetController(morph::examples::Wiring wiring, BootController const& boot) + : _wiring{wiring}, + _boot{&boot}, + _handler{wiring.bridge, &wiring.callbacks}, + _inputs{makeInputs(wiring.runtime)}, + _status{wiring.runtime, std::string{}}, + _reportRequest{wiring.runtime, std::nullopt}, + _createCategory{wiring.runtime, _handler}, + _link{wiring.runtime, _handler}, + _createBudget{wiring.runtime, _handler}, + _setLimit{wiring.runtime, _handler}, + _report{wiring.runtime, _handler, [this] { return _reportRequest.get(); }} { + auto const announce = [this](std::string text) { + _wiring.runtime.untracked([&] { _status.set(std::move(text)); }); + }; + _onCategory = std::make_unique(_wiring.runtime, [this, announce] { + if (std::optional const created = idOf(_createCategory.lastResult())) { + announce("Category created (id " + std::to_string(*created) + ")"); + } + }); + _onBudget = std::make_unique(_wiring.runtime, [this, announce] { + if (std::optional const created = idOf(_createBudget.lastResult())) { + announce("Budget created (id " + std::to_string(*created) + ")"); + } + }); + _onLimit = std::make_unique(_wiring.runtime, [this, announce] { + if (_setLimit.lastResult().has_value()) { + announce("Limit set"); + } + }); +} + +BudgetController::~BudgetController() = default; + +std::string const& BudgetController::input(BudgetInput which) const { + return _inputs.at(static_cast(which))->get(); +} + +void BudgetController::setInput(BudgetInput which, std::string text) { + _inputs.at(static_cast(which))->set(std::move(text)); +} + +bool BudgetController::canCreateCategory() const { + return _boot->ledger().has_value() && !input(BudgetInput::CategoryName).empty(); +} + +bool BudgetController::canLink() const { + return parsePositive(input(BudgetInput::LinkAccount)).has_value() && + parsePositive(input(BudgetInput::LinkCategory)).has_value(); +} + +bool BudgetController::canCreateBudget() const { + return _boot->ledger().has_value() && !input(BudgetInput::BudgetName).empty() && + parsePositive(input(BudgetInput::BudgetCategory)).has_value(); +} + +bool BudgetController::canSetLimit() const { + return canReport() && parseAtLeast(input(BudgetInput::LimitMinor), 0).has_value(); +} + +bool BudgetController::canReport() const { + return parsePositive(input(BudgetInput::LimitBudget)).has_value() && isMonth(input(BudgetInput::LimitMonth)); +} + +void BudgetController::createCategory() { + auto const& book = _boot->ledger(); + if (!book || !canCreateCategory()) { + return; + } + _createCategory.run(CreateCategory{.ledgerId = *book, .name = input(BudgetInput::CategoryName)}); + setInput(BudgetInput::CategoryName, std::string{}); +} + +void BudgetController::linkAccount() { + auto const account = parsePositive(input(BudgetInput::LinkAccount)); + auto const category = parsePositive(input(BudgetInput::LinkCategory)); + if (!account || !category) { + return; + } + _link.run(LinkAccountToCategory{.accountId = AccountId{*account}, .categoryId = CategoryId{*category}}); +} + +void BudgetController::createBudget() { + auto const& book = _boot->ledger(); + auto const category = parsePositive(input(BudgetInput::BudgetCategory)); + if (!book || !category || input(BudgetInput::BudgetName).empty()) { + return; + } + _createBudget.run( + CreateBudget{.ledgerId = *book, .name = input(BudgetInput::BudgetName), .categoryId = CategoryId{*category}}); +} + +void BudgetController::setLimit() { + auto const target = parsePositive(input(BudgetInput::LimitBudget)); + auto const minor = parseAtLeast(input(BudgetInput::LimitMinor), 0); + if (!target || !minor || !isMonth(input(BudgetInput::LimitMonth))) { + return; + } + auto const limit = morph::math::Rational{morph::math::Numerator{*minor}, morph::math::Denominator{1}, + morph::math::DecimalPlaces{currencyDecimalPlaces(Currency::USD)}}; + _setLimit.run(SetBudgetLimit{ + .budgetId = BudgetId{*target}, .month = input(BudgetInput::LimitMonth), .limit = limit, .currency = Currency::USD}); +} + +void BudgetController::report() { + auto const target = parsePositive(input(BudgetInput::LimitBudget)); + if (!target) { + return; + } + _reportRequest.set(GetBudgetReport{.budgetId = BudgetId{*target}, .month = input(BudgetInput::LimitMonth)}); +} + +std::optional BudgetController::lastCategoryId() const { return idOf(_createCategory.lastResult()); } + +std::optional BudgetController::lastBudgetId() const { return idOf(_createBudget.lastResult()); } + +std::string BudgetController::lastCategoryText() const { + auto const created = lastCategoryId(); + return created ? "id " + std::to_string(*created) : std::string{}; +} + +std::string BudgetController::lastBudgetText() const { + auto const created = lastBudgetId(); + return created ? "id " + std::to_string(*created) : std::string{}; +} + +std::string BudgetController::limitText() const { + auto const& result = _report.value(); + return result ? formatMoney(result->currency, result->limit) : std::string{"-"}; +} + +std::string BudgetController::spentText() const { + auto const& result = _report.value(); + return result ? formatMoney(result->currency, result->spent) : std::string{"-"}; +} + +std::string BudgetController::currencyText() const { + auto const& result = _report.value(); + return result ? std::string{currencyToCode(result->currency)} : std::string{"-"}; +} + +bool BudgetController::busy() const { + return _createCategory.pending() || _link.pending() || _createBudget.pending() || _setLimit.pending() || + _report.pending(); +} + +std::string BudgetController::errorText() const { + for (std::exception_ptr const& error : + {_createCategory.error(), _link.error(), _createBudget.error(), _setLimit.error(), _report.error()}) { + if (error != nullptr) { + return morph::reactive::errorMessage(error); + } + } + return {}; +} + +} // namespace ledger::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_ledger_tests +./build/all/examples/ledger/ladder_ledger_tests "[ledger][client]" +``` + +Expected: PASS. + +Mutation check: in `setLimit`, write the limit at `DecimalPlaces{0}`. Expected FAIL in "a category, a budget and a +month's limit report exactly" (`limitText()` is `"30000"`). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ledger/app/controllers/budget_controller.hpp examples/ledger/app/controllers/budget_controller.cpp \ + examples/ledger/tests/client/test_budget_controller.cpp +git commit -m "wip(ledger): BudgetController: categories, budgets, limits and the month's report + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task L5: `RulesController` — the rules tab + +Re-expresses `RulePresenter`/`RuleQmlBridge` and `RulesView.qml`: create and update are `Mutation`s on the +book-keyed `RuleModel` handler; "Last rule" shows the id after a create and the id, match and bumped version after +an update. + +**Files:** +- Create: `examples/ledger/app/controllers/rules_controller.hpp`, `examples/ledger/app/controllers/rules_controller.cpp` +- Test: `examples/ledger/tests/client/test_ledger_rules_controller.cpp` + +**Interfaces:** +- Consumes: `BootController::ledger()`, `parsePositive` (Task L2); `ledger::RuleModel`, `CreateRule`, `UpdateRule`, + `RuleInfo`, `RuleTrigger`, `RuleAction`. +- Produces: `ledger::client::RuleInput{MatchText, CategoryId, EditRuleId, EditMatch, EditCategory}`; + `RulesController(examples::Wiring, BootController const&)`: `input`, `setInput`, `canCreate()`, `canUpdate()`, `create()`, + `update()`, `lastRuleId()`, `lastRuleMatch()`, `lastRuleVersion()`, `statusText()`, `busy()`, `errorText()`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/ledger/tests/client/test_ledger_rules_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include "controllers/boot_controller.hpp" +#include "controllers/rules_controller.hpp" +#include "ledger_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using ledger::client::BootController; +using ledger::client::BootOptions; +using ledger::client::RuleInput; +using ledger::client::RulesController; +using ledger::testing::LocalClient; +using morph::ladder::testkit::DbFixture; + +TEST_CASE("ledger::client::RulesController: create, then update with the bumped version shown", "[ledger][client]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + LocalClient client{"alice"}; + BootController boot{client.wiring(), BootOptions{.principal = "alice", .remote = false, .seed = false, .ledger = *book}}; + RulesController rules{client.wiring(), boot}; + CHECK(rules.lastRuleId() == "-"); + CHECK_FALSE(rules.canCreate()); + + rules.setInput(RuleInput::MatchText, "COFFEE"); + rules.setInput(RuleInput::CategoryId, "7"); + REQUIRE(rules.canCreate()); + rules.create(); + REQUIRE(client.settle([&rules] { return rules.lastRuleId() != "-"; })); + CHECK(rules.statusText() == "Rule created"); + CHECK(rules.lastRuleVersion() == "-"); + + rules.setInput(RuleInput::EditRuleId, rules.lastRuleId()); + rules.setInput(RuleInput::EditMatch, "ESPRESSO"); + rules.setInput(RuleInput::EditCategory, "9"); + REQUIRE(rules.canUpdate()); + rules.update(); + REQUIRE(client.settle([&rules] { return rules.statusText() == "Rule updated"; })); + CHECK(rules.lastRuleMatch() == "ESPRESSO"); + CHECK(std::stoi(rules.lastRuleVersion()) > 1); +} + +TEST_CASE("ledger::client::RulesController: a refusal surfaces on errorText", "[ledger][client]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + LocalClient client{"alice"}; + BootController boot{client.wiring(), BootOptions{.principal = "alice", .remote = false, .seed = false, .ledger = *book}}; + RulesController rules{client.wiring(), boot}; + rules.setInput(RuleInput::EditRuleId, "999999"); + rules.setInput(RuleInput::EditMatch, "NOTHING"); + + rules.update(); + + REQUIRE(client.settle([&rules] { return !rules.errorText().empty(); })); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_ledger_tests` +Expected: compile error, `'controllers/rules_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/ledger/app/controllers/rules_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/boot_controller.hpp" +#include "ledger/models/rule_model.hpp" + +namespace ledger::client { + +/// @brief The rules tab's text inputs. +enum class RuleInput : std::uint8_t { MatchText, CategoryId, EditRuleId, EditMatch, EditCategory }; + +/// @brief The rules tab: create a categorisation rule, edit one (which bumps its version), and show the last one. +class RulesController { +public: + /// @param wiring The runtime, bridge and owner this controller uses. + /// @param boot Names the book. Borrowed: it must outlive this controller. + RulesController(morph::examples::Wiring wiring, BootController const& boot); + ~RulesController(); + RulesController(RulesController const&) = delete; + RulesController& operator=(RulesController const&) = delete; + RulesController(RulesController&&) = delete; + RulesController& operator=(RulesController&&) = delete; + + /// @brief A text input's value. Tracked. @param which The input. @return Its text. + [[nodiscard]] std::string const& input(RuleInput which) const; + /// @brief Sets a text input. @param which The input. @param text Its new text. + void setInput(RuleInput which, std::string text); + /// @brief Tracked. @return Whether "Create rule" can run (a book and a match text). + [[nodiscard]] bool canCreate() const; + /// @brief Tracked. @return Whether "Update rule" can run (a rule id). + [[nodiscard]] bool canUpdate() const; + /// @brief Creates a "description contains → set category" rule from the inputs. + void create(); + /// @brief Edits the rule the inputs name. + void update(); + /// @brief Tracked. @return The last rule's id, or `"-"`. + [[nodiscard]] std::string lastRuleId() const; + /// @brief Tracked. @return The last rule's match text after an update, or `"-"`. + [[nodiscard]] std::string lastRuleMatch() const; + /// @brief Tracked. @return The last rule's version after an update, or `"-"`. + [[nodiscard]] std::string lastRuleVersion() const; + /// @brief Tracked. @return `"Rule created"`, `"Rule updated"`, or empty. + [[nodiscard]] std::string const& statusText() const { return _status.get(); } + /// @brief Tracked. @return Whether a write is in flight. + [[nodiscard]] bool busy() const { return _create.pending() || _update.pending(); } + /// @brief Tracked. @return The first failure, or empty. + [[nodiscard]] std::string errorText() const; + +private: + struct LastRule { + std::string id; + std::string match; + std::string version; + bool operator==(LastRule const&) const = default; + }; + static constexpr std::size_t kInputs = 5; + + morph::examples::Wiring _wiring; + BootController const* _boot; + morph::bridge::BridgeHandler _handler; + std::array>, kInputs> _inputs; + morph::reactive::Signal> _lastRule; + morph::reactive::Signal _status; + morph::reactive::Mutation _create; + morph::reactive::Mutation _update; + std::unique_ptr _onCreated; + std::unique_ptr _onUpdated; +}; + +} // namespace ledger::client +``` + +Create `examples/ledger/app/controllers/rules_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/rules_controller.hpp" + +#include +#include + +#include "support/ledger_format.hpp" + +namespace ledger::client { + +namespace { + +using Text = morph::reactive::Signal; + +[[nodiscard]] std::array, 5> makeInputs(morph::reactive::Runtime& runtime) { + std::array, 5> inputs; + for (auto& input : inputs) { + input = std::make_unique(runtime, std::string{}); + } + return inputs; +} + +} // namespace + +RulesController::RulesController(morph::examples::Wiring wiring, BootController const& boot) + : _wiring{wiring}, + _boot{&boot}, + _handler{wiring.bridge, &wiring.callbacks}, + _inputs{makeInputs(wiring.runtime)}, + _lastRule{wiring.runtime, std::nullopt}, + _status{wiring.runtime, std::string{}}, + _create{wiring.runtime, _handler}, + _update{wiring.runtime, _handler} { + _onCreated = std::make_unique(_wiring.runtime, [this] { + auto const& created = _create.lastResult(); + if (created && created->hasValue()) { + LastRule rule{.id = std::to_string(**created), .match = "-", .version = "-"}; + _wiring.runtime.untracked([&] { + _wiring.runtime.batch([&] { + _lastRule.set(std::move(rule)); + _status.set("Rule created"); + }); + }); + } + }); + _onUpdated = std::make_unique(_wiring.runtime, [this] { + auto const& updated = _update.lastResult(); + if (updated && updated->id.hasValue()) { + LastRule rule{.id = std::to_string(*updated->id), + .match = updated->matchText, + .version = std::to_string(updated->version)}; + _wiring.runtime.untracked([&] { + _wiring.runtime.batch([&] { + _lastRule.set(std::move(rule)); + _status.set("Rule updated"); + }); + }); + } + }); +} + +RulesController::~RulesController() = default; + +std::string const& RulesController::input(RuleInput which) const { + return _inputs.at(static_cast(which))->get(); +} + +void RulesController::setInput(RuleInput which, std::string text) { + _inputs.at(static_cast(which))->set(std::move(text)); +} + +bool RulesController::canCreate() const { + return _boot->ledger().has_value() && !input(RuleInput::MatchText).empty(); +} + +bool RulesController::canUpdate() const { return parsePositive(input(RuleInput::EditRuleId)).has_value(); } + +void RulesController::create() { + auto const& book = _boot->ledger(); + if (!book || !canCreate()) { + return; + } + _create.run(CreateRule{.ledgerId = *book, + .trigger = RuleTrigger::DescriptionContains, + .matchText = input(RuleInput::MatchText), + .action = RuleAction::SetCategory, + .actionValue = input(RuleInput::CategoryId)}); +} + +void RulesController::update() { + auto const rule = parsePositive(input(RuleInput::EditRuleId)); + if (!rule) { + return; + } + _update.run(UpdateRule{.ruleId = RuleId{*rule}, + .matchText = input(RuleInput::EditMatch), + .actionValue = input(RuleInput::EditCategory), + .expectedVersion = std::nullopt}); +} + +std::string RulesController::lastRuleId() const { + auto const& rule = _lastRule.get(); + return rule ? rule->id : std::string{"-"}; +} + +std::string RulesController::lastRuleMatch() const { + auto const& rule = _lastRule.get(); + return rule ? rule->match : std::string{"-"}; +} + +std::string RulesController::lastRuleVersion() const { + auto const& rule = _lastRule.get(); + return rule ? rule->version : std::string{"-"}; +} + +std::string RulesController::errorText() const { + for (std::exception_ptr const& error : {_create.error(), _update.error()}) { + if (error != nullptr) { + return morph::reactive::errorMessage(error); + } + } + return {}; +} + +} // namespace ledger::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_ledger_tests +./build/all/examples/ledger/ladder_ledger_tests "[ledger][client]" +``` + +Expected: PASS. + +Mutation check: in `_onUpdated`, set `.version = "-"`. Expected FAIL in "create, then update with the bumped version +shown" (`std::stoi("-")` throws). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ledger/app/controllers/rules_controller.hpp examples/ledger/app/controllers/rules_controller.cpp \ + examples/ledger/tests/client/test_ledger_rules_controller.cpp +git commit -m "wip(ledger): RulesController: create and update categorisation rules + +Signed-off-by: Christian Parpart " +``` + +--- +### Task L6: `StatementController` — the statement job, polled by a `Query` with `refreshEvery` + +Re-expresses `ReportPresenter`, `ReportJobPoller` and `ReportQmlBridge` (spec 4 §5: "the statement job poll is a +`Query` with `refreshEvery`"). A request is a `Mutation`; its job id becomes the key of a +`Query` that refetches every two seconds on the injected `Scheduler`; an effect settles the +outcome when the job leaves `Pending` — or the poll fails — and clears the key, which makes the query idle and so +stops the polling while the outcome (and its lines) stays. The offset the statement is computed in is a +`StatementOptions` field the application fills with `localOffsetMinutes()` (Task L9), as `ReportView.qml` took it +from the JavaScript `Date`. + +**Files:** +- Create: `examples/ledger/app/controllers/statement_controller.hpp`, + `examples/ledger/app/controllers/statement_controller.cpp` +- Test: `examples/ledger/tests/client/test_statement_controller.cpp` + +**Interfaces:** +- Consumes: `BootController::ledger()`, `statementLines`, `parseAtLeast`, `StatementLine` (Task L2); + `reactive::Query(Runtime&, Fetch, Key, QueryOptions{scheduler, refreshEvery})` (Part 1, Task 9); + `ledger::SubmitReport`, `GetReportStatus`, `GetReportStatusResult`, `MonthlyStatementParams`, `ReportStatus`; + `ledger::app::App` and `runPendingReportsOnce()`, `reportsInFlight()`, `stopBackgroundJobs()` (Task L1, in tests). +- Produces: `ledger::client::StatementStatus{Idle, Pending, Done, Failed}`, `StatementOptions{timezoneOffsetMinutes, + pollEvery}`; `StatementController(examples::Wiring, BootController const&, StatementOptions)`: `year()`, `setYear`, `month()`, + `setMonth(int)`, `canRequest()`, `request()`, `status()`, `statusText()`, `pending()`, `lines()`, `offsetText()`, + `errorText()`, `pollCount()`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/ledger/tests/client/test_statement_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The statement end to end: the client submits, the server-side report runner (here driven by hand) runs the job, +// and the client's poll — a Query refreshed on a manual clock — picks the result up and then stops. + +#include +#include +#include +#include +#include +#include + +#include "controllers/boot_controller.hpp" +#include "controllers/statement_controller.hpp" +#include "ledger/app/app.hpp" +#include "ledger/models/ledger_model.hpp" +#include "ledger_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using ledger::client::BootController; +using ledger::client::BootOptions; +using ledger::client::StatementController; +using ledger::client::StatementOptions; +using ledger::client::StatementStatus; +using morph::ladder::testkit::DbFixture; +using namespace std::chrono_literals; + +namespace { + +// One transfer late on the 31st of January in UTC-5 — the 1st of February in UTC. +void seedLateJanuaryTransfer(ledger::LedgerId book) { + auto const checking = ledger::testing::openAccountDirect(book, "Checking", ledger::AccountKind::Asset); + auto const groceries = ledger::testing::openAccountDirect(book, "Groceries", ledger::AccountKind::Expense); + morph::session::Context context; + context.principal = "alice"; + morph::session::detail::ScopedContext const scope{context}; + auto const instant = morph::time::DateTime::fromIso8601("2026-02-01T04:30:00Z"); + REQUIRE(instant.has_value()); + using morph::math::DecimalPlaces; + using morph::math::Denominator; + using morph::math::Numerator; + ledger::LedgerModel model; + model.execute(ledger::StoreTransaction{ + .ledgerId = book, + .description = "late on the 31st, local", + .date = morph::time::Timestamp{*instant}, + .legs = {ledger::TransactionLeg{.accountId = checking, + .amount = morph::math::Rational{Numerator{-5000}, Denominator{1}, + DecimalPlaces{2}}}, + ledger::TransactionLeg{.accountId = groceries, + .amount = morph::math::Rational{Numerator{5000}, Denominator{1}, + DecimalPlaces{2}}}}}); +} + +void runJobs(ledger::testing::QtClient& client, ledger::app::App& runner) { + runner.runPendingReportsOnce(); + REQUIRE(client.settle([&runner] { return !runner.reportsInFlight(); })); +} + +} // namespace + +TEST_CASE("ledger::client::StatementController: a statement is polled to Done; a Done job stops the poll and keeps " + "its lines", + "[ledger][client][report]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + seedLateJanuaryTransfer(book); + ledger::app::App runner{std::string{ledger::testing::kSecret}, std::chrono::hours{1}}; + ledger::testing::QtClient client{"alice"}; + BootController boot{client.wiring(), BootOptions{.principal = "alice", .remote = false, .seed = false, .ledger = *book}}; + StatementController statement{client.wiring(), boot, + StatementOptions{.timezoneOffsetMinutes = -300, .pollEvery = 2000ms}}; + CHECK(statement.status() == StatementStatus::Idle); + CHECK(statement.offsetText() == "Offset applied: -300 minutes from UTC"); + + statement.setYear("2026"); + statement.setMonth(1); + REQUIRE(statement.canRequest()); + statement.request(); + CHECK(statement.status() == StatementStatus::Pending); + REQUIRE(client.settle([&statement] { return statement.pollCount() >= 1; })); + runJobs(client, runner); + client.scheduler.advance(2000ms); + + REQUIRE(client.settle([&statement] { return statement.status() == StatementStatus::Done; })); + REQUIRE(statement.lines().size() == 1); + CHECK(statement.lines().front().currency == "USD"); + CHECK(statement.lines().front().countText == "1 transactions"); + CHECK(statement.lines().front().amountText == "0"); + CHECK(statement.errorText().empty()); + + std::size_t const polls = statement.pollCount(); + client.scheduler.advance(10000ms); + CHECK(statement.pollCount() == polls); + CHECK(statement.status() == StatementStatus::Done); + CHECK(statement.lines().size() == 1); + + statement.setMonth(3); + statement.request(); + REQUIRE(client.settle([&statement] { return statement.pollCount() > polls; })); + runJobs(client, runner); + client.scheduler.advance(2000ms); + REQUIRE(client.settle([&statement] { return statement.status() == StatementStatus::Done; })); + REQUIRE_FALSE(statement.lines().empty()); + CHECK(statement.lines().front().countText == "0 transactions"); + + runner.stopBackgroundJobs(); + REQUIRE(client.settle([&runner] { return !runner.reportsInFlight(); })); +} + +TEST_CASE("ledger::client::StatementController: a refused submission fails at once and polls nothing", + "[ledger][client][report]") { + DbFixture const fixture; + ledger::testing::LocalClient client{"alice"}; + BootController boot{client.wiring(), BootOptions{.principal = "alice", .remote = false, .seed = false, .ledger = 999999}}; + StatementController statement{client.wiring(), boot, StatementOptions{.timezoneOffsetMinutes = 0, .pollEvery = 2000ms}}; + statement.setYear("2026"); + statement.setMonth(1); + + statement.request(); + + REQUIRE(client.settle([&statement] { return statement.status() == StatementStatus::Failed; })); + CHECK_FALSE(statement.errorText().empty()); + CHECK(statement.pollCount() == 0); + client.scheduler.advance(10000ms); + CHECK(statement.pollCount() == 0); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_ledger_tests` +Expected: compile error, `'controllers/statement_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/ledger/app/controllers/statement_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/boot_controller.hpp" +#include "ledger/models/ledger_model.hpp" +#include "support/ledger_format.hpp" + +namespace ledger::client { + +/// @brief Where the statement is. +enum class StatementStatus : std::uint8_t { Idle, Pending, Done, Failed }; + +/// @brief How statements are computed and polled. +struct StatementOptions { + /// @brief The display zone's offset from UTC; month boundaries are drawn in it. + int timezoneOffsetMinutes = 0; + /// @brief How often a pending job is polled. + std::chrono::milliseconds pollEvery{2000}; +}; + +/// @brief The statement tab: request a monthly statement, poll its job until it settles, show its lines. +class StatementController { +public: + /// @param wiring The runtime, scheduler, bridge and owner this controller uses. + /// @param boot Names the book. Borrowed: it must outlive this controller. + /// @param options The display zone and the poll period. + StatementController(morph::examples::Wiring wiring, BootController const& boot, StatementOptions options); + ~StatementController(); + StatementController(StatementController const&) = delete; + StatementController& operator=(StatementController const&) = delete; + StatementController(StatementController&&) = delete; + StatementController& operator=(StatementController&&) = delete; + + /// @brief Tracked. @return The year input. + [[nodiscard]] std::string const& year() const { return _year.get(); } + /// @brief Sets the year input. @param text The year. + void setYear(std::string text) { _year.set(std::move(text)); } + /// @brief Tracked. @return The month, 1–12. + [[nodiscard]] int month() const { return _month.get(); } + /// @brief Sets the month. @param value 1–12. + void setMonth(int value) { _month.set(value); } + /// @brief Tracked. @return Whether "Request" can run: a book, a year 1970–2999, a month 1–12, nothing pending. + [[nodiscard]] bool canRequest() const; + /// @brief Submits the monthly statement for the inputs. + void request(); + /// @brief Tracked. @return Where the statement is. + [[nodiscard]] StatementStatus status() const; + /// @brief Tracked. @return `"idle"`, `"pending"`, `"done"` or `"failed"`. + [[nodiscard]] std::string statusText() const; + /// @brief Tracked. @return True while submitting or polling. + [[nodiscard]] bool pending() const { return status() == StatementStatus::Pending; } + /// @brief Tracked. @return The finished statement's lines; empty otherwise. + [[nodiscard]] std::vector lines() const; + /// @brief The offset line. @return `"Offset applied: minutes from UTC"`. + [[nodiscard]] std::string offsetText() const; + /// @brief Tracked. @return Why the last statement failed, or empty. + [[nodiscard]] std::string errorText() const; + /// @brief How many status polls were issued; for tests. + /// @return The count. + [[nodiscard]] std::size_t pollCount() const noexcept { return _polls; } + +private: + struct Outcome { + bool failed = false; + std::string message; + std::vector lines; + bool operator==(Outcome const&) const = default; + }; + + void settle(); + void finish(Outcome outcome); + + morph::examples::Wiring _wiring; + BootController const* _boot; + StatementOptions _options; + morph::bridge::BridgeHandler _handler; + morph::reactive::Signal _year; + morph::reactive::Signal _month; + morph::reactive::Signal> _job; + morph::reactive::Signal> _outcome; + std::size_t _polls = 0; + morph::reactive::Mutation _submit; + morph::reactive::Query _status; + std::unique_ptr _track; + std::unique_ptr _settle; +}; + +} // namespace ledger::client +``` + +Create `examples/ledger/app/controllers/statement_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/statement_controller.hpp" + +#include +#include +#include + +#include "ledger/dto/report_dto.hpp" + +namespace ledger::client { + +namespace { + +constexpr std::int64_t kFirstYear = 1970; +constexpr std::int64_t kLastYear = 2999; + +} // namespace + +StatementController::StatementController(morph::examples::Wiring wiring, BootController const& boot, + StatementOptions options) + : _wiring{wiring}, + _boot{&boot}, + _options{options}, + _handler{wiring.bridge, &wiring.callbacks}, + _year{wiring.runtime, std::string{"2026"}}, + _month{wiring.runtime, 1}, + _job{wiring.runtime, std::nullopt}, + _outcome{wiring.runtime, std::nullopt}, + _submit{wiring.runtime, _handler}, + _status{wiring.runtime, + [this](GetReportStatus const& action) { + ++_polls; + return _handler.execute(action); + }, + [this]() -> std::optional { + auto const& job = _job.get(); + if (!job) { + return std::nullopt; + } + return GetReportStatus{.jobId = *job}; + }, + morph::reactive::QueryOptions{.scheduler = &wiring.scheduler, .refreshEvery = options.pollEvery}} { + _track = std::make_unique(_wiring.runtime, [this] { + auto const& submitted = _submit.lastResult(); + if (submitted) { + ReportJobId const job = *submitted; + _wiring.runtime.untracked([&] { _job.set(job); }); + } + }); + _settle = std::make_unique(_wiring.runtime, [this] { settle(); }); +} + +StatementController::~StatementController() = default; + +bool StatementController::canRequest() const { + auto const yearValue = parseAtLeast(_year.get(), kFirstYear); + int const monthValue = _month.get(); + return _boot->ledger().has_value() && yearValue.has_value() && *yearValue <= kLastYear && monthValue >= 1 && + monthValue <= 12 && !pending(); +} + +void StatementController::request() { + auto const& book = _boot->ledger(); + auto const yearValue = parseAtLeast(_year.peek(), kFirstYear); + if (!book || !yearValue || pending()) { + return; + } + MonthlyStatementParams const params{.year = static_cast(*yearValue), + .month = static_cast(_month.peek()), + .timezoneOffsetMinutes = _options.timezoneOffsetMinutes}; + _outcome.set(std::nullopt); + _submit.run(SubmitReport{.ledgerId = *book, + .kind = ReportKind::MonthlyStatement, + .params = glz::write_json(params).value_or(std::string{"{}"})}); +} + +StatementStatus StatementController::status() const { + if (_submit.pending() || _job.get().has_value()) { + return StatementStatus::Pending; + } + if (auto const& outcome = _outcome.get()) { + return outcome->failed ? StatementStatus::Failed : StatementStatus::Done; + } + return _submit.error() != nullptr ? StatementStatus::Failed : StatementStatus::Idle; +} + +std::string StatementController::statusText() const { + switch (status()) { + case StatementStatus::Idle: + return "idle"; + case StatementStatus::Pending: + return "pending"; + case StatementStatus::Done: + return "done"; + case StatementStatus::Failed: + return "failed"; + default: + return "idle"; + } +} + +std::vector StatementController::lines() const { + auto const& outcome = _outcome.get(); + return outcome && !outcome->failed ? outcome->lines : std::vector{}; +} + +std::string StatementController::offsetText() const { + return "Offset applied: " + std::to_string(_options.timezoneOffsetMinutes) + " minutes from UTC"; +} + +std::string StatementController::errorText() const { + if (auto const& outcome = _outcome.get(); outcome && outcome->failed) { + return outcome->message; + } + if (std::exception_ptr const error = _submit.error(); error != nullptr) { + return morph::reactive::errorMessage(error); + } + return {}; +} + +void StatementController::settle() { + auto const& reply = _status.value(); + std::exception_ptr const failure = _status.error(); + _wiring.runtime.untracked([&] { + if (!_job.peek()) { + return; + } + if (failure != nullptr) { + finish(Outcome{.failed = true, .message = morph::reactive::errorMessage(failure), .lines = {}}); + return; + } + if (!reply || reply->status == ReportStatus::Pending) { + return; + } + if (reply->status == ReportStatus::Failed) { + finish(Outcome{.failed = true, .message = "report job failed", .lines = {}}); + return; + } + auto decoded = statementLines(reply->result.value_or(std::string{})); + if (!decoded) { + finish(Outcome{.failed = true, .message = "could not decode report body", .lines = {}}); + return; + } + finish(Outcome{.failed = false, .message = {}, .lines = std::move(*decoded)}); + }); +} + +void StatementController::finish(Outcome outcome) { + // Clearing the job makes the status query idle, which is what stops the timed refresh from polling a job that + // has settled. + _wiring.runtime.batch([&] { + _outcome.set(std::move(outcome)); + _job.set(std::nullopt); + }); +} + +} // namespace ledger::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_ledger_tests +./build/all/examples/ledger/ladder_ledger_tests "[ledger][client][report]" +``` + +Expected: PASS, 2 cases. + +Mutation check: in `finish`, delete `_job.set(std::nullopt);`. Expected FAIL in "a statement is polled to Done…" +(`status()` stays Pending, or — once Done is reached through the outcome — `pollCount()` grows after the 10 s +advance). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ledger/app/controllers/statement_controller.hpp \ + examples/ledger/app/controllers/statement_controller.cpp \ + examples/ledger/tests/client/test_statement_controller.cpp +git commit -m "wip(ledger): StatementController: the statement job polled by a Query with refreshEvery + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task L7: The multi-client stress case drives `AccountsController`s + +Re-expresses `tests/test_multiclient.cpp` (spec 4 §7 rule 6: stress harnesses are unchanged but drive controllers): +N accounts controllers on one bridge store a seeded, weighted script of transfers at one book; every burst checks +the zero-sum invariant; the run checks convergence and that no controller reported an error. + +**Files:** +- Modify (rewrite): `examples/ledger/tests/test_multiclient.cpp` +- Test: the same file + +**Interfaces:** +- Consumes: `AccountsController::storeTransfer`, `busy`, `errorText` (Task L3); `BootController` (Task L2); + `ledger::testing::LocalClient`, `seedLedger`, `openAccountDirect` (Task L2); `testkit/action_driver.hpp` + (`SeededScript`), `testkit/convergence.hpp` (`pollUntilConverged`). +- Produces: nothing new. + +- [ ] **Step 1: Write the failing test** + +Replace the whole of `examples/ledger/tests/test_multiclient.cpp` with: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// Rung 5's multi-client stress case (examples/TESTING.md, "Multi-client stress harness"). N accounts controllers +// on one bridge drive a seeded, weighted script of transfers at one book, and the run asserts the two invariants +// that must survive concurrency: +// +// * every currency's legs still sum to exactly zero, and +// * every client converges on the same ledger state. +// +// Scale comes from MORPH_LADDER_CLIENTS / MORPH_LADDER_ACTIONS and the seed from MORPH_STRESS_SEED (SeededScript +// prints it on failure), so a CI failure is reproducible by re-running with the same seed. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "client/ledger_client_support.hpp" +#include "controllers/accounts_controller.hpp" +#include "controllers/boot_controller.hpp" +#include "testkit/action_driver.hpp" +#include "testkit/convergence.hpp" +#include "testkit/db_fixture.hpp" + +namespace { + +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::pollUntilConverged; +using morph::ladder::testkit::SeededScript; + +[[nodiscard]] int envCount(char const* name, int fallback) { + // NOLINTNEXTLINE(concurrency-mt-unsafe): read on the test thread before any client starts work. + char const* raw = std::getenv(name); + if (raw == nullptr || *raw == '\0') { + return fallback; + } + int const parsed = std::atoi(raw); + return parsed > 0 ? parsed : fallback; +} + +struct Move { + std::int64_t amountMinor; + bool reversed; +}; + +[[nodiscard]] ledger::GetLedgerResult readBook(ledger::LedgerId book) { + morph::session::Context context; + context.principal = "alice"; + morph::session::detail::ScopedContext const scope{context}; + ledger::LedgerModel model; + return model.execute(ledger::GetLedger{.ledgerId = book}); +} + +} // namespace + +TEST_CASE("N clients storing concurrent transactions converge, legs always sum zero", "[ledger][stress]") { + auto const nClients = static_cast(envCount("MORPH_LADDER_CLIENTS", 4)); + int const nActions = envCount("MORPH_LADDER_ACTIONS", 40); + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Stress"); + auto const checking = ledger::testing::openAccountDirect(book, "Checking", ledger::AccountKind::Asset); + auto const groceries = ledger::testing::openAccountDirect(book, "Groceries", ledger::AccountKind::Expense); + ledger::testing::LocalClient client{"alice"}; + ledger::client::BootController boot{ + client.wiring(), + ledger::client::BootOptions{.principal = "alice", .remote = false, .seed = false, .ledger = *book}}; + std::vector> clients; + for (std::size_t i = 0; i < nClients; ++i) { + clients.push_back(std::make_unique(client.wiring(), boot)); + } + + SeededScript script{/*defaultSeed=*/20260819, + /*generators=*/ + {{3, [] { return Move{.amountMinor = 500, .reversed = false}; }}, + {1, [] { return Move{.amountMinor = 250, .reversed = true}; }}}, + /*burstSize=*/10, + /*onBurst=*/ + [&](std::vector const&) { + auto const state = readBook(book); + morph::math::Rational total = morph::math::Rational::zero(morph::math::DecimalPlaces{2}); + for (auto const& account : state.accounts) { + total = total + account.balance; + } + CHECK(total.numerator == 0); + }}; + for (int i = 0; i < nActions; ++i) { + Move const move = script.next(); + auto& controller = *clients.at(static_cast(i) % clients.size()); + controller.storeTransfer(ledger::client::Transfer{.from = move.reversed ? groceries : checking, + .to = move.reversed ? checking : groceries, + .amountMinor = move.amountMinor, + .description = "stress"}); + } + script.flushBurst(); + + REQUIRE(client.settle( + [&clients] { return std::ranges::none_of(clients, [](auto const& controller) { return controller->busy(); }); }, + std::chrono::seconds{30})); + for (auto const& controller : clients) { + CHECK(controller->errorText().empty()); + } + + auto const fingerprints = [&] { + std::vector out; + for (std::size_t i = 0; i < clients.size(); ++i) { + std::string fingerprint; + for (auto const& account : readBook(book).accounts) { + fingerprint += std::to_string(account.balance.numerator) + "/" + + std::to_string(account.balance.denominator) + "@" + + std::to_string(account.balance.decimalPlaces.value) + ";"; + } + out.push_back(std::move(fingerprint)); + } + return out; + }; + CHECK(pollUntilConverged(fingerprints, /*maxAttempts=*/20)); + auto const state = readBook(book); + REQUIRE(state.accounts.size() == 2); + INFO("checking=" << state.accounts.at(0).balance.numerator + << " groceries=" << state.accounts.at(1).balance.numerator); + CHECK(state.accounts.at(0).balance.numerator != 0); + CHECK(state.accounts.at(0).balance.numerator == -state.accounts.at(1).balance.numerator); + INFO("MORPH_STRESS_SEED=" << script.seed()); + INFO("clients=" << clients.size() << " actions=" << nActions); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run the mutation first (Task L3 is in place): in `AccountsController::storeTransfer`, give the credit leg +`cents(transfer.amountMinor + 1)`. Then + +```bash +cmake --build build/all --target ladder_ledger_tests +./build/all/examples/ledger/ladder_ledger_tests "[ledger][stress]" +``` + +Expected: FAIL — every store is refused by the model's zero-sum check, so `errorText()` is non-empty and the +balances stay zero. Restore `cents(transfer.amountMinor)`. + +- [ ] **Step 3: Implement** + +No production change. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +./build/all/examples/ledger/ladder_ledger_tests "[ledger][stress]" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ledger/tests/test_multiclient.cpp +git commit -m "wip(ledger): the multi-client stress case drives AccountsControllers + +Signed-off-by: Christian Parpart " +``` + +--- +### Task L8: Ledger's `AppController` and views — four tabs + +`Main.qml`'s `TabBar` + `StackLayout` becomes a `ui::Tabs` behind a `Switch` on the boot state (signing in → a +`Busy`; failed → the reason; ready → the tabs). Each tab is bindings over its controller; the hand-built inputs of +the QML views stay hand-built `TextInput`s and `Select`s (ledger never used the forms renderer), and the accounts +list becomes a `Table`. + +**Files:** +- Create: `examples/ledger/app/app_controller.hpp`, `examples/ledger/app/app_controller.cpp` +- Create: `examples/ledger/app/views/ledger_views.hpp`, `examples/ledger/app/views/ledger_views.cpp` +- Test: `examples/ledger/tests/client/test_ledger_views.cpp` + +**Interfaces:** +- Consumes: Tasks L2–L6; Part 2's `ui::tabs`, `Tab`, `table`, `TableColumn`, `textInput`, `select`, `busy`, + `grid`, `switchOn`; `RecordingBackend::find`, `prop`, `edit`, `click`, `dump` (a `Tabs` page is mounted when + its tab is first selected). +- Produces: `ledger::client::AppOptions{boot, statement}`, `AppController(examples::Wiring, AppOptions)` with `boot()`, + `accounts()`, `budget()`, `rules()`, `statement()`, `tab()`, `selectTab(std::size_t)`; views `bootView`, + `accountsTab(AccountsController&)`, `budgetsTab(BudgetController&)`, `rulesTab(RulesController&)`, + `statementTab(StatementController&)`, `rootView(AppController&)`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/ledger/tests/client/test_ledger_views.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// One RecordingBackend test per screen: the tree shows what the screen must, and its controls drive the controller. + +#include +#include +#include +#include + +#include "app_controller.hpp" +#include "ledger_client_support.hpp" +#include "testkit/db_fixture.hpp" +#include "views/ledger_views.hpp" + +namespace { + +using ledger::client::AppController; +using ledger::client::AppOptions; +using ledger::client::BootOptions; +using ledger::testing::LocalClient; +using morph::ladder::testkit::DbFixture; +using morph::ui::testing::RecordingBackend; + +[[nodiscard]] bool shows(RecordingBackend const& backend, std::string const& text) { + return backend.dump().find(text) != std::string::npos; +} + +[[nodiscard]] int byLabel(RecordingBackend const& backend, std::string const& label) { + auto const found = backend.find("Button", "label", label); + REQUIRE(found.has_value()); + return *found; +} + +[[nodiscard]] int byPlaceholder(RecordingBackend const& backend, std::string const& placeholder) { + auto const found = backend.find("TextInput", "placeholder", placeholder); + REQUIRE(found.has_value()); + return *found; +} + +// Clicks the button labelled @p label once it is enabled: a button's `enabled` follows its controller's `can…()` +// one flush after an input changes, and the backend ignores a click on a disabled widget. +void clickWhenEnabled(LocalClient& client, RecordingBackend& backend, std::string const& label) { + int const target = byLabel(backend, label); + REQUIRE(client.settle([&backend, target] { return backend.prop(target, "enabled") != "false"; })); + backend.click(target); +} + +[[nodiscard]] AppOptions optionsFor(ledger::LedgerId book, bool seed = false) { + return AppOptions{.boot = BootOptions{.principal = "alice", .remote = false, .seed = seed, .ledger = *book}, + .statement = {}}; +} + +} // namespace + +TEST_CASE("ledger view: the root shows boot progress, then the four tabs", "[ledger][view]") { + DbFixture const fixture; + LocalClient client; + AppController app{client.wiring(), optionsFor(ledger::LedgerId{1}, /*seed=*/true)}; + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, ledger::client::rootView(app)}; + REQUIRE(client.settle([&] { return shows(backend, "Refresh"); })); + // The four labels are the Tabs widget's own; only the selected (accounts) page is mounted until another is chosen. + CHECK(backend.find("Tabs", "tabs", "[Accounts,Budgets,Rules,Statement]").has_value()); + CHECK_FALSE(backend.find("Button", "label", "Create category").has_value()); +} + +TEST_CASE("ledger view: the accounts tab opens an account and stores a transfer", "[ledger][view]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + auto const checking = ledger::testing::openAccountDirect(book, "Checking", ledger::AccountKind::Asset); + LocalClient client{"alice"}; + AppController app{client.wiring(), optionsFor(book)}; + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, ledger::client::accountsTab(app.accounts())}; + REQUIRE(client.settle([&] { return shows(backend, "Checking"); })); + + backend.edit(byPlaceholder(backend, "New account name"), "Groceries"); + app.accounts().setNewKind(ledger::AccountKind::Expense); + clickWhenEnabled(client, backend, "Open account"); + REQUIRE(client.settle([&] { return app.accounts().accounts().size() == 2; })); + REQUIRE(client.settle([&] { return shows(backend, "Groceries"); })); + + app.accounts().setFrom(*checking); + app.accounts().setTo(app.accounts().accounts().back().id); + backend.edit(byPlaceholder(backend, "Amount (cents)"), "5000"); + clickWhenEnabled(client, backend, "Store"); + REQUIRE(client.settle([&] { return shows(backend, "-50"); })); +} + +TEST_CASE("ledger view: the budgets tab creates a category and shows its id", "[ledger][view]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + LocalClient client{"alice"}; + AppController app{client.wiring(), optionsFor(book)}; + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, ledger::client::budgetsTab(app.budget())}; + REQUIRE(client.settle([&] { return backend.find("Button", "label", "Create category").has_value(); })); + + backend.edit(byPlaceholder(backend, "Category name"), "Groceries"); + clickWhenEnabled(client, backend, "Create category"); + + REQUIRE(client.settle([&] { return shows(backend, "Category created (id "); })); +} + +TEST_CASE("ledger view: the rules tab creates a rule and shows it as the last rule", "[ledger][view]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + LocalClient client{"alice"}; + AppController app{client.wiring(), optionsFor(book)}; + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, ledger::client::rulesTab(app.rules())}; + REQUIRE(client.settle([&] { return backend.find("Button", "label", "Create rule").has_value(); })); + + backend.edit(byPlaceholder(backend, "Description contains…"), "COFFEE"); + clickWhenEnabled(client, backend, "Create rule"); + + REQUIRE(client.settle([&] { return shows(backend, "Rule created"); })); +} + +TEST_CASE("ledger view: the statement tab's Request submits a statement", "[ledger][view]") { + DbFixture const fixture; + auto const book = ledger::testing::seedLedger("Personal"); + LocalClient client{"alice"}; + AppController app{client.wiring(), optionsFor(book)}; + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, ledger::client::statementTab(app.statement())}; + REQUIRE(client.settle([&] { return shows(backend, "Monthly statement") && shows(backend, "idle"); })); + + clickWhenEnabled(client, backend, "Request"); + + REQUIRE(client.settle([&] { return app.statement().pollCount() >= 1; })); + CHECK(shows(backend, "pending")); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_ledger_tests` +Expected: compile error, `'app_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/ledger/app/app_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "app/wiring.hpp" +#include "controllers/accounts_controller.hpp" +#include "controllers/boot_controller.hpp" +#include "controllers/budget_controller.hpp" +#include "controllers/rules_controller.hpp" +#include "controllers/statement_controller.hpp" + +namespace ledger::client { + +/// @brief What the ledger app is configured with besides its wiring. +struct AppOptions { + /// @brief Who runs it, in which mode, on which book. + BootOptions boot; + /// @brief The statement's display zone and poll period. + StatementOptions statement; +}; + +/// @brief The ledger client: startup and the four tabs' controllers. +class AppController { +public: + /// @param wiring The runtime, scheduler, bridge and owner every controller uses. + /// @param options Boot and statement options. + AppController(morph::examples::Wiring wiring, AppOptions options); + + /// @brief The boot controller. @return It. + [[nodiscard]] BootController& boot() noexcept { return _boot; } + /// @brief The accounts tab's controller. @return It. + [[nodiscard]] AccountsController& accounts() noexcept { return _accounts; } + /// @brief The budgets tab's controller. @return It. + [[nodiscard]] BudgetController& budget() noexcept { return _budget; } + /// @brief The rules tab's controller. @return It. + [[nodiscard]] RulesController& rules() noexcept { return _rules; } + /// @brief The statement tab's controller. @return It. + [[nodiscard]] StatementController& statement() noexcept { return _statement; } + /// @brief The selected tab. Tracked. @return Its index. + [[nodiscard]] std::size_t tab() const { return _tab.get(); } + /// @brief Selects a tab. @param index Its index. + void selectTab(std::size_t index) { _tab.set(index); } + +private: + BootController _boot; + AccountsController _accounts; + BudgetController _budget; + RulesController _rules; + StatementController _statement; + morph::reactive::Signal _tab; +}; + +} // namespace ledger::client +``` + +Create `examples/ledger/app/app_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "app_controller.hpp" + +#include + +namespace ledger::client { + +AppController::AppController(morph::examples::Wiring wiring, AppOptions options) + : _boot{wiring, std::move(options.boot)}, + _accounts{wiring, _boot}, + _budget{wiring, _boot}, + _rules{wiring, _boot}, + _statement{wiring, _boot, options.statement}, + _tab{wiring.runtime, std::size_t{0}} {} + +} // namespace ledger::client +``` + +Create `examples/ledger/app/views/ledger_views.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "app_controller.hpp" + +namespace ledger::client { + +/// @brief The accounts tab. @param accounts Its controller. @return The tab's content. +[[nodiscard]] morph::ui::Node accountsTab(AccountsController& accounts); +/// @brief The budgets tab. @param budget Its controller. @return The tab's content. +[[nodiscard]] morph::ui::Node budgetsTab(BudgetController& budget); +/// @brief The rules tab. @param rules Its controller. @return The tab's content. +[[nodiscard]] morph::ui::Node rulesTab(RulesController& rules); +/// @brief The statement tab. @param statement Its controller. @return The tab's content. +[[nodiscard]] morph::ui::Node statementTab(StatementController& statement); +/// @brief The whole app: boot progress, its failure, or the four tabs. +/// @param app The app; it outlives the mounted view. +/// @return The root node. +[[nodiscard]] morph::ui::Node rootView(AppController& app); + +} // namespace ledger::client +``` + +Create `examples/ledger/app/views/ledger_views.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "views/ledger_views.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ledger::client { + +namespace { + +namespace ui = morph::ui; +using morph::reactive::Signal; + +template +ui::Node field(Controller& controller, Input which, std::string placeholder) { + return ui::textInput({.value = [&controller, which] { return controller.input(which); }, + .onChange = [&controller, which](std::string text) { controller.setInput(which, std::move(text)); }, + .placeholder = std::move(placeholder), + .common = {.layout = {.width = ui::Sizing::stretch()}}}); +} + +ui::Node stretchText(std::function text, ui::TextRole role = ui::TextRole::Normal) { + return ui::text({.text = std::move(text), .role = role, .common = {.layout = {.width = ui::Sizing::stretch()}}}); +} + +std::vector accountOptions(AccountsController const& accounts) { + std::vector options; + for (AccountRow const& row : accounts.accounts()) { + options.push_back(ui::SelectOption{.key = ui::Key{row.id}, .label = row.name}); + } + return options; +} + +std::optional keyOf(std::optional const& accountId) { + if (!accountId) { + return std::nullopt; + } + return ui::Key{*accountId}; +} + +} // namespace + +ui::Node accountsTab(AccountsController& accounts) { + std::vector kinds; + for (std::string const& name : kindNames()) { + kinds.push_back(ui::SelectOption{.key = ui::Key{name}, .label = name}); + } + return ui::column({ + .children = + { + ui::row({.children = {ui::text({.text = "Accounts", .role = ui::TextRole::Heading}), + ui::busy({.active = [&accounts] { return accounts.busy(); }, .label = ""}), + ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::button({.label = "Refresh", .onClick = [&accounts] { accounts.refresh(); }})}, + .gap = 1}), + ui::table( + {{.label = "Name", .width = ui::Sizing::stretch()}, + {.label = "Kind"}, + {.label = "Currency"}, + {.label = "Balance"}}, + [&accounts] { return accounts.accounts(); }, [](AccountRow const& row) { return ui::Key{row.id}; }, + [](Signal const& row) { + return std::vector{ + ui::text({.text = [&row] { return row.get().name; }}), + ui::text({.text = [&row] { return row.get().kind; }, .role = ui::TextRole::Muted}), + ui::text({.text = [&row] { return row.get().currency; }, .role = ui::TextRole::Muted}), + ui::text({.text = [&row] { return row.get().balanceText; }})}; + }), + ui::row({.children = {field(accounts, AccountsInput::NewName, "New account name"), + ui::select({.options = std::move(kinds), + .selected = [&accounts]() -> std::optional { + return ui::Key{kindText(accounts.newKind())}; + }, + .onSelect = + [&accounts](ui::Key const& key) { + if (auto const* const name = std::get_if(&key); + name != nullptr) { + accounts.setNewKind(kindFromText(*name)); + } + }}), + ui::button({.label = "Open account", + .onClick = [&accounts] { accounts.openAccount(); }, + .common = {.enabled = [&accounts] { return accounts.canOpenAccount(); }}})}, + .gap = 1}), + ui::row({.children = {ui::select({.options = [&accounts] { return accountOptions(accounts); }, + .selected = [&accounts] { return keyOf(accounts.from()); }, + .onSelect = + [&accounts](ui::Key const& key) { + if (auto const* const account = std::get_if(&key); + account != nullptr) { + accounts.setFrom(*account); + } + }}), + ui::select({.options = [&accounts] { return accountOptions(accounts); }, + .selected = [&accounts] { return keyOf(accounts.to()); }, + .onSelect = + [&accounts](ui::Key const& key) { + if (auto const* const account = std::get_if(&key); + account != nullptr) { + accounts.setTo(*account); + } + }}), + field(accounts, AccountsInput::Amount, "Amount (cents)"), + field(accounts, AccountsInput::Description, "Description"), + ui::button({.label = "Store", + .onClick = [&accounts] { accounts.store(); }, + .common = {.enabled = [&accounts] { return accounts.canStore(); }}})}, + .gap = 1}), + ui::row({.children = {ui::text({.text = "Entries", .role = ui::TextRole::Heading}), + field(accounts, AccountsInput::Month, "Month (YYYY-MM)"), + ui::button({.label = "List", + .onClick = [&accounts] { accounts.list(); }, + .common = {.enabled = [&accounts] { return accounts.canList(); }}})}, + .gap = 1}), + ui::forEach( + [&accounts] { return accounts.entries(); }, [](EntryRow const& entry) { return ui::Key{entry.id}; }, + [&accounts](Signal const& entry) { + return ui::row( + {.children = {ui::text({.text = [&entry] { return entry.get().idText; }, + .role = ui::TextRole::Muted}), + stretchText([&entry] { return entry.get().description; }), + ui::text({.text = [&entry] { return entry.get().dateText; }, + .role = ui::TextRole::Muted}), + ui::button({.label = "Undo", + .onClick = [&accounts, &entry] { accounts.undo(entry.peek().id); }})}, + .gap = 1}); + }), + ui::text({.text = [&accounts] { return accounts.errorText(); }, .role = ui::TextRole::Error}), + }, + .gap = 1, + }); +} + +ui::Node budgetsTab(BudgetController& budget) { + return ui::column({ + .children = + { + ui::row({.children = {ui::text({.text = "Budgets", .role = ui::TextRole::Heading}), + ui::busy({.active = [&budget] { return budget.busy(); }, .label = ""})}, + .gap = 1}), + ui::text({.text = [&budget] { return budget.statusText(); }, .role = ui::TextRole::Muted}), + ui::row({.children = {field(budget, BudgetInput::CategoryName, "Category name"), + ui::button({.label = "Create category", + .onClick = [&budget] { budget.createCategory(); }, + .common = {.enabled = [&budget] { return budget.canCreateCategory(); }}}), + ui::text({.text = [&budget] { return budget.lastCategoryText(); }, + .role = ui::TextRole::Muted})}, + .gap = 1}), + ui::row({.children = {field(budget, BudgetInput::LinkAccount, "Account id"), + field(budget, BudgetInput::LinkCategory, "Category id"), + ui::button({.label = "Link account to category", + .onClick = [&budget] { budget.linkAccount(); }, + .common = {.enabled = [&budget] { return budget.canLink(); }}})}, + .gap = 1}), + ui::row({.children = {field(budget, BudgetInput::BudgetName, "Budget name"), + field(budget, BudgetInput::BudgetCategory, "Budget category id"), + ui::button({.label = "Create budget", + .onClick = [&budget] { budget.createBudget(); }, + .common = {.enabled = [&budget] { return budget.canCreateBudget(); }}}), + ui::text({.text = [&budget] { return budget.lastBudgetText(); }, + .role = ui::TextRole::Muted})}, + .gap = 1}), + ui::row({.children = {field(budget, BudgetInput::LimitBudget, "Budget id"), + field(budget, BudgetInput::LimitMonth, "Month (YYYY-MM)"), + field(budget, BudgetInput::LimitMinor, "Limit (cents)"), + ui::button({.label = "Set limit", + .onClick = [&budget] { budget.setLimit(); }, + .common = {.enabled = [&budget] { return budget.canSetLimit(); }}}), + ui::button({.label = "Report", + .onClick = [&budget] { budget.report(); }, + .common = {.enabled = [&budget] { return budget.canReport(); }}})}, + .gap = 1}), + ui::grid({.columns = 2, + .cells = {{.node = ui::text({.text = "Limit"})}, + {.node = ui::text({.text = [&budget] { return budget.limitText(); }})}, + {.node = ui::text({.text = "Spent"})}, + {.node = ui::text({.text = [&budget] { return budget.spentText(); }})}, + {.node = ui::text({.text = "Currency"})}, + {.node = ui::text({.text = [&budget] { return budget.currencyText(); }})}}, + .gap = 1}), + ui::text({.text = [&budget] { return budget.errorText(); }, .role = ui::TextRole::Error}), + }, + .gap = 1, + }); +} + +ui::Node rulesTab(RulesController& rules) { + return ui::column({ + .children = + { + ui::row({.children = {ui::text({.text = "Categorisation rules", .role = ui::TextRole::Heading}), + ui::busy({.active = [&rules] { return rules.busy(); }, .label = ""})}, + .gap = 1}), + ui::text({.text = "Editing a rule bumps its version. Transactions already categorised keep the " + "version that categorised them — an edit never rewrites history.", + .role = ui::TextRole::Muted}), + ui::text({.text = [&rules] { return rules.statusText(); }, .role = ui::TextRole::Muted}), + ui::row({.children = {field(rules, RuleInput::MatchText, "Description contains…"), + field(rules, RuleInput::CategoryId, "Set category id"), + ui::button({.label = "Create rule", + .onClick = [&rules] { rules.create(); }, + .common = {.enabled = [&rules] { return rules.canCreate(); }}})}, + .gap = 1}), + ui::row({.children = {field(rules, RuleInput::EditRuleId, "Rule id"), + field(rules, RuleInput::EditMatch, "New match text"), + field(rules, RuleInput::EditCategory, "New category id"), + ui::button({.label = "Update rule", + .onClick = [&rules] { rules.update(); }, + .common = {.enabled = [&rules] { return rules.canUpdate(); }}})}, + .gap = 1}), + ui::panel({.title = "Last rule", + .padding = 1, + .child = ui::grid({.columns = 2, + .cells = {{.node = ui::text({.text = "id"})}, + {.node = ui::text({.text = [&rules] { return rules.lastRuleId(); }})}, + {.node = ui::text({.text = "match"})}, + {.node = ui::text({.text = [&rules] { return rules.lastRuleMatch(); }})}, + {.node = ui::text({.text = "version"})}, + {.node = ui::text({.text = [&rules] { return rules.lastRuleVersion(); }, + .role = ui::TextRole::Heading})}}, + .gap = 1})}), + ui::text({.text = [&rules] { return rules.errorText(); }, .role = ui::TextRole::Error}), + }, + .gap = 1, + }); +} + +ui::Node statementTab(StatementController& statement) { + std::vector months; + for (std::int64_t month = 1; month <= 12; ++month) { + months.push_back(ui::SelectOption{.key = ui::Key{month}, .label = std::to_string(month)}); + } + return ui::column({ + .children = + { + ui::text({.text = "Monthly statement", .role = ui::TextRole::Heading}), + ui::row({.children = {ui::textInput({.value = [&statement] { return statement.year(); }, + .onChange = [&statement](std::string text) { statement.setYear(std::move(text)); }, + .placeholder = "Year"}), + ui::select({.options = std::move(months), + .selected = [&statement]() -> std::optional { + return ui::Key{std::int64_t{statement.month()}}; + }, + .onSelect = + [&statement](ui::Key const& key) { + if (auto const* const month = std::get_if(&key); + month != nullptr) { + statement.setMonth(static_cast(*month)); + } + }}), + ui::button({.label = "Request", + .onClick = [&statement] { statement.request(); }, + .common = {.enabled = [&statement] { return statement.canRequest(); }}}), + ui::text({.text = [&statement] { return statement.statusText(); }, + .role = ui::TextRole::Heading}), + ui::busy({.active = [&statement] { return statement.pending(); }, .label = ""})}, + .gap = 1}), + ui::text({.text = statement.offsetText(), .role = ui::TextRole::Muted}), + ui::forEach( + [&statement] { return statement.lines(); }, + [](StatementLine const& line) { return ui::Key{line.currency}; }, + [](Signal const& line) { + return ui::row({.children = {stretchText([&line] { return line.get().currency; }), + ui::text({.text = [&line] { return line.get().countText; }, + .role = ui::TextRole::Muted}), + ui::text({.text = [&line] { return line.get().amountText; }})}, + .gap = 1}); + }), + ui::text({.text = [&statement] { return statement.errorText(); }, .role = ui::TextRole::Error}), + }, + .gap = 1, + }); +} + +ui::Node rootView(AppController& app) { + BootController& boot = app.boot(); + return ui::switchOn( + [&boot] { return boot.state(); }, + {{BootState::SigningIn, ui::busy({.active = true, .label = [&boot] { return boot.statusText(); }})}, + {BootState::Failed, ui::text({.text = [&boot] { return boot.statusText(); }, .role = ui::TextRole::Error})}, + {BootState::Ready, + ui::tabs({.tabs = {{.label = "Accounts", .node = accountsTab(app.accounts())}, + {.label = "Budgets", .node = budgetsTab(app.budget())}, + {.label = "Rules", .node = rulesTab(app.rules())}, + {.label = "Statement", .node = statementTab(app.statement())}}, + .selected = [&app] { return app.tab(); }, + .onSelect = [&app](std::size_t index) { app.selectTab(index); }})}}); +} + +} // namespace ledger::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_ledger_tests +./build/all/examples/ledger/ladder_ledger_tests "[ledger][view]" +git grep -n -E '#include " +``` + +--- +### Task L9: Ledger's application, binary and frontend smoke tests + +`ledger::client::makeApplication(ctx, env)` connects, runs as `--user` (default `demo`), and opens book 1 — the id +`Main.qml` hard-coded — or, with `--seed`, a new "Personal" book. The statement's display zone is this machine's +offset from UTC, read when the application is built (`localOffsetMinutes()`), as `ReportView.qml` took it from the +JavaScript `Date`. Neither is a command-line knob, so `ui/main.cpp` is exactly spec 4 §3's composition root. + +**Files:** +- Create: `examples/ledger/app/ledger_application.hpp`, `examples/ledger/app/ledger_application.cpp` +- Create: `examples/ledger/ui/main.cpp` +- Test: `examples/ledger/tests/smoke/test_ledger_frontends.cpp` + +**Interfaces:** +- Consumes: as Task K13 (Part 6's `AppEnvironment`, `connect`, `LocalSetup`, `Connection`, `Wiring`; Part 2's + frontend seam; Parts 3–4's `frontendOption`s; `runFrontendSmoke`); `AppController`, `AppOptions`, `BootOptions`, + `StatementOptions`, `rootView`, `localOffsetMinutes` (Tasks L2, L6, L8); `ledger::db::setup`; + `Bridge::setExecuteDeadline` (`morph/core/bridge.hpp:1020`). +- Produces: `ledger::client::makeApplication(ui::AppContext&, examples::AppEnvironment const&) -> + std::unique_ptr`; the `ledger` binary. + +- [ ] **Step 1: Write the failing test** + +Create `examples/ledger/tests/smoke/test_ledger_frontends.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The application mounts and quits on every frontend this build has (spec 4 §7 rule 5). This file is +// ladder_ledger_smoke_tests, a binary of its own: its main owns no Qt application object. + +#if MORPH_EXAMPLE_HAS_TUI || MORPH_EXAMPLE_HAS_QT_QUICK + +#include +#include +#include + +#include "app/app_environment.hpp" +#include "ledger_application.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/frontend_smoke.hpp" + +namespace { + +using morph::examples::testing::SmokeFrontend; + +void smoke(SmokeFrontend frontend) { + morph::ladder::testkit::DbFixture const fixture; + morph::examples::AppEnvironment env; + // NOLINTNEXTLINE(concurrency-mt-unsafe): read on the test thread before the app starts any thread. + env.db = morph::ladder::testkit::DbFixture::computeConnectionString(std::getenv("ODBC_CONNECTION_STRING")); + env.user = "alice"; + env.seed = true; + morph::examples::testing::runFrontendSmoke( + [&env](morph::ui::AppContext& ctx) { return ledger::client::makeApplication(ctx, env); }, frontend); +} + +} // namespace + +#if MORPH_EXAMPLE_HAS_TUI +TEST_CASE("ledger mounts and quits on the TUI", "[ledger][smoke]") { smoke(SmokeFrontend::Tui); } +#endif + +#if MORPH_EXAMPLE_HAS_QT_QUICK +TEST_CASE("ledger mounts and quits on Qt Quick", "[ledger][smoke]") { smoke(SmokeFrontend::QtQuick); } +#endif + +#endif +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_ledger_smoke_tests`. +Expected: compile error, `'ledger_application.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/ledger/app/ledger_application.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "app/app_environment.hpp" + +namespace ledger::client { + +/// @brief Builds the ledger application on a frontend's context: connects (local or `--server`), runs as `--user` +/// (default `demo`), and opens book 1 or, with `--seed`, a new "Personal" book. Statements draw their month +/// boundaries in this machine's offset from UTC. +/// @param ctx The frontend's context. Borrowed: it outlives the application. +/// @param env `--server`, `--db`, `--user`, `--seed`. +/// @return The application. +/// @throws morph::examples::TransportError when `--server` is given and this frontend has no transport for it. +[[nodiscard]] std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env); + +} // namespace ledger::client +``` + +Create `examples/ledger/app/ledger_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "ledger_application.hpp" + +#include +#include +#include +#include +#include +#include + +#include "app/transport.hpp" +#include "app/wiring.hpp" +#include "app_controller.hpp" +#include "ledger/db/database.hpp" +#include "support/ledger_format.hpp" +#include "views/ledger_views.hpp" + +namespace ledger::client { + +namespace { + +constexpr std::string_view kDefaultDatabase = "DRIVER=SQLite3;Database=ledger.db;Timeout=5000"; +constexpr std::string_view kDevPrincipal = "demo"; +// The book opened without --seed: the id the old client hard-coded. +constexpr std::int64_t kDefaultBook = 1; +// A call that never answers fails after this long, so a statement poll cannot leave the tab pending forever. +constexpr std::chrono::milliseconds kExecuteDeadline{5000}; +constexpr std::size_t kLocalWorkers = 4; + +[[nodiscard]] morph::examples::AppEnvironment withDatabase(morph::examples::AppEnvironment env) { + if (env.db.empty()) { + env.db = std::string{kDefaultDatabase}; + } + return env; +} + +[[nodiscard]] std::unique_ptr withDeadline( + std::unique_ptr connection) { + connection->bridge().setExecuteDeadline(kExecuteDeadline); + return connection; +} + +class LedgerApplication final : public morph::ui::Application { +public: + LedgerApplication(morph::ui::AppContext& ctx, morph::examples::AppEnvironment const& env) + : _connection{withDeadline(morph::examples::connect( + ctx, withDatabase(env), + morph::examples::LocalSetup{.setupDatabase = [](std::string const& database) { db::setup(database); }, + .workers = kLocalWorkers}))}, + _app{morph::examples::Wiring{.runtime = ctx.runtime(), + .scheduler = ctx.scheduler(), + .bridge = _connection->bridge(), + .callbacks = _connection->callbacks()}, + AppOptions{.boot = BootOptions{.principal = env.user.empty() ? std::string{kDevPrincipal} : env.user, + .remote = env.server.has_value(), + .seed = env.seed, + .ledger = kDefaultBook}, + .statement = StatementOptions{.timezoneOffsetMinutes = localOffsetMinutes(), + .pollEvery = std::chrono::milliseconds{2000}}}} {} + + [[nodiscard]] morph::ui::Node view() override { return rootView(_app); } + +private: + std::unique_ptr _connection; + AppController _app; +}; + +} // namespace + +std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env) { + return std::make_unique(ctx, env); +} + +} // namespace ledger::client +``` + +Create `examples/ledger/ui/main.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The ledger client: picks the Qt Quick or terminal frontend at runtime (--ui=qt|tui, MORPH_UI, or the first that +// can run here) and hands it the application. +// +// ledger [--ui=qt|tui] [--server ws://host:port] [--db ] [--user ] [--seed] + +#include +#include +#include +#include + +#include "app/app_environment.hpp" +#include "ledger_application.hpp" + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#endif +#if MORPH_EXAMPLE_HAS_TUI +#include +#endif + +int main(int argc, char** argv) { + try { + auto const env = morph::examples::AppEnvironment::fromArgs(argc, argv); + std::vector built; +#if MORPH_EXAMPLE_HAS_QT_QUICK + built.push_back(morph::qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_EXAMPLE_HAS_TUI + built.push_back(morph::tui::frontendOption()); +#endif + auto const frontend = morph::ui::selectFrontend(built, argc, argv); + return frontend->run([&env](morph::ui::AppContext& ctx) { return ledger::client::makeApplication(ctx, env); }); + } catch (std::exception const& error) { + std::cerr << "ledger: " << error.what() << '\n'; + return 1; + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_ledger_smoke_tests ledger +./build/all/examples/ledger/ladder_ledger_smoke_tests +``` + +Expected: both targets build; 2 smoke cases pass. + +Mutation check: make `makeApplication` begin with `throw std::runtime_error{"mutation"};`. Expected FAIL in both smoke +cases. Restore it. + +By hand, once: `./build/all/examples/ledger/ledger --ui=tui --seed --db "DRIVER=SQLite3;Database=/tmp/ledger-manual.db"` +— open two accounts, store a transfer, switch tabs, Ctrl+C; the same with `--ui=qt`. Report what you saw. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ledger/app/ledger_application.hpp examples/ledger/app/ledger_application.cpp examples/ledger/ui \ + examples/ledger/tests/smoke/test_ledger_frontends.cpp +git commit -m "wip(ledger): the application and one binary for both frontends + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task L10: Retire ledger's QML stack; the README's client section + +| Deleted test file | What it pinned | Where that claim lives now | +|---|---|---| +| `test_ledger_presenter.cpp` | GetLedger listed; OpenAccount carries the account; a refusal is relayed | `test_accounts_controller.cpp` ("a fresh book is empty…", "a model refusal surfaces on errorText") | +| `test_ledger_qml_bridge.cpp` | accounts as maps; balances exact, never float; refusal on `lastError` | `test_accounts_controller.cpp` ("a transfer carries balances exactly…"), `test_ledger_format.cpp` | +| `test_budget_presenter.cpp`, `test_budget_qml_bridge.cpp` | budget created; a month's limit and spend exact; refusal relayed | `test_budget_controller.cpp` | +| `test_rule_presenter.cpp` | create, update with the bumped version; refusal | `test_ledger_rules_controller.cpp` | +| `test_report_presenter.cpp` | submit → poll → done, exact lines; a second month | `test_statement_controller.cpp` ("a statement is polled to Done…") | +| `test_report_job_poller.cpp` | Done once then no more polls; Failed once; a dispatch error is terminal; a null error; a destroyed poller suppresses its reply | `test_statement_controller.cpp` (Done stops the poll; a refused submission fails and polls nothing); terminal-on-error and destroyed-in-flight are `reactive::Query`'s own contract (Part 1's query tests: an error is kept until the next success, a destroyed query gates replies), so they are not re-tested per app | +| `test_ledger_qml_surface.cpp` | bridges expose exactly what QML binds | by construction: views call controller members | +| `test_gui_qml_smoke.cpp` | each QML screen loads | `tests/smoke/test_ledger_frontends.cpp` and `test_ledger_views.cpp` (one per tab) | + +**Files:** +- Delete: `examples/ledger/gui/`, `examples/ledger/gui_lib/`, and in `examples/ledger/tests/`: + `test_ledger_presenter.cpp`, `test_ledger_qml_bridge.cpp`, `test_budget_presenter.cpp`, + `test_budget_qml_bridge.cpp`, `test_rule_presenter.cpp`, `test_report_presenter.cpp`, + `test_report_job_poller.cpp`, `test_ledger_qml_surface.cpp`, `test_gui_qml_smoke.cpp` +- Modify: `examples/ledger/CMakeLists.txt` (whole file, below), `examples/ledger/README.md`, `codecov.yml`, and the + comments listed in Step 3 +- Test: the whole ledger suite, plus the check below + +**Interfaces:** none new. + +- [ ] **Step 1: Write the failing check** + +```bash +test ! -e examples/ledger/gui && test ! -e examples/ledger/gui_lib && \ + { git grep -n -E 'LedgerQmlBridge|LedgerPresenter|ReportJobPoller|ledger/gui_lib|ledger/gui/|\.qml' -- \ + examples/ledger codecov.yml; test $? -eq 1; } && echo "ledger's QML stack is gone" +``` + +- [ ] **Step 2: Run it to verify it fails** + +Expected: no "gone" line (directories exist; `README.md` and `codecov.yml` match). + +- [ ] **Step 3: Implement** + +```bash +git rm -r -q examples/ledger/gui examples/ledger/gui_lib +git rm -q examples/ledger/tests/test_ledger_presenter.cpp examples/ledger/tests/test_ledger_qml_bridge.cpp \ + examples/ledger/tests/test_budget_presenter.cpp examples/ledger/tests/test_budget_qml_bridge.cpp \ + examples/ledger/tests/test_rule_presenter.cpp examples/ledger/tests/test_report_presenter.cpp \ + examples/ledger/tests/test_report_job_poller.cpp examples/ledger/tests/test_ledger_qml_surface.cpp \ + examples/ledger/tests/test_gui_qml_smoke.cpp +``` + +Replace the whole of `examples/ledger/CMakeLists.txt` with: + +```cmake +# SPDX-License-Identifier: Apache-2.0 +# +# ledger — rung 5 of the application ladder (examples/ledger/README.md). +# morph_add_rung() wires the standard targets: the domain library, the client app library (app/), the ledger +# binary (ui/main.cpp), the server with its report runner (src/server/app/, ladder_ledger_server_app), the tests +# and the frontend smoke tests (tests/smoke/). This file adds what is particular to this rung. + +cmake_minimum_required(VERSION 3.25) + +morph_add_rung(NAME ledger) + +# morph_add_rung() globs src/models, src/db and src/app into ladder_ledger_lib; Login's validation lives beside +# them in src/dto. +if(TARGET ladder_ledger_lib) + target_sources(ladder_ledger_lib PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/dto/auth_dto.cpp") +endif() + +``` + +`examples/ledger/README.md`: + +1. Insert this section immediately before `## morph subsystems exercised`: + +```markdown +## The client + +One binary, `ledger` (`ui/main.cpp`), runs on Qt Quick or in a terminal: `--ui=qt|tui`, else `MORPH_UI`, else the +first frontend that can run here. Locally it opens `--db` (default `ledger.db`) as `--user` (default `demo`); with +`--server ws://host:port` it signs in to `ladder_ledger_server` with `Login` as that user. It works on book 1; +`--seed` creates a "Personal" book for the user and opens that instead. + +`app/` is `ladder_ledger_app`, which links no toolkit: `BootController` (the session and the book), +`AccountsController`, `BudgetController`, `RulesController` and `StatementController` — the statement job is a +`Query` refreshed on the frontend's scheduler, which goes idle, and so stops polling, once the job settles — and one +view per tab. Statement jobs are run by the server's report runner (`src/server/app/app.cpp`), so a local +client's statement stays pending: run the server for statements. + +**What looks different from the old Qt Quick screens.** Behaviour is kept; the layout is each frontend's own. The +accounts list is a table; a transfer's two accounts are picked from the book's accounts instead of typed as ids; +the statement's year is a text field and its month a list, instead of spin boxes. +``` + +2. Replace "`LedgerView.qml`'s Undo control now takes its id from a listed row instead of from a text field." with + "The accounts tab's Undo control takes its id from a listed row." +3. Replace "each with model, presenter, QML bridge and tests. The desktop client wiring all four bridges is in + `gui/`." with "each with model, client controller and tests; the client is `app/` with `ui/main.cpp` (\"The + client\" below)." +4. Replace "`ladder_ledger_gui` mints a Local-mode session via `AppContext::login()` in Local mode, and dispatches + `Login` against the server in Remote mode, mirroring bookmarks'/kanban's own `gui/main.cpp`." with "The `ledger` + client installs its `--user` as the session locally and dispatches `Login` against a server + (`app/controllers/boot_controller.cpp`)." +5. Replace "The QML views bind the pre-rendered `balanceText` / `limitText` / `spentText` / `amountText` the + bridges publish." with "The views bind the balance, limit, spend and amount text the controllers render with it." +6. Replace "text the bridge pre-rendered through `ledger::formatMoney`" with "text the controllers render through + `ledger::formatMoney`", and "No QML file divides anything." with "No view divides anything." +7. Replace "is a presenter-layer conversion — a dual-mode GUI test." with "is a client-layer conversion, pinned by + `tests/client/test_statement_controller.cpp` (a late-January transfer in UTC-5)." + +`codecov.yml`: replace `"examples/ledger/gui/**"` and `"examples/ledger/gui_wasm/**"` with `"examples/ledger/ui/**"`. + +Comments that name the deleted client (each states what the code does now; replace exactly the quoted text): + +- `include/ledger/core/money.hpp`: "a QML `numerator / denominator / Math.pow(10, places)` drifts" → "a floating-point + `numerator / denominator / 10^places` drifts". +- `include/ledger/dto/transaction_dto.hpp`: "a WebSocket client and the QML bridge have no `DataMapper`, so the + shipped Undo control" → "a WebSocket client has no `DataMapper`, so the accounts tab's Undo control". +- `include/ledger/models/ledger_model.hpp`: in the `Q_MOC_RUN` comment, "emitting + `Lightweight::ledger::gui::LedgerPresenter` and failing to compile with \"no member named 'ledger' in namespace + 'Lightweight'\"." → "so a `Q_OBJECT` header that includes this one fails to compile its moc output (\"no member + named 'ledger' in namespace 'Lightweight'\")."; delete the paragraph that begins "Latent until now rather than new"; + replace the `AccountRecord` comment's first paragraph ("Forward-declared rather than included: … failing to + compile.") with "Forward-declared rather than included: `ledger_entity.hpp` pulls in + ``, which moc mis-parses (see above) in any `Q_OBJECT` header that reaches + this one." and its last sentence ("This also matches rung 4's shape -- … never hit this.") with "Rung 4's model + header has the same shape: core and dto headers only, never its own entity header."; in `ListTransactions`' + comment, "The read that makes `UndoTransaction` -- and the Undo control `gui/qml/LedgerView.qml` ships -- drivable + at all: before this, `JournalId` appeared in exactly one DTO field in the rung, and that field was + `UndoTransaction`'s own input, so a client could only ever pass an id it had guessed." → "The read that makes + `UndoTransaction` -- and the accounts tab's Undo control -- drivable: without it, `JournalId` appears in exactly + one DTO field in the rung, `UndoTransaction`'s own input, so a client could only pass an id it had guessed." +- `src/server/main.cpp`: "The desktop client (`examples/ledger/gui/`) talks to this" → "The `ledger` client + (`examples/ledger/ui/`, with `--server`) talks to this". +- `tests/test_ledger_list_transactions.cpp`: the paragraph "Before this action, … what a socket client and the QML + bridge do not have." → "Without this action, `JournalId` appears in exactly one DTO field in the whole rung, + `UndoTransaction`'s own *input*: `StoreTransaction` and `UndoTransaction` both answer with `GetLedgerResult` + (accounts and balances), `GetLedger` the same, `ImportLedgerChunk` with counts. So the reversal this rung ships -- + and the accounts tab's Undo control -- could only be handed a number someone guessed; a test can reach around that + through a `DataMapper`, which a socket client does not have." +- `tests/test_ledger_model_keys.cpp`: "the one the ladder's QML boundary has to be careful with elsewhere" → "the one + any JavaScript-number boundary has to be careful with". +- `tests/test_ledger_units.cpp`: "where the double division the QML views used to do drifts" → "where a double + division drifts". + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_ledger_tests ladder_ledger_server ledger +./build/all/examples/ledger/ladder_ledger_tests +``` + +Then the check from Step 1. Expected: every ledger case passes; the check prints "ledger's QML stack is gone". + +Mutation check: `git checkout HEAD -- examples/ledger/tests/test_gui_qml_smoke.cpp` and rebuild. Expected: the build +fails (`MORPH_LADDER_QML_URI` is no longer defined for this rung). Delete it again. + +- [ ] **Step 5: Commit** + +```bash +git add -A examples/ledger codecov.yml +git commit -m "wip(ledger): retire ledger's QML, bridges, presenters and job poller; README client section + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task L11: Ledger group verification and squash + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Strict build and the ladder suite** + +```bash +cmake --build build/all && ctest --test-dir build/all -L ladder --output-on-failure +``` + +Expected: every test passes. + +- [ ] **Step 2: No frontend** + +```bash +cmake --build build/nofront --target ladder_ledger_tests ladder_ledger_app +./build/nofront/examples/ledger/ladder_ledger_tests "[ledger]" +``` + +Expected: builds; every case passes; there is no `ledger` target. + +- [ ] **Step 3: Toolkit-free and UI-free checks** + +```bash +git grep -n -E '#include +``` + +The last line of the procedure must show `examples/ledger: one app, any frontend` directly after +`examples/kanban: one app, any frontend`. + +--- +# Group `lims` + +### Task M1: The lims app library — display strings + +Lims' forms are runtime forms over the schemas its DTOs already emit (spec 4 §5); like kanban's, each is a +`FormSession` over `morph::forms::FormModel::forAction()` whose submissions go through the handler that serves +them, with `morph::examples::mapCompletion` for the success hook and `morph::examples::Wiring` as every controller's +input — all shared (Parts 5 and 6). This task adds the rung's own display strings; `morph_add_rung` builds `app/` +into `ladder_lims_app` and links it into `ladder_lims_tests`. + +**Files:** +- Create: `examples/lims/app/support/lims_format.hpp`, `examples/lims/app/support/lims_format.cpp` +- Test: `examples/lims/tests/client/test_lims_format.cpp` + +**Interfaces:** +- Consumes: `morph::units::toDecimalString` (`morph/util/quantity.hpp`), `lims::Concentration` + (`lims/core/types.hpp:231`), `stateName`, `qualifierName`, `conflictReasonName`, `conflictStatusName` (the DTO + headers). +- Produces: `lims::client::ResultRow{id, idText, reading, outOfSpec, capturedBy}`, `ConflictRow{id, text}`, `AnalysisRow{versionId, + name}`; `valueText(Concentration const&)`, `unitText()`, `sampleHeader(std::optional const&)`, + `resultRow(ResultView const&)`, `conflictRow(ConflictView const&)`, `analysisRow(AnalysisVersionView const&)`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/lims/tests/client/test_lims_format.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "support/lims_format.hpp" + +namespace { + +[[nodiscard]] morph::math::Rational exact(std::int64_t num, std::int64_t den, std::uint32_t places) { + return morph::math::Rational{morph::math::Numerator{num}, morph::math::Denominator{den}, + morph::math::DecimalPlaces{places}}; +} + +} // namespace + +TEST_CASE("lims::client: quantity text is exact and empty-safe", "[lims][client]") { + CHECK(lims::client::valueText(lims::Concentration{exact(1, 400, 4)}) == "0.0025"); + CHECK(lims::client::valueText(lims::Concentration{}).empty()); + CHECK(lims::client::unitText() == "mg/L"); +} + +TEST_CASE("lims::client: a result row names its reading, or its qualifier when it has no number", "[lims][client]") { + lims::ResultView measured{.id = lims::ResultId{3}, + .sampleId = lims::SampleId{1}, + .analysisVersionId = lims::AnalysisVersionId{2}, + .qualifier = lims::ResultQualifier::Measured, + .value = lims::Concentration{exact(12, 5, 3)}, + .outOfSpec = true, + .capturedBy = "alice", + .capturedAt = {}}; + auto const row = lims::client::resultRow(measured); + CHECK(row.idText == "3"); + CHECK(row.reading == "2.4 mg/L"); + CHECK(row.outOfSpec == "out of spec"); + CHECK(row.capturedBy == "alice"); + + lims::ResultView qualified = measured; + qualified.value = lims::Concentration{}; + qualified.qualifier = lims::ResultQualifier::BelowLOD; + qualified.outOfSpec = false; + auto const noNumber = lims::client::resultRow(qualified); + CHECK(noNumber.reading == "belowLOD"); + CHECK(noNumber.outOfSpec.empty()); +} + +TEST_CASE("lims::client::sampleHeader names the sample, its state and version", "[lims][client]") { + CHECK(lims::client::sampleHeader(std::nullopt) == "no sample attached"); + lims::SampleView const sample{.id = lims::SampleId{7}, + .clientId = lims::ClientId{1}, + .reference = "WW-1", + .state = lims::SampleState::InProgress, + .version = lims::SampleVersion{3}, + .registeredAt = {}}; + CHECK(lims::client::sampleHeader(sample) == "sample 7 — in-progress (v3)"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_lims_tests` +Expected: compile error, `'support/lims_format.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/lims/app/support/lims_format.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include "lims/core/types.hpp" +#include "lims/dto/analysis_dto.hpp" +#include "lims/dto/offline_dto.hpp" +#include "lims/dto/result_dto.hpp" +#include "lims/dto/sample_dto.hpp" + +namespace lims::client { + +/// @brief One captured result. +struct ResultRow { + /// @brief The result's id. + std::int64_t id = 0; + /// @brief That id as text. + std::string idText; + /// @brief The reading with its unit, or the qualifier when there is no number. + std::string reading; + /// @brief `"out of spec"`, or empty. + std::string outOfSpec; + /// @brief Who captured it. + std::string capturedBy; + /// @brief Compared by value. + bool operator==(ResultRow const&) const = default; +}; + +/// @brief One replay conflict. +struct ConflictRow { + /// @brief The conflict's id. + std::int64_t id = 0; + /// @brief Its display line. + std::string text; + /// @brief Compared by value. + bool operator==(ConflictRow const&) const = default; +}; + +/// @brief One analysis version in the catalogue picker. +struct AnalysisRow { + /// @brief The analysis version's id. + std::int64_t versionId = 0; + /// @brief The analysis' name. + std::string name; + /// @brief Compared by value. + bool operator==(AnalysisRow const&) const = default; +}; + +/// @brief A concentration as exact decimal text. @param quantity The value. @return The digits, or empty. +[[nodiscard]] std::string valueText(Concentration const& quantity); +/// @brief The canonical concentration unit's display text. @return `"mg/L"`. +[[nodiscard]] std::string unitText(); +/// @brief The shell's header line. +/// @param sample The attached sample, if any. +/// @return `"sample — (v)"`, or `"no sample attached"`. +[[nodiscard]] std::string sampleHeader(std::optional const& sample); +/// @brief A result as a list row. @param view The result. @return The row. +[[nodiscard]] ResultRow resultRow(ResultView const& view); +/// @brief A conflict as a list row. @param view The conflict. @return The row. +[[nodiscard]] ConflictRow conflictRow(ConflictView const& view); +/// @brief An analysis version as a picker row. @param view The version. @return The row. +[[nodiscard]] AnalysisRow analysisRow(AnalysisVersionView const& view); + +} // namespace lims::client +``` + +Create `examples/lims/app/support/lims_format.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "support/lims_format.hpp" + +#include +#include + +namespace lims::client { + +std::string valueText(Concentration const& quantity) { + if (!quantity.hasValue()) { + return {}; + } + return morph::units::toDecimalString(quantity); +} + +std::string unitText() { return std::string{Concentration::unitMeta().display}; } + +std::string sampleHeader(std::optional const& sample) { + if (!sample || !sample->id.hasValue()) { + return "no sample attached"; + } + return "sample " + std::to_string(*sample->id) + " — " + std::string{stateName(sample->state)} + " (v" + + std::to_string(*sample->version) + ")"; +} + +ResultRow resultRow(ResultView const& view) { + std::int64_t const resultId = view.id.hasValue() ? *view.id : 0; + return ResultRow{.id = resultId, + .idText = std::to_string(resultId), + .reading = view.value.hasValue() ? valueText(view.value) + " " + unitText() + : std::string{qualifierName(view.qualifier)}, + .outOfSpec = view.outOfSpec ? std::string{"out of spec"} : std::string{}, + .capturedBy = view.capturedBy}; +} + +ConflictRow conflictRow(ConflictView const& view) { + std::int64_t const conflictId = view.id.hasValue() ? *view.id : 0; + return ConflictRow{.id = conflictId, + .text = "conflict " + std::to_string(conflictId) + " — " + + std::string{conflictReasonName(view.reason)} + " (base v" + + std::to_string(*view.baseVersion) + " vs server v" + + std::to_string(*view.serverVersion) + ") — " + + std::string{conflictStatusName(view.status)}}; +} + +AnalysisRow analysisRow(AnalysisVersionView const& view) { + return AnalysisRow{.versionId = view.id.hasValue() ? *view.id : 0, .name = view.name}; +} + +} // namespace lims::client +``` + +Re-run the configure (`cmake build/all`) so `morph_add_rung`'s globs see the new `app/` and `tests/client/` files. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_lims_tests +./build/all/examples/lims/ladder_lims_tests "[lims][client]" +``` + +Expected: PASS, 3 cases. + +Mutation check: in `resultRow`, drop the `+ " " + unitText()`. Expected FAIL in "a result row names its reading…". +Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/lims/app examples/lims/tests/client +git commit -m "wip(lims): app library foundations: display strings + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task M2: `SampleController` — the lifecycle tab + +Re-expresses `SamplePresenter`/`SampleBridge` and `SampleView.qml`: the registration, reject and rework forms are +runtime forms (RegisterClient through a plain handler — it carries no key, and an `AllowShared` handler is not bound +until it attaches; the others through the shared one); the attached sample is a `Query` keyed on the +sample asked for (a registration's reply, or "Open sample"); the four zero-field transitions are `Mutation`s that +invalidate it; and which button is enabled in which state is a controller projection, not a QML expression. + +**Files:** +- Create: `examples/lims/app/controllers/sample_controller.hpp`, `examples/lims/app/controllers/sample_controller.cpp` +- Create: `examples/lims/tests/client/lims_client_support.hpp` +- Test: `examples/lims/tests/client/test_sample_controller.cpp` + +**Interfaces:** +- Consumes: Task M1; `lims::SampleModel`, `RegisterClient`/`RegisterClientResult`, `RegisterSample`, `OpenSample`, + `ReceiveSample`, `StartWork`, `SubmitForVerification`, `PublishSample`, `RejectSample`, `ReturnForRework`, + `SampleView`, `stateName`. +- Produces: `SampleController(examples::Wiring)`: `registerClientForm()`, `registerSampleForm()`, `rejectForm()`, `reworkForm()`, + `sample()`, `sampleId()`, `headerText()`, `state()`, `canReceive()`, `canStartWork()`, `canSubmit()`, + `canPublish()`, `canRework()`, `canReject()`, `receive()`, `startWork()`, `submitForVerification()`, `publish()`, + `openSample(SampleId)`, `refresh()`, `openIdText()`, `setOpenIdText(std::string)`, `canOpen()`, `openTyped()`, + `clientText()`, `errorText()`, `busy()`, `registrar()`. Test support (`lims::testing`): `LocalClient`, `fill`, + `submitForm`, `submitFormExpectingRefusal`, `registerSample(client, samples)`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/lims/tests/client/lims_client_support.hpp`: + +```cpp +// 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 +#include + +#include "app/wiring.hpp" +#include "controllers/sample_controller.hpp" +#include "testkit/wait.hpp" + +namespace lims::testing { + +using namespace std::chrono_literals; + +/// A client over a local backend on a MainThreadExecutor, signed in as @p principal. +class LocalClient { +public: + explicit LocalClient(std::string principal) { + morph::session::Context session; + session.principal = std::move(principal); + bridge.setDefaultSession(std::move(session)); + } + ~LocalClient() = default; + LocalClient(LocalClient const&) = delete; + LocalClient& operator=(LocalClient const&) = delete; + LocalClient(LocalClient&&) = delete; + LocalClient& operator=(LocalClient&&) = delete; + + template + [[nodiscard]] bool settle(Pred done, std::chrono::milliseconds budget = 5000ms) { + return morph::examples::testing::pumpUntil(owner, std::move(done), budget); + } + [[nodiscard]] morph::examples::Wiring wiring() { + return morph::examples::Wiring{ + .runtime = runtime, .scheduler = scheduler, .bridge = bridge, .callbacks = owner}; + } + + morph::exec::ThreadPoolExecutor pool{4}; + morph::exec::MainThreadExecutor owner; + morph::bridge::Bridge bridge{std::make_unique(pool), owner}; + morph::reactive::Runtime runtime{owner}; + morph::reactive::testing::ManualScheduler scheduler; +}; + +/// Sets each member @p bodyJson names, as typing into those fields would, and leaves every other field alone — the +/// context its controller filled in (the registered client) stays. `prefill` would blank that context. +inline void fill(morph::forms::FormSession& form, std::string_view bodyJson) { + glz::generic_u64 members; + REQUIRE_FALSE(glz::read_json(members, std::string{bodyJson})); + REQUIRE(members.is_object()); + for (auto const& [name, value] : members.get_object()) { + form.assign(name, glz::write_json(value).value_or(std::string{"null"})); + } +} + +/// Fills @p form, submits it, waits for the outcome and requires success. +inline void submitForm(LocalClient& client, morph::forms::FormSession& form, std::string_view bodyJson) { + fill(form, bodyJson); + REQUIRE(form.ready()); + form.submit(); + REQUIRE(client.settle([&form] { return !form.pending(); })); + INFO("the form reported: " << morph::reactive::errorMessage(form.lastError())); + REQUIRE(form.lastError() == nullptr); +} + +/// Fills @p form, submits it, requires a refusal and returns its message. +[[nodiscard]] inline std::string submitFormExpectingRefusal(LocalClient& client, morph::forms::FormSession& form, + std::string_view bodyJson) { + fill(form, bodyJson); + REQUIRE(form.ready()); + form.submit(); + REQUIRE(client.settle([&form] { return !form.pending(); })); + REQUIRE(form.lastError() != nullptr); + return morph::reactive::errorMessage(form.lastError()); +} + +/// Registers a client and a sample "WW-1" through @p samples' own forms; the sample ends up attached. +inline void registerSample(LocalClient& client, lims::client::SampleController& samples) { + submitForm(client, samples.registerClientForm(), R"({"name":"Waterworks Ltd"})"); + REQUIRE(client.settle([&samples] { return samples.clientText() != "Latest client id: -1"; })); + submitForm(client, samples.registerSampleForm(), R"({"reference":"WW-1"})"); + REQUIRE(client.settle([&samples] { return samples.sample().has_value(); })); +} + +} // namespace lims::testing +``` + +Create `examples/lims/tests/client/test_sample_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include "controllers/sample_controller.hpp" +#include "lims_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using lims::SampleState; +using lims::client::SampleController; +using lims::testing::LocalClient; +using morph::ladder::testkit::DbFixture; + +TEST_CASE("lims::client::SampleController routes registration and every lifecycle transition", "[lims][client]") { + DbFixture const fixture; + LocalClient client{"alice"}; + SampleController samples{client.wiring()}; + CHECK(samples.headerText() == "no sample attached"); + CHECK(samples.clientText() == "Latest client id: -1"); + + lims::testing::registerSample(client, samples); + + CHECK(samples.sample()->state == SampleState::Registered); + CHECK(samples.headerText().starts_with("sample ")); + CHECK(samples.canReceive()); + CHECK(samples.canReject()); + CHECK_FALSE(samples.canPublish()); + + samples.receive(); + REQUIRE(client.settle([&samples] { return samples.state() == "received"; })); + CHECK(samples.canStartWork()); + samples.startWork(); + REQUIRE(client.settle([&samples] { return samples.state() == "in-progress"; })); + CHECK(samples.canSubmit()); + samples.submitForVerification(); + REQUIRE(client.settle([&samples] { return samples.state() == "to-be-verified"; })); + CHECK(samples.canPublish()); + CHECK(samples.canRework()); + CHECK_FALSE(samples.canReject()); + samples.publish(); + REQUIRE(client.settle([&samples] { return samples.state() == "published"; })); + CHECK_FALSE(samples.busy()); +} + +TEST_CASE("lims::client::SampleController surfaces a model refusal as a displayable message", "[lims][client]") { + DbFixture const fixture; + LocalClient client{"alice"}; + SampleController samples{client.wiring()}; + lims::testing::registerSample(client, samples); + + samples.publish(); + + REQUIRE(client.settle([&samples] { return !samples.errorText().empty(); })); + CHECK(samples.errorText().find("registered") != std::string::npos); + CHECK(samples.errorText().find("published") != std::string::npos); + CHECK_FALSE(samples.busy()); +} + +TEST_CASE("lims::client::SampleController reports busy while a transition is in flight", "[lims][client]") { + DbFixture const fixture; + LocalClient client{"alice"}; + SampleController samples{client.wiring()}; + lims::testing::registerSample(client, samples); + CHECK_FALSE(samples.busy()); + + samples.receive(); + + CHECK(samples.busy()); + REQUIRE(client.settle([&samples] { return !samples.busy(); })); +} + +TEST_CASE("lims::client::SampleController opens an existing sample by id and refuses a bad one", + "[lims][client]") { + DbFixture const fixture; + LocalClient client{"alice"}; + SampleController first{client.wiring()}; + lims::testing::registerSample(client, first); + std::string const sampleId = std::to_string(*first.sample()->id); + SampleController second{client.wiring()}; + + second.setOpenIdText("abc"); + CHECK_FALSE(second.canOpen()); + second.setOpenIdText(sampleId); + REQUIRE(second.canOpen()); + second.openTyped(); + + REQUIRE(client.settle([&second] { return second.sample().has_value(); })); + CHECK(std::to_string(*second.sample()->id) == sampleId); +} + +TEST_CASE("lims::client::SampleController: the reject form's refusal is the model's own message", "[lims][client]") { + DbFixture const fixture; + LocalClient client{"alice"}; + SampleController samples{client.wiring()}; + lims::testing::registerSample(client, samples); + samples.receive(); + REQUIRE(client.settle([&samples] { return samples.state() == "received"; })); + samples.startWork(); + REQUIRE(client.settle([&samples] { return samples.state() == "in-progress"; })); + + std::string const message = + lims::testing::submitFormExpectingRefusal(client, samples.rejectForm(), R"({"reason":"seal broken"})"); + + CHECK_FALSE(message.empty()); + CHECK(samples.state() == "in-progress"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_lims_tests` +Expected: compile error, `'controllers/sample_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/lims/app/controllers/sample_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "lims/models/sample_model.hpp" + +namespace lims::client { + +/// @brief The lifecycle tab: register a client and a sample, open one, move it through its lifecycle, return it +/// for rework or reject it. +class SampleController { +public: + /// @param wiring The runtime, bridge and owner this controller uses. + explicit SampleController(morph::examples::Wiring wiring); + + /// @brief The `RegisterClient` form. @return It. + [[nodiscard]] morph::forms::FormSession& registerClientForm() noexcept { return _registerClient; } + /// @brief The `RegisterSample` form; the last registered client is filled in. @return It. + [[nodiscard]] morph::forms::FormSession& registerSampleForm() noexcept { return _registerSample; } + /// @brief The `RejectSample` form. @return It. + [[nodiscard]] morph::forms::FormSession& rejectForm() noexcept { return _reject; } + /// @brief The `ReturnForRework` form. @return It. + [[nodiscard]] morph::forms::FormSession& reworkForm() noexcept { return _rework; } + /// @brief The attached sample. Tracked. @return Its state, kept while it refreshes; empty before one is attached. + [[nodiscard]] std::optional const& sample() const { return _open.value(); } + /// @brief The attached sample's id. Tracked, equality-gated. @return The id, or empty. + [[nodiscard]] std::optional sampleId() const { return _sampleId.get(); } + /// @brief Tracked. @return The shell's header line. + [[nodiscard]] std::string headerText() const; + /// @brief Tracked. @return The attached sample's state name, or empty. + [[nodiscard]] std::string state() const; + /// @brief Tracked. @return Whether "Receive" applies (registered). + [[nodiscard]] bool canReceive() const; + /// @brief Tracked. @return Whether "Start work" applies (received). + [[nodiscard]] bool canStartWork() const; + /// @brief Tracked. @return Whether "Submit for verification" applies (in progress). + [[nodiscard]] bool canSubmit() const; + /// @brief Tracked. @return Whether "Publish" applies (to be verified). + [[nodiscard]] bool canPublish() const; + /// @brief Tracked. @return Whether the rework form applies (to be verified). + [[nodiscard]] bool canRework() const; + /// @brief Tracked. @return Whether the reject form applies (registered or received). + [[nodiscard]] bool canReject() const; + /// @brief Receives the attached sample. + void receive(); + /// @brief Starts work on it. + void startWork(); + /// @brief Submits it for verification. + void submitForVerification(); + /// @brief Publishes it. + void publish(); + /// @brief Attaches to an existing sample. @param sample The sample. + void openSample(SampleId sample); + /// @brief Fetches the attached sample again. + void refresh(); + /// @brief Tracked. @return The "Open existing" id input. + [[nodiscard]] std::string const& openIdText() const { return _openIdText.get(); } + /// @brief Sets the "Open existing" id input. @param text The id as typed. + void setOpenIdText(std::string text) { _openIdText.set(std::move(text)); } + /// @brief Tracked. @return Whether the id input names a positive id. + [[nodiscard]] bool canOpen() const; + /// @brief Attaches to the sample the id input names. + void openTyped(); + /// @brief Tracked. @return `"Latest client id: "` (`-1` before one is registered). + [[nodiscard]] std::string clientText() const; + /// @brief Tracked. @return The first failure of the attach or a transition, or empty. + [[nodiscard]] std::string errorText() const; + /// @brief Tracked. @return Whether the attach or a transition is in flight. + [[nodiscard]] bool busy() const; + /// @brief The plain `SampleModel` handler, bound from construction: it serves the actions that name no sample — + /// `RegisterClient`, and the capture form's qualifier and dilution lists. + /// @return The handler; it lives as long as this controller. + [[nodiscard]] morph::bridge::BridgeHandler& registrar() noexcept { return _creator; } + +private: + [[nodiscard]] morph::async::Completion submit(std::string_view actionType, std::string body); + void onReply(std::string_view actionType, std::string const& reply); + [[nodiscard]] bool stateIs(SampleState wanted) const; + + morph::examples::Wiring _wiring; + morph::bridge::BridgeHandler _creator; + morph::bridge::BridgeHandler _handler; + morph::reactive::Signal> _requested; + morph::reactive::Signal> _client; + morph::reactive::Signal _openIdText; + morph::reactive::Query _open; + morph::reactive::Computed> _sampleId; + morph::reactive::Mutation _receive; + morph::reactive::Mutation _startWork; + morph::reactive::Mutation _submitForVerification; + morph::reactive::Mutation _publish; + morph::forms::FormSession _registerClient; + morph::forms::FormSession _registerSample; + morph::forms::FormSession _reject; + morph::forms::FormSession _rework; + // Last member, so it is destroyed first: a reply still in flight finds it stopped. + morph::async::CallbackScope _lifetime; +}; + +} // namespace lims::client +``` + +Create `examples/lims/app/controllers/sample_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/sample_controller.hpp" + +#include +#include +#include +#include +#include +#include + +#include "app/completion_map.hpp" +#include "support/lims_format.hpp" + +namespace lims::client { + +namespace { + +[[nodiscard]] std::optional positiveId(std::string_view text) { + std::int64_t value = 0; + auto const [end, failure] = std::from_chars(text.data(), text.data() + text.size(), value); + if (text.empty() || failure != std::errc{} || end != text.data() + text.size() || value <= 0) { + return std::nullopt; + } + return value; +} + +} // namespace + +SampleController::SampleController(morph::examples::Wiring wiring) + : _wiring{wiring}, + _creator{wiring.bridge, &wiring.callbacks}, + _handler{wiring.bridge, &wiring.callbacks}, + _requested{wiring.runtime, std::nullopt}, + _client{wiring.runtime, std::nullopt}, + _openIdText{wiring.runtime, std::string{}}, + _open{wiring.runtime, _handler, + [this]() -> std::optional { + auto const& requested = _requested.get(); + if (!requested) { + return std::nullopt; + } + return OpenSample{.sampleId = *requested}; + }}, + _sampleId{wiring.runtime, + [this]() -> std::optional { + auto const& view = _open.value(); + if (!view) { + return std::nullopt; + } + return view->id; + }}, + _receive{wiring.runtime, _handler, morph::reactive::MutationOptions{.invalidates = {&_open}}}, + _startWork{wiring.runtime, _handler, morph::reactive::MutationOptions{.invalidates = {&_open}}}, + _submitForVerification{wiring.runtime, _handler, morph::reactive::MutationOptions{.invalidates = {&_open}}}, + _publish{wiring.runtime, _handler, morph::reactive::MutationOptions{.invalidates = {&_open}}}, + _registerClient{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { return submit(actionType, std::move(body)); }, + {}}, + _registerSample{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { return submit(actionType, std::move(body)); }, + {}}, + _reject{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { return submit(actionType, std::move(body)); }, + {}}, + _rework{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { return submit(actionType, std::move(body)); }, + {}} {} + +std::string SampleController::headerText() const { return sampleHeader(_open.value()); } + +std::string SampleController::state() const { + auto const& view = _open.value(); + return view ? std::string{stateName(view->state)} : std::string{}; +} + +bool SampleController::stateIs(SampleState wanted) const { + auto const& view = _open.value(); + return view.has_value() && view->state == wanted; +} + +bool SampleController::canReceive() const { return stateIs(SampleState::Registered); } +bool SampleController::canStartWork() const { return stateIs(SampleState::Received); } +bool SampleController::canSubmit() const { return stateIs(SampleState::InProgress); } +bool SampleController::canPublish() const { return stateIs(SampleState::ToBeVerified); } +bool SampleController::canRework() const { return stateIs(SampleState::ToBeVerified); } +bool SampleController::canReject() const { + return stateIs(SampleState::Registered) || stateIs(SampleState::Received); +} + +void SampleController::receive() { _receive.run(ReceiveSample{}); } +void SampleController::startWork() { _startWork.run(StartWork{}); } +void SampleController::submitForVerification() { _submitForVerification.run(SubmitForVerification{}); } +void SampleController::publish() { _publish.run(PublishSample{}); } + +void SampleController::openSample(SampleId sample) { + if (_requested.peek() == std::optional{sample}) { + _open.refetch(); + return; + } + _requested.set(sample); +} + +void SampleController::refresh() { _open.refetch(); } + +bool SampleController::canOpen() const { return positiveId(_openIdText.get()).has_value(); } + +void SampleController::openTyped() { + if (auto const sampleId = positiveId(_openIdText.peek())) { + openSample(SampleId{*sampleId}); + } +} + +std::string SampleController::clientText() const { + auto const& client = _client.get(); + return "Latest client id: " + std::to_string(client && client->hasValue() ? **client : -1); +} + +std::string SampleController::errorText() const { + for (std::exception_ptr const& error : {_open.error(), _receive.error(), _startWork.error(), + _submitForVerification.error(), _publish.error()}) { + if (error != nullptr) { + return morph::reactive::errorMessage(error); + } + } + return {}; +} + +bool SampleController::busy() const { + return _open.pending() || _receive.pending() || _startWork.pending() || _submitForVerification.pending() || + _publish.pending(); +} + +morph::async::Completion SampleController::submit(std::string_view actionType, std::string body) { + // RegisterClient names no sample, so it cannot run on the shared handler before that handler has attached; + // every other form acts on the attached sample (RegisterSample attaches it from its own reply). + auto completion = actionType == morph::model::ActionTraits::typeId() + ? _creator.executeJson(actionType, body) + : _handler.executeJson(actionType, body); + return morph::examples::mapCompletion( + _wiring.callbacks, _lifetime.token(), std::move(completion), + [this, type = std::string{actionType}](std::string const& reply) { + onReply(type, reply); + return reply; + }, + [](std::exception_ptr const&) {}); +} + +void SampleController::onReply(std::string_view actionType, std::string const& reply) { + if (actionType == morph::model::ActionTraits::typeId()) { + RegisterClientResult result; + if (!glz::read_json(result, reply) && result.clientId.hasValue()) { + _client.set(result.clientId); + _registerClient.reset(); + _registerSample.reset(); + _registerSample.assign("clientId", std::to_string(*result.clientId)); + } + return; + } + if (actionType == morph::model::ActionTraits::typeId()) { + SampleView view; + if (!glz::read_json(view, reply) && view.id.hasValue()) { + _registerSample.reset(); + if (auto const& client = _client.peek(); client && client->hasValue()) { + _registerSample.assign("clientId", std::to_string(**client)); + } + openSample(view.id); + } + return; + } + if (actionType == morph::model::ActionTraits::typeId()) { + _reject.reset(); + } else { + _rework.reset(); + } + _open.refetch(); +} + +} // namespace lims::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_lims_tests +./build/all/examples/lims/ladder_lims_tests "[lims][client]" +``` + +Expected: PASS. + +Mutation check: in `submit`, route every action to `_handler`. Expected FAIL in every case that registers (the +`RegisterClient` submission is refused: "handler not bound"). Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/lims/app/controllers/sample_controller.hpp examples/lims/app/controllers/sample_controller.cpp \ + examples/lims/tests/client/lims_client_support.hpp examples/lims/tests/client/test_sample_controller.cpp +git commit -m "wip(lims): SampleController: registration, attach and the sample lifecycle + +Signed-off-by: Christian Parpart " +``` + +--- +### Task M3: `ResultController` — the results tab + +Re-expresses `ResultPresenter`/`ResultBridge` and `ResultEntryView.qml`: the catalogue is a `Query`; +the results tab's own `AllowShared` handler attaches to whichever sample the lifecycle tab has open (a +`Query` keyed on `SampleController::sampleId()` — `Main.qml`'s `onSampleChanged` hand-off, as a key); +results and conflicts are queries keyed on that attachment; verify is a `Mutation` invalidating the results; the +capture and resolve forms are runtime forms whose success refreshes results and conflicts. The capture form's two +Choice fields fetch their lists (`ListResultQualifiers`, `ListDilutionModes`) through `forms::handlerChoiceFetcher` +over the lifecycle tab's plain `SampleModel` handler (`SampleController::registrar()`): the lists name no sample, the +form fetches them as it is constructed, and this tab's own `AllowShared` handler is not bound until it attaches. + +**Files:** +- Create: `examples/lims/app/controllers/result_controller.hpp`, `examples/lims/app/controllers/result_controller.cpp` +- Test: `examples/lims/tests/client/test_result_controller.cpp` + +**Interfaces:** +- Consumes: `forms::handlerChoiceFetcher` (Part 5, ``); + `SampleController::sampleId()`, `registrar()`, `receive()`, `startWork()`, `state()` (Task M2); `resultRow`, + `conflictRow`, `analysisRow` (Task M1); `morph::examples::mapCompletion` (Part 6); + `forms::FormModel::forAction()` (Part 5); `lims::AnalysisCatalogModel`, + `ListAnalyses`, `DefineAnalysis`, `SampleModel`, `OpenSample`, `ListResults`, `ListConflicts`, `VerifyResult`, + `VerificationView`, `CaptureConcentration`, `ResolveConflict`, `QualifierChoice`, `kQualifierBelowLod`. +- Produces: `ResultController(examples::Wiring, SampleController&)`: `analyses()`, `refreshAnalyses()`, `picked()`, + `pick(std::int64_t)`, `pickedText()`, `captureForm()`, `resolveForm()`, `results()`, `refreshResults()`, + `conflicts()`, `refreshConflicts()`, `hasConflicts()`, `verify(std::int64_t resultId)`, `attached()`, + `statusText()`, `errorText()`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/lims/tests/client/test_result_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include + +#include "controllers/result_controller.hpp" +#include "controllers/sample_controller.hpp" +#include "lims/models/analysis_catalog_model.hpp" +#include "lims_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using lims::client::ResultController; +using lims::client::SampleController; +using lims::testing::LocalClient; +using morph::ladder::testkit::DbFixture; + +namespace { + +[[nodiscard]] morph::math::Rational exact(std::int64_t num, std::int64_t den, std::uint32_t places) { + return morph::math::Rational{morph::math::Numerator{num}, morph::math::Denominator{den}, + morph::math::DecimalPlaces{places}}; +} + +// Defines an analysis straight through the catalogue model, as "alice". +[[nodiscard]] lims::AnalysisVersionId defineAnalysis(std::string name) { + morph::session::Context context; + context.principal = "alice"; + morph::session::detail::ScopedContext const scope{context}; + lims::AnalysisCatalogModel catalog; + return catalog.execute(lims::DefineAnalysis{.name = std::move(name), .canonicalUnit = "mg_per_L", .decimalPlaces = 3}) + .versionId; +} + +// Registers a sample and moves it to in-progress, where results can be captured. +void sampleInProgress(LocalClient& client, SampleController& samples) { + lims::testing::registerSample(client, samples); + samples.receive(); + REQUIRE(client.settle([&samples] { return samples.state() == "received"; })); + samples.startWork(); + REQUIRE(client.settle([&samples] { return samples.state() == "in-progress"; })); +} + +[[nodiscard]] std::string captureBody(lims::CaptureConcentration const& capture) { + auto const body = glz::write_json(capture); + REQUIRE(body.has_value()); + return *body; +} + +} // namespace + +TEST_CASE("lims::client::ResultController follows the lifecycle tab's sample", "[lims][client]") { + DbFixture const fixture; + LocalClient client{"alice"}; + SampleController samples{client.wiring()}; + ResultController results{client.wiring(), samples}; + CHECK_FALSE(results.attached()); + + lims::testing::registerSample(client, samples); + + REQUIRE(client.settle([&results] { return results.attached(); })); + CHECK(results.results().empty()); + CHECK_FALSE(results.hasConflicts()); + CHECK(results.analyses().empty()); + CHECK(results.errorText().empty()); +} + +TEST_CASE("lims::client::ResultController: a captured result carries the exact decimal", "[lims][client]") { + DbFixture const fixture; + auto const nitrate = defineAnalysis("Nitrate"); + LocalClient client{"alice"}; + SampleController samples{client.wiring()}; + ResultController results{client.wiring(), samples}; + sampleInProgress(client, samples); + REQUIRE(client.settle([&results] { return results.attached(); })); + + results.refreshAnalyses(); + REQUIRE(client.settle([&results] { return results.analyses().size() == 1; })); + results.pick(results.analyses().front().versionId); + CHECK(results.pickedText() == "version id " + std::to_string(*nitrate)); + + lims::testing::submitForm(client, results.captureForm(), + captureBody(lims::CaptureConcentration{.analysisVersionId = nitrate, + .value = lims::Concentration{exact(12, 5, 3)}})); + + REQUIRE(client.settle([&results] { return results.results().size() == 1; })); + auto const row = results.results().front(); + CHECK(row.reading == "2.4 mg/L"); + CHECK(row.capturedBy == "alice"); + CHECK(row.outOfSpec.empty()); +} + +TEST_CASE("lims::client::ResultController: a no-number result names its qualifier", "[lims][client]") { + DbFixture const fixture; + auto const lead = defineAnalysis("Lead"); + LocalClient client{"alice"}; + SampleController samples{client.wiring()}; + ResultController results{client.wiring(), samples}; + sampleInProgress(client, samples); + REQUIRE(client.settle([&results] { return results.attached(); })); + + lims::testing::submitForm( + client, results.captureForm(), + captureBody(lims::CaptureConcentration{.analysisVersionId = lead, + .qualifier = lims::QualifierChoice{std::string{lims::kQualifierBelowLod}}})); + + REQUIRE(client.settle([&results] { return results.results().size() == 1; })); + CHECK(results.results().front().reading == "belowLOD"); +} + +TEST_CASE("lims::client::ResultController: verifying one's own result is refused, and says so", "[lims][client]") { + DbFixture const fixture; + auto const nitrate = defineAnalysis("Nitrate"); + LocalClient client{"alice"}; + SampleController samples{client.wiring()}; + ResultController results{client.wiring(), samples}; + sampleInProgress(client, samples); + REQUIRE(client.settle([&results] { return results.attached(); })); + lims::testing::submitForm(client, results.captureForm(), + captureBody(lims::CaptureConcentration{.analysisVersionId = nitrate, + .value = lims::Concentration{exact(12, 5, 3)}})); + REQUIRE(client.settle([&results] { return results.results().size() == 1; })); + + results.verify(results.results().front().id); + + REQUIRE(client.settle([&results] { return !results.errorText().empty(); })); + CHECK(results.statusText().empty()); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_lims_tests` +Expected: compile error, `'controllers/result_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/lims/app/controllers/result_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/sample_controller.hpp" +#include "lims/models/analysis_catalog_model.hpp" +#include "lims/models/sample_model.hpp" +#include "support/lims_format.hpp" + +namespace lims::client { + +/// @brief The results tab: the analysis catalogue, capture, the attached sample's results with verify, and replay +/// conflicts with their resolution. +class ResultController { +public: + /// @param wiring The runtime, bridge and owner this controller uses. + /// @param samples The lifecycle tab, whose open sample this tab attaches to and whose plain handler serves the + /// capture form's lists. Borrowed: it must outlive this. + ResultController(morph::examples::Wiring wiring, SampleController& samples); + + /// @brief Tracked. @return The catalogue's analysis versions. + [[nodiscard]] std::vector analyses() const; + /// @brief Fetches the catalogue again. + void refreshAnalyses() { _analyses.refetch(); } + /// @brief Tracked. @return The picked analysis version, or empty. + [[nodiscard]] std::optional const& picked() const { return _picked.get(); } + /// @brief Picks an analysis version. @param versionId The version. + void pick(std::int64_t versionId) { _picked.set(versionId); } + /// @brief Tracked. @return `"version id "` for the picked version, or empty. + [[nodiscard]] std::string pickedText() const; + /// @brief The `CaptureConcentration` form. @return It. + [[nodiscard]] morph::forms::FormSession& captureForm() noexcept { return _capture; } + /// @brief The `ResolveConflict` form. @return It. + [[nodiscard]] morph::forms::FormSession& resolveForm() noexcept { return _resolve; } + /// @brief Tracked. @return The attached sample's results. + [[nodiscard]] std::vector results() const; + /// @brief Fetches the results again. + void refreshResults() { _results.refetch(); } + /// @brief Tracked. @return The attached sample's replay conflicts. + [[nodiscard]] std::vector conflicts() const; + /// @brief Fetches the conflicts again. + void refreshConflicts() { _conflicts.refetch(); } + /// @brief Tracked. @return Whether there is a conflict to resolve. + [[nodiscard]] bool hasConflicts() const { return !conflicts().empty(); } + /// @brief Verifies a result (the four-eyes rule applies). @param resultId The result. + void verify(std::int64_t resultId); + /// @brief Tracked. @return Whether this tab's handler is attached to the open sample. + [[nodiscard]] bool attached() const { return _attach.value().has_value(); } + /// @brief Tracked. @return `"verified result "` after a verification, or empty. + [[nodiscard]] std::string statusText() const; + /// @brief Tracked. @return The first failure, or empty. + [[nodiscard]] std::string errorText() const; + +private: + [[nodiscard]] morph::async::Completion submit(std::string_view actionType, std::string body); + + morph::examples::Wiring _wiring; + SampleController const* _samples; + morph::bridge::BridgeHandler _catalog; + morph::bridge::BridgeHandler _sample; + morph::reactive::Signal> _picked; + morph::reactive::Query _analyses; + morph::reactive::Query _attach; + morph::reactive::Query _results; + morph::reactive::Query _conflicts; + morph::reactive::Mutation _verify; + morph::forms::FormSession _capture; + morph::forms::FormSession _resolve; + // Last member, so it is destroyed first: a reply still in flight finds it stopped. + morph::async::CallbackScope _lifetime; +}; + +} // namespace lims::client +``` + +Create `examples/lims/app/controllers/result_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "controllers/result_controller.hpp" + +#include +#include +#include + +#include "app/completion_map.hpp" + +namespace lims::client { + +ResultController::ResultController(morph::examples::Wiring wiring, SampleController& samples) + : _wiring{wiring}, + _samples{&samples}, + _catalog{wiring.bridge, &wiring.callbacks}, + _sample{wiring.bridge, &wiring.callbacks}, + _picked{wiring.runtime, std::nullopt}, + _analyses{wiring.runtime, _catalog, []() -> std::optional { return ListAnalyses{}; }}, + _attach{wiring.runtime, _sample, + [this]() -> std::optional { + std::optional const sample = _samples->sampleId(); + if (!sample) { + return std::nullopt; + } + return OpenSample{.sampleId = *sample}; + }}, + _results{wiring.runtime, _sample, + [this]() -> std::optional { + if (!_attach.value()) { + return std::nullopt; + } + return ListResults{}; + }}, + _conflicts{wiring.runtime, _sample, + [this]() -> std::optional { + if (!_attach.value()) { + return std::nullopt; + } + return ListConflicts{}; + }}, + _verify{wiring.runtime, _sample, morph::reactive::MutationOptions{.invalidates = {&_results}}}, + _capture{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { return submit(actionType, std::move(body)); }, + // The lists name no sample, so they come from the plain handler: this tab's shared one is not bound + // until it attaches, and the form fetches them as it is constructed. + morph::forms::handlerChoiceFetcher(wiring.callbacks, samples.registrar())}, + _resolve{wiring.runtime, morph::forms::FormModel::forAction(), + [this](std::string_view actionType, std::string body) { return submit(actionType, std::move(body)); }, + {}} {} + +std::vector ResultController::analyses() const { + std::vector rows; + if (auto const& listed = _analyses.value()) { + for (AnalysisVersionView const& view : listed->analyses) { + rows.push_back(analysisRow(view)); + } + } + return rows; +} + +std::string ResultController::pickedText() const { + auto const& picked = _picked.get(); + return picked ? "version id " + std::to_string(*picked) : std::string{}; +} + +std::vector ResultController::results() const { + std::vector rows; + if (auto const& listed = _results.value()) { + for (ResultView const& view : listed->results) { + rows.push_back(resultRow(view)); + } + } + return rows; +} + +std::vector ResultController::conflicts() const { + std::vector rows; + if (auto const& listed = _conflicts.value()) { + for (ConflictView const& view : listed->conflicts) { + rows.push_back(conflictRow(view)); + } + } + return rows; +} + +void ResultController::verify(std::int64_t resultId) { _verify.run(VerifyResult{.resultId = ResultId{resultId}}); } + +std::string ResultController::statusText() const { + auto const& verified = _verify.lastResult(); + if (!verified || !verified->resultId.hasValue()) { + return {}; + } + return "verified result " + std::to_string(*verified->resultId); +} + +std::string ResultController::errorText() const { + for (std::exception_ptr const& error : + {_verify.error(), _attach.error(), _results.error(), _conflicts.error(), _analyses.error()}) { + if (error != nullptr) { + return morph::reactive::errorMessage(error); + } + } + return {}; +} + +morph::async::Completion ResultController::submit(std::string_view actionType, std::string body) { + bool const capture = actionType == morph::model::ActionTraits::typeId(); + return morph::examples::mapCompletion( + _wiring.callbacks, _lifetime.token(), _sample.executeJson(actionType, body), + [this, capture](std::string const& reply) { + (capture ? _capture : _resolve).reset(); + _results.refetch(); + _conflicts.refetch(); + return reply; + }, + [](std::exception_ptr const&) {}); +} + +} // namespace lims::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_lims_tests +./build/all/examples/lims/ladder_lims_tests "[lims][client]" +``` + +Expected: PASS. + +Mutation check: key `_attach` on nothing (`return std::nullopt;` always). Expected FAIL: "follows the lifecycle +tab's sample" times out on `attached()`, and every capture case is refused because the shared handler never attaches. +Restore it. + +- [ ] **Step 5: Commit** + +```bash +git add examples/lims/app/controllers/result_controller.hpp examples/lims/app/controllers/result_controller.cpp \ + examples/lims/tests/client/test_result_controller.cpp +git commit -m "wip(lims): ResultController: catalogue, capture, results, verify and conflicts on the shared sample + +Signed-off-by: Christian Parpart " +``` + +--- +### Task M4: lims' `AppController` and views — two tabs under a header + +`Main.qml`'s header row, `TabBar` + `StackLayout` and error label become a `Column{header Row, Tabs, error Text}`. +`SampleView.qml`'s and `ResultEntryView.qml`'s `DynamicForm`s become `forms::formView` over the controllers' +runtime sessions (each action is `explicitSubmit`, so each form carries its own submit button, labelled here). The +return/reject forms, which QML disabled outside their states, are hidden outside them: a `Panel`'s `enabled` is not +specified to reach its children, its `visible` is. + +**Files:** +- Create: `examples/lims/app/app_controller.hpp`, `examples/lims/app/app_controller.cpp` +- Create: `examples/lims/app/views/lims_views.hpp`, `examples/lims/app/views/lims_views.cpp` +- Test: `examples/lims/tests/client/test_lims_views.cpp` + +**Interfaces:** +- Consumes: Tasks M2, M3; Part 2's `ui::column`, `row`, `panel`, `tabs`, `table`, `forEach`, + `textInput`, `select`, `button`, `text`, `spacer`; Part 5's `forms::formView`, `FormViewOptions{.submitLabel}`; + `RecordingBackend::find`, `prop`, `click`, `edit`, `dump` (a `Tabs` page is mounted when its tab is first + selected); `lims::testing::fill` (Task M2). +- Produces: `lims::client::AppController(examples::Wiring)` with `samples()`, `results()`, `headerText()`, `errorText()`, + `tab()`, `selectTab(std::size_t)`; views `lifecycleTab(SampleController&)`, `resultsTab(ResultController&)`, + `rootView(AppController&)`. + +- [ ] **Step 1: Write the failing test** + +Create `examples/lims/tests/client/test_lims_views.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// One RecordingBackend test per screen: the tree shows what the screen must, and its controls drive the controller. + +#include +#include +#include +#include +#include +#include +#include + +#include "app_controller.hpp" +#include "lims/models/analysis_catalog_model.hpp" +#include "lims_client_support.hpp" +#include "testkit/db_fixture.hpp" +#include "views/lims_views.hpp" + +namespace { + +using lims::client::AppController; +using lims::testing::LocalClient; +using morph::ladder::testkit::DbFixture; +using morph::ui::testing::RecordingBackend; + +[[nodiscard]] bool shows(RecordingBackend const& backend, std::string const& text) { + return backend.dump().find(text) != std::string::npos; +} + +[[nodiscard]] int byLabel(RecordingBackend const& backend, std::string const& label) { + auto const found = backend.find("Button", "label", label); + REQUIRE(found.has_value()); + return *found; +} + +// Clicks the button labelled @p label once it is enabled. A form's Submit button follows `ready()`, and a +// transition's button its controller's `can…()`, one flush after the change; the backend ignores a click on a +// disabled widget. +void clickWhenEnabled(LocalClient& client, RecordingBackend& backend, std::string const& label) { + int const target = byLabel(backend, label); + REQUIRE(client.settle([&backend, target] { return backend.prop(target, "enabled") != "false"; })); + backend.click(target); +} + +} // namespace + +TEST_CASE("lims view: the shell shows the header, both tabs, and follows the attached sample", "[lims][view]") { + DbFixture const fixture; + LocalClient client{"alice"}; + AppController app{client.wiring()}; + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, lims::client::rootView(app)}; + CHECK(shows(backend, "no sample attached")); + // Both labels are the Tabs widget's own; only the selected (lifecycle) page is mounted yet. + CHECK(backend.find("Tabs", "tabs", "[Lifecycle,Results]").has_value()); + CHECK(backend.find("Button", "label", "Register client").has_value()); + CHECK_FALSE(backend.find("Button", "label", "Refresh catalogue").has_value()); + + lims::testing::registerSample(client, app.samples()); + + REQUIRE(client.settle([&backend] { return shows(backend, "registered (v"); })); + CHECK_FALSE(shows(backend, "no sample attached")); +} + +TEST_CASE("lims view: the lifecycle tab registers through its forms and moves the sample", "[lims][view]") { + DbFixture const fixture; + LocalClient client{"alice"}; + AppController app{client.wiring()}; + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, lims::client::lifecycleTab(app.samples())}; + CHECK(shows(backend, "Latest client id: -1")); + + lims::testing::fill(app.samples().registerClientForm(), R"({"name":"Waterworks Ltd"})"); + clickWhenEnabled(client, backend, "Register client"); + REQUIRE(client.settle([&backend] { return !shows(backend, "Latest client id: -1"); })); + + lims::testing::fill(app.samples().registerSampleForm(), R"({"reference":"WW-1"})"); + clickWhenEnabled(client, backend, "Register sample"); + REQUIRE(client.settle([&app] { return app.samples().state() == "registered"; })); + + clickWhenEnabled(client, backend, "Receive"); + REQUIRE(client.settle([&app] { return app.samples().state() == "received"; })); + CHECK(shows(backend, "state: received")); +} + +TEST_CASE("lims view: the results tab lists the catalogue, captures, and reports a refused verify", "[lims][view]") { + DbFixture const fixture; + lims::AnalysisVersionId nitrate; + { + morph::session::Context context; + context.principal = "alice"; + morph::session::detail::ScopedContext const scope{context}; + lims::AnalysisCatalogModel catalog; + nitrate = catalog.execute(lims::DefineAnalysis{.name = "Nitrate", .canonicalUnit = "mg_per_L", .decimalPlaces = 3}) + .versionId; + } + LocalClient client{"alice"}; + AppController app{client.wiring()}; + lims::testing::registerSample(client, app.samples()); + app.samples().receive(); + REQUIRE(client.settle([&app] { return app.samples().state() == "received"; })); + app.samples().startWork(); + REQUIRE(client.settle([&app] { return app.samples().state() == "in-progress"; })); + RecordingBackend backend; + morph::ui::Mounted const mounted{client.runtime, backend, lims::client::resultsTab(app.results())}; + + backend.click(byLabel(backend, "Refresh catalogue")); + REQUIRE(client.settle([&backend] { return shows(backend, "Nitrate"); })); + + auto const body = glz::write_json(lims::CaptureConcentration{ + .analysisVersionId = nitrate, + .value = lims::Concentration{morph::math::Rational{morph::math::Numerator{12}, morph::math::Denominator{5}, + morph::math::DecimalPlaces{3}}}}); + REQUIRE(body.has_value()); + lims::testing::fill(app.results().captureForm(), *body); + clickWhenEnabled(client, backend, "Capture"); + REQUIRE(client.settle([&backend] { return shows(backend, "2.4 mg/L"); })); + + backend.click(byLabel(backend, "Verify")); + REQUIRE(client.settle([&app] { return !app.results().errorText().empty(); })); + CHECK(shows(backend, app.results().errorText())); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_lims_tests` +Expected: compile error, `'app_controller.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/lims/app/app_controller.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include "app/wiring.hpp" +#include "controllers/result_controller.hpp" +#include "controllers/sample_controller.hpp" + +namespace lims::client { + +/// @brief The lims client: the lifecycle and results tabs' controllers and the selected tab. +class AppController { +public: + /// @param wiring The runtime, bridge and owner both controllers use. + explicit AppController(morph::examples::Wiring wiring); + + /// @brief The lifecycle tab's controller. @return It. + [[nodiscard]] SampleController& samples() noexcept { return _samples; } + /// @brief The results tab's controller. @return It. + [[nodiscard]] ResultController& results() noexcept { return _results; } + /// @brief Tracked. @return The header line naming the attached sample. + [[nodiscard]] std::string headerText() const { return _samples.headerText(); } + /// @brief Tracked. @return The lifecycle tab's failure, shown under both tabs, or empty. + [[nodiscard]] std::string errorText() const { return _samples.errorText(); } + /// @brief The selected tab. Tracked. @return Its index. + [[nodiscard]] std::size_t tab() const { return _tab.get(); } + /// @brief Selects a tab. @param index Its index. + void selectTab(std::size_t index) { _tab.set(index); } + +private: + SampleController _samples; + ResultController _results; // borrows _samples, so it is declared after it + morph::reactive::Signal _tab; +}; + +} // namespace lims::client +``` + +Create `examples/lims/app/app_controller.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "app_controller.hpp" + +namespace lims::client { + +AppController::AppController(morph::examples::Wiring wiring) + : _samples{wiring}, _results{wiring, _samples}, _tab{wiring.runtime, std::size_t{0}} {} + +} // namespace lims::client +``` + +Create `examples/lims/app/views/lims_views.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "app_controller.hpp" + +namespace lims::client { + +/// @brief The lifecycle tab. @param samples Its controller. @return The tab's content. +[[nodiscard]] morph::ui::Node lifecycleTab(SampleController& samples); +/// @brief The results tab. @param results Its controller. @return The tab's content. +[[nodiscard]] morph::ui::Node resultsTab(ResultController& results); +/// @brief The whole app: the header, both tabs, and the lifecycle tab's failure. +/// @param app The app; it outlives the mounted view. +/// @return The root node. +[[nodiscard]] morph::ui::Node rootView(AppController& app); + +} // namespace lims::client +``` + +Create `examples/lims/app/views/lims_views.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "views/lims_views.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lims::client { + +namespace { + +namespace ui = morph::ui; +using morph::reactive::Signal; + +ui::Node form(morph::forms::FormSession& session, std::string submitLabel) { + return morph::forms::formView(session, morph::forms::FormViewOptions{.submitLabel = std::move(submitLabel)}); +} + +ui::Node stretchText(std::function text, ui::TextRole role = ui::TextRole::Normal) { + return ui::text({.text = std::move(text), .role = role, .common = {.layout = {.width = ui::Sizing::stretch()}}}); +} + +std::vector analysisOptions(ResultController const& results) { + std::vector options; + for (AnalysisRow const& row : results.analyses()) { + options.push_back(ui::SelectOption{.key = ui::Key{row.versionId}, .label = row.name}); + } + return options; +} + +} // namespace + +ui::Node lifecycleTab(SampleController& samples) { + return ui::column({ + .children = + { + ui::panel({.title = "Register", + .padding = 1, + .child = ui::column({.children = {form(samples.registerClientForm(), "Register client"), + ui::text({.text = [&samples] { return samples.clientText(); }, + .role = ui::TextRole::Muted}), + form(samples.registerSampleForm(), "Register sample")}, + .gap = 1})}), + ui::row({.children = {ui::text({.text = "Open existing"}), + ui::textInput({.value = [&samples] { return samples.openIdText(); }, + .onChange = [&samples](std::string text) { samples.setOpenIdText(std::move(text)); }, + .placeholder = "Sample id"}), + ui::button({.label = "Open sample", + .onClick = [&samples] { samples.openTyped(); }, + .common = {.enabled = [&samples] { return samples.canOpen(); }}})}, + .gap = 1}), + ui::panel( + {.title = "Lifecycle", + .padding = 1, + .child = ui::column( + {.children = + {ui::text({.text = [&samples] { return "state: " + samples.state(); }, + .common = {.visible = [&samples] { return samples.sample().has_value(); }}}), + ui::row({.children = {ui::button({.label = "Receive", + .onClick = [&samples] { samples.receive(); }, + .common = {.enabled = [&samples] { return samples.canReceive(); }}}), + ui::button({.label = "Start work", + .onClick = [&samples] { samples.startWork(); }, + .common = {.enabled = [&samples] { return samples.canStartWork(); }}}), + ui::button({.label = "Submit for verification", + .onClick = [&samples] { samples.submitForVerification(); }, + .common = {.enabled = [&samples] { return samples.canSubmit(); }}}), + ui::button({.label = "Publish", + .onClick = [&samples] { samples.publish(); }, + .common = {.enabled = [&samples] { return samples.canPublish(); }}}), + ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::button({.label = "Refresh", + .onClick = [&samples] { samples.refresh(); }, + .common = {.enabled = [&samples] { return samples.sample().has_value(); }}})}, + .gap = 1})}, + .gap = 1})}), + ui::panel({.title = "Return or reject", + .padding = 1, + .child = ui::column( + {.children = {ui::column({.children = {form(samples.reworkForm(), "Return for rework")}, + .common = {.visible = [&samples] { return samples.canRework(); }}}), + ui::column({.children = {form(samples.rejectForm(), "Reject")}, + .common = {.visible = [&samples] { return samples.canReject(); }}})}, + .gap = 1}), + .common = {.visible = [&samples] { return samples.canRework() || samples.canReject(); }}}), + }, + .gap = 1, + }); +} + +ui::Node resultsTab(ResultController& results) { + return ui::column({ + .children = + { + ui::row({.children = {ui::text({.text = "Catalogue"}), + ui::select({.options = [&results] { return analysisOptions(results); }, + .selected = [&results]() -> std::optional { + auto const& picked = results.picked(); + return picked ? std::optional{ui::Key{*picked}} : std::nullopt; + }, + .onSelect = + [&results](ui::Key const& key) { + if (auto const* const version = std::get_if(&key); + version != nullptr) { + results.pick(*version); + } + }}), + ui::text({.text = [&results] { return results.pickedText(); }, .role = ui::TextRole::Muted}), + ui::button({.label = "Refresh catalogue", .onClick = [&results] { results.refreshAnalyses(); }})}, + .gap = 1}), + ui::panel({.title = "Capture", .padding = 1, .child = form(results.captureForm(), "Capture")}), + ui::row({.children = {ui::text({.text = "Results", .role = ui::TextRole::Heading}), + ui::spacer({.common = {.layout = {.width = ui::Sizing::stretch()}}}), + ui::button({.label = "Refresh results", + .onClick = [&results] { results.refreshResults(); }, + .common = {.enabled = [&results] { return results.attached(); }}}), + ui::button({.label = "Refresh conflicts", + .onClick = [&results] { results.refreshConflicts(); }, + .common = {.enabled = [&results] { return results.attached(); }}})}, + .gap = 1}), + ui::table( + {{.label = "Id"}, + {.label = "Reading", .width = ui::Sizing::stretch()}, + {.label = "Spec"}, + {.label = "Captured by"}, + {.label = ""}}, + [&results] { return results.results(); }, [](ResultRow const& row) { return ui::Key{row.id}; }, + [&results](Signal const& row) { + return std::vector{ + ui::text({.text = [&row] { return row.get().idText; }, .role = ui::TextRole::Muted}), + ui::text({.text = [&row] { return row.get().reading; }}), + ui::text({.text = [&row] { return row.get().outOfSpec; }, .role = ui::TextRole::Error}), + ui::text({.text = [&row] { return row.get().capturedBy; }, .role = ui::TextRole::Muted}), + ui::button({.label = "Verify", .onClick = [&results, &row] { results.verify(row.peek().id); }})}; + }), + ui::forEach( + [&results] { return results.conflicts(); }, [](ConflictRow const& row) { return ui::Key{row.id}; }, + [](Signal const& row) { + return stretchText([&row] { return row.get().text; }, ui::TextRole::Error); + }), + ui::panel({.title = "Resolve conflict", + .padding = 1, + .child = form(results.resolveForm(), "Resolve"), + .common = {.visible = [&results] { return results.hasConflicts(); }}}), + ui::text({.text = [&results] { return results.statusText(); }, .role = ui::TextRole::Success}), + ui::text({.text = [&results] { return results.errorText(); }, .role = ui::TextRole::Error}), + }, + .gap = 1, + }); +} + +ui::Node rootView(AppController& app) { + return ui::column({ + .children = + { + ui::row({.children = {ui::text({.text = "lims", .role = ui::TextRole::Heading}), + stretchText([&app] { return app.headerText(); }, ui::TextRole::Muted)}, + .gap = 1}), + ui::tabs({.tabs = {{.label = "Lifecycle", .node = lifecycleTab(app.samples())}, + {.label = "Results", .node = resultsTab(app.results())}}, + .selected = [&app] { return app.tab(); }, + .onSelect = [&app](std::size_t index) { app.selectTab(index); }, + .common = {.layout = {.height = ui::Sizing::stretch()}}}), + ui::text({.text = [&app] { return app.errorText(); }, .role = ui::TextRole::Error}), + }, + .gap = 1, + }); +} + +} // namespace lims::client +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_lims_tests +./build/all/examples/lims/ladder_lims_tests "[lims][view]" +git grep -n -E '#include " +``` + +--- +### Task M5: lims' application, binary and frontend smoke tests + +lims still has no server of its own. The client runs its models in-process by default, exactly as the old +`ladder_lims_gui` did; `--server` is kept because `connect` offers it and a deployment may host `SampleModel` and +`AnalysisCatalogModel` itself. Unlike the old client, it installs a principal: `--user`, default `demo`, so a capture +records who captured it. Its database is `--db`, else the `LIMS_DB` environment variable (read through +`ui::processEnvironment()`), else `lims.db`, so `ui/main.cpp` is exactly spec 4 §3's composition root. + +**Files:** +- Create: `examples/lims/app/lims_application.hpp`, `examples/lims/app/lims_application.cpp` +- Create: `examples/lims/ui/main.cpp` +- Test: `examples/lims/tests/smoke/test_lims_frontends.cpp` + +**Interfaces:** +- Consumes: as Task L9 (Part 6's `AppEnvironment`, `connect`, `LocalSetup`, `Connection`, `Wiring`; Part 2's + frontend seam and `ui::processEnvironment()`; Parts 3–4's `frontendOption`s; `runFrontendSmoke`); + `AppController`, `rootView` (Task M4); `lims::db::setup`; `Bridge::setDefaultSession` (`morph/core/bridge.hpp`, as + `LocalClient` in Task M2 uses it). +- Produces: `lims::client::makeApplication(ui::AppContext&, examples::AppEnvironment const&) -> + std::unique_ptr`; the `lims` binary. + +- [ ] **Step 1: Write the failing test** + +Create `examples/lims/tests/smoke/test_lims_frontends.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The application mounts and quits on every frontend this build has (spec 4 §7 rule 5). This file is +// ladder_lims_smoke_tests, a binary of its own: its main owns no Qt application object. + +#if MORPH_EXAMPLE_HAS_TUI || MORPH_EXAMPLE_HAS_QT_QUICK + +#include +#include +#include + +#include "app/app_environment.hpp" +#include "lims_application.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/frontend_smoke.hpp" + +namespace { + +using morph::examples::testing::SmokeFrontend; + +void smoke(SmokeFrontend frontend) { + morph::ladder::testkit::DbFixture const fixture; + morph::examples::AppEnvironment env; + // NOLINTNEXTLINE(concurrency-mt-unsafe): read on the test thread before the app starts any thread. + env.db = morph::ladder::testkit::DbFixture::computeConnectionString(std::getenv("ODBC_CONNECTION_STRING")); + env.user = "alice"; + morph::examples::testing::runFrontendSmoke( + [&env](morph::ui::AppContext& ctx) { return lims::client::makeApplication(ctx, env); }, frontend); +} + +} // namespace + +#if MORPH_EXAMPLE_HAS_TUI +TEST_CASE("lims mounts and quits on the TUI", "[lims][smoke]") { smoke(SmokeFrontend::Tui); } +#endif + +#if MORPH_EXAMPLE_HAS_QT_QUICK +TEST_CASE("lims mounts and quits on Qt Quick", "[lims][smoke]") { smoke(SmokeFrontend::QtQuick); } +#endif + +#endif +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cmake build/all && cmake --build build/all --target ladder_lims_smoke_tests`. +Expected: compile error, `'lims_application.hpp' file not found`. + +- [ ] **Step 3: Implement** + +Create `examples/lims/app/lims_application.hpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "app/app_environment.hpp" + +namespace lims::client { + +/// @brief Builds the lims application on a frontend's context: hosts the models in-process on `--db` (else the +/// `LIMS_DB` environment variable, else `lims.db`), or reaches them at `--server`, and acts as `--user` +/// (default `demo`). +/// @param ctx The frontend's context. Borrowed: it outlives the application. +/// @param env `--server`, `--db`, `--user`. +/// @return The application. +/// @throws morph::examples::TransportError when `--server` is given and this frontend has no transport for it. +[[nodiscard]] std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env); + +} // namespace lims::client +``` + +Create `examples/lims/app/lims_application.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include "lims_application.hpp" + +#include +#include +#include +#include +#include + +#include "app/transport.hpp" +#include "app/wiring.hpp" +#include "app_controller.hpp" +#include "lims/db/database.hpp" +#include "views/lims_views.hpp" + +namespace lims::client { + +namespace { + +constexpr std::string_view kDefaultDatabase = "DRIVER=SQLite3;Database=lims.db;Timeout=5000"; +constexpr std::string_view kDevPrincipal = "demo"; +constexpr std::size_t kLocalWorkers = 4; + +[[nodiscard]] morph::examples::AppEnvironment withDatabase(morph::examples::AppEnvironment env) { + if (env.db.empty()) { + env.db = morph::ui::processEnvironment()("LIMS_DB").value_or(std::string{kDefaultDatabase}); + } + return env; +} + +// The models re-check every role they depend on, on every dispatch path, so an in-process client is gated as hard +// as a remote one. What it needs is a principal for those checks and for the records' "captured by". +[[nodiscard]] std::unique_ptr actingAs( + std::unique_ptr connection, std::string principal) { + morph::session::Context session; + session.principal = std::move(principal); + connection->bridge().setDefaultSession(std::move(session)); + return connection; +} + +class LimsApplication final : public morph::ui::Application { +public: + LimsApplication(morph::ui::AppContext& ctx, morph::examples::AppEnvironment const& env) + : _connection{actingAs( + morph::examples::connect( + ctx, withDatabase(env), + morph::examples::LocalSetup{.setupDatabase = [](std::string const& database) { db::setup(database); }, + .workers = kLocalWorkers}), + env.user.empty() ? std::string{kDevPrincipal} : env.user)}, + _app{morph::examples::Wiring{.runtime = ctx.runtime(), + .scheduler = ctx.scheduler(), + .bridge = _connection->bridge(), + .callbacks = _connection->callbacks()}} {} + + [[nodiscard]] morph::ui::Node view() override { return rootView(_app); } + +private: + std::unique_ptr _connection; + AppController _app; +}; + +} // namespace + +std::unique_ptr makeApplication(morph::ui::AppContext& ctx, + morph::examples::AppEnvironment const& env) { + return std::make_unique(ctx, env); +} + +} // namespace lims::client +``` + +Create `examples/lims/ui/main.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// The lims client: picks the Qt Quick or terminal frontend at runtime (--ui=qt|tui, MORPH_UI, or the first that can +// run here) and hands it the application. +// +// lims [--ui=qt|tui] [--db ] [--user ] [--server ws://host:port] +// +// Without --db, the LIMS_DB environment variable names the database, else lims.db. + +#include +#include +#include +#include + +#include "app/app_environment.hpp" +#include "lims_application.hpp" + +#if MORPH_EXAMPLE_HAS_QT_QUICK +#include +#endif +#if MORPH_EXAMPLE_HAS_TUI +#include +#endif + +int main(int argc, char** argv) { + try { + auto const env = morph::examples::AppEnvironment::fromArgs(argc, argv); + std::vector built; +#if MORPH_EXAMPLE_HAS_QT_QUICK + built.push_back(morph::qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_EXAMPLE_HAS_TUI + built.push_back(morph::tui::frontendOption()); +#endif + auto const frontend = morph::ui::selectFrontend(built, argc, argv); + return frontend->run([&env](morph::ui::AppContext& ctx) { return lims::client::makeApplication(ctx, env); }); + } catch (std::exception const& error) { + std::cerr << "lims: " << error.what() << '\n'; + return 1; + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_lims_smoke_tests lims +./build/all/examples/lims/ladder_lims_smoke_tests +``` + +Expected: both targets build; 2 smoke cases pass. + +Mutation check: make `makeApplication` begin with `throw std::runtime_error{"mutation"};`. Expected FAIL in both smoke +cases. Restore it. + +By hand, once: `./build/all/examples/lims/lims --ui=tui --db "DRIVER=SQLite3;Database=/tmp/lims-manual.db"` — +register a client and a sample, receive it, start work, switch to Results, Ctrl+C; the same with `--ui=qt`. Report +what you saw. + +- [ ] **Step 5: Commit** + +```bash +git add examples/lims/app/lims_application.hpp examples/lims/app/lims_application.cpp examples/lims/ui \ + examples/lims/tests/smoke/test_lims_frontends.cpp +git commit -m "wip(lims): the application and one binary for both frontends + +Signed-off-by: Christian Parpart " +``` + +--- +### Task M6: Retire lims' QML stack; the README's client section + +| Deleted test file | What it pinned | Where that claim lives now | +|---|---|---| +| `test_lims_presenters.cpp` | registration and every transition routed; a refusal displayable; busy while in flight; catalogue, capture and listing routed | `test_sample_controller.cpp` (the first three, one case each), `test_result_controller.cpp` ("a captured result carries the exact decimal") | +| `test_lims_qml_bridges.cpp` | every connected signal exists; the sample bag carries every key QML reads | by construction: views call controller members, and the compiler checks every one | +| | a refusal reaches the operator and `lastError` holds it | `test_sample_controller.cpp` ("surfaces a model refusal…"), `test_result_controller.cpp` ("verifying one's own result is refused…") | +| | a result row's exact decimal; a no-number result names its qualifier | `test_result_controller.cpp` (two cases), `test_lims_format.cpp` ("a result row names its reading…") | +| | an unengaged id crosses as -1; quantity text exact and empty-safe | `test_sample_controller.cpp` (`"Latest client id: -1"`), `test_lims_format.cpp` ("quantity text is exact and empty-safe") | +| | the schema document carries a form per typed-field action; `submitIfValid` refuses a foreign action | by construction: each controller builds one `FormSession` per action from that action's own schema, and a session submits only its own action type | +| | a form body reaches the model; registering through forms leaves the shared handler attached with the client id set; a model's refusal of a body is its own message | `test_sample_controller.cpp` ("routes registration…", "the reject form's refusal is the model's own message"), `test_result_controller.cpp` ("follows the lifecycle tab's sample") | +| `test_lims_qml_surface.cpp` | the bridges expose exactly what QML binds; a refused form submission has a path to the operator | by construction; `formView` shows each session's last error itself (Part 5), and `test_lims_views.cpp` shows the results tab's refused verify | +| | every rendered form opts out of auto-submit; a read-only action's schema does not | `test_lims_forms.cpp` (below) | +| `test_gui_qml_smoke.cpp` | `Main.qml` and both surfaces load; each schema form renders its own Submit button | `tests/smoke/test_lims_frontends.cpp`; `test_lims_views.cpp` clicks "Register client", "Register sample" and "Capture" | + +**Files:** +- Delete: `examples/lims/gui/`, `examples/lims/gui_lib/`, and in `examples/lims/tests/`: + `test_lims_presenters.cpp`, `test_lims_qml_bridges.cpp`, `test_lims_qml_surface.cpp`, `test_gui_qml_smoke.cpp` +- Create: `examples/lims/tests/client/test_lims_forms.cpp` +- Modify: `examples/lims/README.md`, `examples/lims/src/models/sample_model.cpp` (one comment), + `examples/crm/include/crm/gui/crm_schemas.hpp` (one comment), `codecov.yml` +- Test: the whole lims suite, plus the check below + +**Interfaces:** none new. + +- [ ] **Step 1: Write the failing check and the replacement test** + +```bash +test ! -e examples/lims/gui && test ! -e examples/lims/gui_lib && \ + { git grep -n -E 'lims::gui|lims_qml|ladder_lims_gui|lims/gui_lib|lims/gui/|gui/qml|lims_schemas|SampleBridge|ResultBridge|SamplePresenter|ResultPresenter' -- \ + examples/lims examples/crm/include/crm/gui/crm_schemas.hpp codecov.yml; test $? -eq 1; } && \ + echo "lims' QML stack is gone" +``` + +Create `examples/lims/tests/client/test_lims_forms.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// Every form the lims client renders has effects (a registration, a rework or rejection, a reading, a conflict +// resolution), so each must wait for its Submit button: a form that auto-submitted would file one action per +// keystroke. The served schema is what decides it, so this reads the session's model, not the DTO's declaration. + +#include +#include + +#include "controllers/result_controller.hpp" +#include "controllers/sample_controller.hpp" +#include "lims_client_support.hpp" +#include "testkit/db_fixture.hpp" + +using morph::forms::SubmitMode; + +TEST_CASE("lims::client: every form the client renders submits only on its button", "[lims][client]") { + morph::ladder::testkit::DbFixture const fixture; + lims::testing::LocalClient client{"alice"}; + lims::client::SampleController samples{client.wiring()}; + lims::client::ResultController results{client.wiring(), samples}; + + for (morph::forms::FormSession const* form : + {&samples.registerClientForm(), &samples.registerSampleForm(), &samples.rejectForm(), &samples.reworkForm(), + &results.captureForm(), &results.resolveForm()}) { + INFO("action type: " << form->model().actionType()); + CHECK(form->model().submitMode() == SubmitMode::Explicit); + } +} + +TEST_CASE("lims::client: a read-only action's form would submit automatically", "[lims][client]") { + // The other half, and why the check above is not vacuous: explicit submit is opt-in, so a schema writer that + // stamped it on every action would pass the first case too. + CHECK(morph::forms::FormModel::forAction().submitMode() == SubmitMode::Automatic); +} +``` + +- [ ] **Step 2: Run them to verify the check fails** + +Expected: no "gone" line (the directories exist; the README, `sample_model.cpp`, `crm_schemas.hpp` and +`codecov.yml` match). `test_lims_forms.cpp` already passes: it pins behaviour the controllers have since Tasks M2 and +M3, and is added here because it replaces a deleted test's claim. + +- [ ] **Step 3: Implement** + +```bash +git rm -r -q examples/lims/gui examples/lims/gui_lib +git rm -q examples/lims/tests/test_lims_presenters.cpp examples/lims/tests/test_lims_qml_bridges.cpp \ + examples/lims/tests/test_lims_qml_surface.cpp examples/lims/tests/test_gui_qml_smoke.cpp +``` + +`examples/lims/src/models/sample_model.cpp`, the comment above `markDecided`: replace its second paragraph ("A free +function rather than a member: … is ORM-free for the same reason.") with: + +```cpp +/// A free function rather than a member: it touches no state of its own, and +/// keeping `Lightweight::DataMapper` out of `sample_model.hpp` keeps that +/// header includable by a client built without the ORM (`MORPH_CLIENT_ONLY`). +/// Every other rung's model header is ORM-free for the same reason. +``` + +`examples/crm/include/crm/gui/crm_schemas.hpp`, the `@file` comment: replace "mirrors +`lims::gui::limsSchemasJson()`'s exact shape (`examples/lims/gui_lib/lims_schemas.hpp`). Lives under +`include/crm/gui` rather than `gui_lib/` (unlike lims's copy): it has" with "one entry per form action, each +`schemaJson()`. Lives under `include/crm/gui` rather than `gui_lib/`: it has", and "Selection rule (from lims's +file, reused here):" with "Selection rule:", and "for the same reason lims excludes its own empty-body transitions" +with "because an action with no fields has no form to generate". + +`codecov.yml`: replace `"examples/lims/gui/**"` and `"examples/lims/gui_wasm/**"` with `"examples/lims/ui/**"`. + +`examples/lims/README.md`: + +1. In the status paragraph, replace "and a full QML GUI under [`gui/qml/`](gui/qml);" with "and a client for Qt Quick + and the terminal under [`app/`](app);". In "Resolved design decisions", replace "and a full QML GUI under + [`gui/qml/`](gui/qml)." with "and a client for Qt Quick and the terminal under [`app/`](app).". +2. Replace everything from `## The client` up to (not including) `### 20. Model coverage is 98.96%` with: + +```markdown +## The client + +One binary, `lims` (`ui/main.cpp`), runs on Qt Quick or in a terminal: `--ui=qt|tui`, else `MORPH_UI`, else the +first frontend that can run here. It hosts the models in-process on `--db` (else `LIMS_DB`, else `lims.db`) and acts +as `--user` (default `demo`); the models re-check every role they depend on, on every dispatch path, so an +in-process client is gated as hard as a remote one. `--server ws://host:port` reaches the same models hosted +elsewhere. **Known gap:** this rung builds no server of its own, so `--server` needs one somebody else hosts. + +`app/` is `ladder_lims_app`, which links no toolkit: `SampleController` (the lifecycle tab), `ResultController` +(the results tab), `AppController` (both, and the selected tab) and one view per tab, made of bindings. + +Two tabs, one shell: the sample lifecycle and result entry, side by side rather than stacked, because both act on +the *same* attached sample at once — a bench operator captures a reading while the office watches the state move. +The results tab's handler is `AllowShared` over the keyed `SampleModel` and attaches with a `Query` keyed +on the lifecycle tab's attached sample id, so both land on one instance. That is the shared-instance design made +visible rather than asserted. + +### Every typed field is schema-driven; nothing is hand-built + +> **A field a person types is rendered from the served schema. A value the model already supplied is a typed +> call.** + +So `RegisterClient`, `RegisterSample`, `RejectSample`, `ReturnForRework`, `CaptureConcentration` and +`ResolveConflict` are runtime forms: a `FormSession` built from the action's own `schemaJson()`, rendered by +`forms::formView`. The zero-field transitions (`ReceiveSample`, `StartWork`, `SubmitForVerification`, +`PublishSample`) are plain buttons, because an action with no fields has no form to generate. `VerifyResult` and +`OpenSample` are typed calls: their one field is an id the table row or the id field already holds. + +The capture form is the one that earns its keep: `exactlyOneOf(value, qualifier)` is the submit gate that makes "a +number *and* a below-LOD flag" unsubmittable; the `requiredWhen`/`visibleWhen` pair shows the dilution factor and +requires it exactly when the preparation says diluted; `x-decimalPlaces`/`x-unitAlternatives` drive the entry unit. +None of that is written in the app. Its qualifier and dilution lists come through `forms::handlerChoiceFetcher` over +the lifecycle tab's plain handler: they name no sample, and the results tab's shared handler cannot serve them before +it has attached. + +Every one of these actions declares `explicitSubmit`, so each form waits for its own Submit button +(`tests/client/test_lims_forms.cpp`): an automatic form would file a lab reading mid-keystroke. + +### A refusal is shown where it happened + +A refusal is this rung's product, not its exception path — an over-precise reading (decision 7), an `exactlyOneOf` +violation (decision 6), a four-eyes refusal (decision 16), an unknown qualifier or dilution code (decision 18) and a +rejected conflict resolution are each a statement about the measurement that the analyst has to read. A form shows +its own last reply or the model's own message under its Submit button, and keeps what was typed until a submission +is accepted. The typed calls' failures — open, the transitions, verify — are each tab's error line; the lifecycle +tab's also shows under both tabs. + +### Two handlers on the lifecycle tab, and why + +`SampleModel` is keyed, so the handler every attached action runs on is `AllowShared`. But an `AllowShared` handler +is **not bound until it attaches to a key**, and `RegisterClient` carries none, so that one form submits on a +second, plain `NoSharing` handler, which registers a fresh instance on construction and is bound immediately. +`RegisterSample` needs no such help even though it too arrives before any key exists: it is result-keyed, so +`BridgeHandler::execute`'s `ResultKeyed` branch runs it on an anonymous instance and promotes that instance to the id +the result names before the completion resolves — one dispatch, on the *shared* handler, and that handler is +attached when it returns. `RegisterClient`'s reply is decoded to show the latest client id and to fill it into the +`RegisterSample` form. + +### One dispatch path per action + +No action has both a typed call and a form. Two paths to one action is two places for the behaviour to differ — +a typed capture that took a `double` would round an over-precise reading that the form submits exactly and the +model refuses (decision 7). There is no `double` anywhere on the client: readings are entered and shown as exact +decimals. + +### What the tests cover + +`tests/client/` drives each controller against the real models in-process (`test_sample_controller.cpp`, +`test_result_controller.cpp`), mounts each tab on a recording backend and drives it through its controls +(`test_lims_views.cpp`), and starts the whole application on each frontend the build has +(`tests/smoke/test_lims_frontends.cpp`). + +**What looks different from the old Qt Quick screens.** Behaviour is kept; the layout is each frontend's own. The +return and reject forms are hidden outside the states they apply to instead of disabled; the open-existing id is a +text field instead of a spin box; the results are a table; and a capture records the `--user` who made it, where the +old client installed no principal. +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake build/all && cmake --build build/all --target ladder_lims_tests lims +./build/all/examples/lims/ladder_lims_tests +``` + +Then the check from Step 1. Expected: every lims case passes; the check prints "lims' QML stack is gone". + +Mutation check: in `examples/lims/include/lims/dto/sample_dto.hpp`, set `RejectSample::explicitSubmit` to `false`. +Expected FAIL in "every form the client renders submits only on its button" (action type `RejectSample`). Restore +it. + +- [ ] **Step 5: Commit** + +```bash +git add -A examples/lims examples/crm/include/crm/gui/crm_schemas.hpp codecov.yml +git commit -m "wip(lims): retire lims' QML, bridges and presenters; README client section + +Signed-off-by: Christian Parpart " +``` + +--- + +### Task M7: lims group verification, the ladder library's Qt link, and squash + +With kanban's, ledger's and lims' domain libraries Qt-free, nothing in `ladder__lib` needs Qt. Part 8's +`morph_add_rung` already links it `Qt6::Core` (and turns on its `AUTOMOC`) only while a rung has no `app/` +directory, and all three rungs now have one, so this task confirms the link is gone rather than changing the +function. + +**Files:** none new; fixes land in the files they concern. + +- [ ] **Step 1: Confirm the ladder library's Qt link** + +```bash +grep -n -B1 -A3 'if(NOT EXISTS "${_dir}/app")' cmake/morph_add_rung.cmake +git grep -l -E '#include +``` + +The last line of the procedure must show `examples/lims: one app, any frontend` directly after +`examples/ledger: one app, any frontend`. diff --git a/docs/superpowers/plans/2026-10-04-declarative-ui-tui-interfaces.md b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-interfaces.md new file mode 100644 index 000000000..c50170c65 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-declarative-ui-tui-interfaces.md @@ -0,0 +1,442 @@ +# Declarative UI Program — Cross-Part Interface Contract + +The public names each part **produces** and later parts **consume**. Part plans implement exactly these +declarations (Doxygen, `noexcept`, `[[nodiscard]]` and private members are each plan's to add); a part that needs a +name not listed here defines it in its own namespace and lists it in its plan's Interfaces block. If a plan must +deviate, its squashed commit states the deviation (master plan, "The docs commit"). + +Naming: types `CamelCase`, functions `camelBack`, private members `_camelBack`, constants `kName`. Strings are UTF-8. + +--- + +## Part 0 — core-cpp 0.7 (`core::tui`, in the core-cpp repository) + +```cpp +namespace core::tui { +enum class MouseTracking : std::uint8_t { Off, Buttons, Drag, AnyMotion }; +// TerminalInput::setMouseTracking(MouseTracking); Terminal::setMouseTracking(MouseTracking); +// Screen::releasePointer(); [[nodiscard]] Component* Screen::componentAt(int row, int col) const; +} +``` + +morph: `MORPH_CORE_CPP_VERSION 0.7`, `GIT_TAG v0.7.0`. + +## Part 1 — `morph::reactive` (`include/morph/reactive/`) + +As Part 1's plan defines them: `Runtime`, `RuntimeOptions{maxEffectRunsPerFlush, afterFlush}` (`runtime.hpp`); +`Signal`, `Computed`, `Effect` (`signal.hpp`); `Scope` (`scope.hpp`); `ExhaustiveUpdate`, +`Store`, `request()` (`store.hpp`); `errorMessage`, `Refetchable`, `Query`, +`MutationOptions`, `Mutation`, `Subscription` (`control.hpp`); `detail::site::*` misuse names. Plus: + +```cpp +// include/morph/reactive/scheduler.hpp +namespace morph::reactive { +class TimerHandle { // move-only; cancels on destruction +public: + TimerHandle() = default; + explicit TimerHandle(std::function cancel); + ~TimerHandle(); + TimerHandle(TimerHandle&&) noexcept; TimerHandle& operator=(TimerHandle&&) noexcept; + void cancel() noexcept; + [[nodiscard]] bool active() const noexcept; +}; +class Scheduler { // callbacks run on the runtime's owner executor +public: + virtual ~Scheduler() = default; + [[nodiscard]] virtual TimerHandle after(std::chrono::milliseconds delay, std::function fn) = 0; + [[nodiscard]] virtual TimerHandle every(std::chrono::milliseconds period, std::function fn) = 0; +}; +} +// include/morph/reactive/testing/manual_scheduler.hpp +namespace morph::reactive::testing { +class ManualScheduler final : public Scheduler { // deterministic: time advances only when told +public: + void advance(std::chrono::milliseconds by); // fires due timers in deadline order, on the calling thread + [[nodiscard]] std::size_t pendingTimers() const; +}; +} +// control.hpp addition — last constructor parameter of both Query constructors +namespace morph::reactive { +struct QueryOptions { + Scheduler* scheduler = nullptr; // required when refreshEvery > 0 + std::chrono::milliseconds refreshEvery{0}; // 0 = no timed refresh +}; +} +``` + +## Part 2 — `morph::ui` (`include/morph/ui/`) + +### `view.hpp` + +```cpp +namespace morph::ui { +using Action = std::function; +using Key = std::variant; +template class Prop; // implicit from a non-callable convertible to T (constant) or a callable + // returning one (binding); isBound(), constant(), binding(), evaluate() +enum class TextRole : std::uint8_t { Normal, Muted, Heading, Error, Success }; +enum class TextInputMode : std::uint8_t { SingleLine, Multiline, Password }; +enum class SelectStyle : std::uint8_t { Dropdown, Radio }; +enum class Axis : std::uint8_t { Vertical, Horizontal }; +enum class SelectionMode : std::uint8_t { None, Single, Multiple }; +enum class DateMode : std::uint8_t { Date, DateTime }; +enum class FilePickerMode : std::uint8_t { Open, Save }; +struct Sizing { enum class Kind : std::uint8_t { Content, Fixed, Stretch }; Kind kind; int amount; + static constexpr Sizing content(); static constexpr Sizing fixed(int units); + static constexpr Sizing stretch(int weight = 1); bool operator==(Sizing const&) const = default; }; +struct LayoutHints { Sizing width{}; Sizing height{}; bool operator==(LayoutHints const&) const = default; }; +struct Common { + Prop visible = true; + Prop enabled = true; + LayoutHints layout{}; + Prop> dragKey; // engaged: draggable, carrying the key + std::function accepts; // empty: accepts every key (when onDrop is set) + std::function onDrop; // set: a drop target +}; +struct NodeData; using Node = std::shared_ptr; +struct Text { Prop text; Prop role = TextRole::Normal; Common common{}; }; +struct Button { Prop label; Action onClick; Common common{}; }; +struct TextInput { Prop value; std::function onChange; + std::function onSubmit; Prop placeholder; + TextInputMode mode = TextInputMode::SingleLine; Common common{}; }; +struct Checkbox { Prop label; Prop checked; std::function onToggle; Common common{}; }; +struct SelectOption { Key key; std::string label; bool operator==(SelectOption const&) const = default; }; +struct Select { Prop> options; Prop> selected; + std::function onSelect; SelectStyle style = SelectStyle::Dropdown; Common common{}; }; +struct MenuItem { Prop label; Action onSelect; }; +struct Menu { std::vector items; Common common{}; }; +struct Column { std::vector children; int gap = 0; Common common{}; }; +struct Row { std::vector children; int gap = 0; Common common{}; }; +struct GridCell { Node node; int span = 1; }; +struct Grid { int columns = 1; std::vector cells; int gap = 0; Common common{}; }; +struct Spacer { Common common{}; }; +struct Panel { Prop title; int padding = 0; Node child; bool collapsible = false; + Prop collapsed = false; std::function onToggle; Common common{}; }; +struct Scroll { Node child; Axis axis = Axis::Vertical; Common common{}; }; +struct SwitchCase { Key key; Node node; }; +struct Switch { Prop selector; std::vector cases; Node fallback; Common common{}; }; +struct Tab { std::string label; Node node; }; +struct Tabs { std::vector tabs; Prop selected; std::function onSelect; Common common{}; }; +struct Dialog { Prop open; Prop title; Node child; Action onDismiss; Common common{}; }; +struct Busy { Prop active; Prop label; Common common{}; }; +namespace detail { class RowSlot; class ForEachSession; class ForEachModel; } // as Part 2's plan defines +struct ForEach { std::shared_ptr model; Axis axis = Axis::Vertical; int gap = 0; Common common{}; }; +struct TableColumn { std::string label; Sizing width = Sizing::content(); }; +struct Table { std::vector columns; std::shared_ptr rows; + SelectionMode selectionMode = SelectionMode::None; Prop> selection; + std::function)> onSelectionChange; std::function onActivate; + Common common{}; }; // each row view is a Row whose children are the cells +struct DateTimeInput { Prop> value; + std::function)> onChange; + DateMode mode = DateMode::DateTime; int offsetMinutes = 0; Common common{}; }; +struct Slider { Prop value; std::int64_t minimum = 0; std::int64_t maximum = 100; + std::int64_t step = 1; std::function onChange; Common common{}; }; +struct FilePicker { Prop path; FilePickerMode mode = FilePickerMode::Open; + std::function onPicked; Common common{}; }; +struct NodeData { std::variant kind; }; +// builders, one per kind: +Node text(Text); Node button(Button); Node textInput(TextInput); Node checkbox(Checkbox); Node select(Select); +Node menu(Menu); Node column(Column); Node row(Row); Node grid(Grid); Node spacer(Spacer = {}); Node panel(Panel); +Node scroll(Scroll); Node switchOf(Switch); Node tabs(Tabs); Node dialog(Dialog); Node busy(Busy); +Node dateTimeInput(DateTimeInput); Node slider(Slider); Node filePicker(FilePicker); +template requires std::is_enum_v +Node switchOn(std::function selector, std::vector> cases, Node fallback = {}); +template Node forEach(std::function()> rows, std::function keyOf, + std::function const&)> rowView, Axis axis = Axis::Vertical, int gap = 0); +template Node forEach(reactive::Signal> const& rows, + std::function keyOf, std::function const&)> rowView, + Axis axis = Axis::Vertical, int gap = 0); +struct TableOptions { SelectionMode selectionMode = SelectionMode::None; Prop> selection; + std::function)> onSelectionChange; std::function onActivate; + Common common{}; }; +template Node table(std::vector columns, std::function()> rows, + std::function keyOf, std::function(reactive::Signal const&)> cells, + TableOptions options = {}); +} +``` + +### `backend.hpp` + +```cpp +namespace morph::ui { +class Widget { public: virtual ~Widget() = default; + virtual void setVisible(bool) = 0; virtual void setEnabled(bool) = 0; virtual void setLayout(LayoutHints const&) = 0; + virtual void setDragKey(std::optional const&) = 0; + virtual void setDropHandler(std::function accepts, std::function onDrop) = 0; }; +class ContainerWidget : public Widget { public: virtual void moveChild(Widget& child, std::size_t index) = 0; }; +class TextWidget : public Widget { public: virtual void setText(std::string_view) = 0; virtual void setRole(TextRole) = 0; }; +class ButtonWidget : public Widget { public: virtual void setLabel(std::string_view) = 0; virtual void setOnClick(Action) = 0; }; +class TextInputWidget : public Widget { public: virtual void setText(std::string_view) = 0; // never fires onChange + virtual void setPlaceholder(std::string_view) = 0; virtual void setOnChange(std::function) = 0; + virtual void setOnSubmit(std::function) = 0; }; +class CheckboxWidget : public Widget { public: virtual void setLabel(std::string_view) = 0; virtual void setChecked(bool) = 0; + virtual void setOnToggle(std::function) = 0; }; +class SelectWidget : public Widget { public: virtual void setOptions(std::vector const&) = 0; + virtual void setSelected(std::optional const&) = 0; virtual void setOnSelect(std::function) = 0; }; +class MenuWidget : public Widget { public: virtual void setItems(std::vector const&) = 0; + virtual void setOnActivate(std::function) = 0; }; +class StackWidget : public ContainerWidget { public: virtual void setGap(int) = 0; }; +class GridWidget : public ContainerWidget { public: virtual void setColumns(int) = 0; virtual void setGap(int) = 0; + virtual void setSpan(Widget& child, int span) = 0; }; +class SpacerWidget : public Widget {}; +class PanelWidget : public ContainerWidget { public: virtual void setTitle(std::string_view) = 0; virtual void setPadding(int) = 0; + virtual void setCollapsible(bool) = 0; virtual void setCollapsed(bool) = 0; virtual void setOnToggle(std::function) = 0; }; +class ScrollWidget : public ContainerWidget {}; +class SlotWidget : public ContainerWidget {}; +class TabsWidget : public ContainerWidget { public: virtual void setTabs(std::vector const&) = 0; + virtual void setSelected(std::size_t) = 0; virtual void setOnSelect(std::function) = 0; }; +class DialogWidget : public ContainerWidget { public: virtual void setOpen(bool) = 0; virtual void setTitle(std::string_view) = 0; + virtual void setOnDismiss(Action) = 0; }; +class BusyWidget : public Widget { public: virtual void setActive(bool) = 0; virtual void setLabel(std::string_view) = 0; }; +class TableWidget : public ContainerWidget { public: virtual void setColumns(std::vector const&) = 0; + virtual void setSelectionMode(SelectionMode) = 0; virtual void setRowKey(Widget& row, Key const& key) = 0; + virtual void setSelection(std::vector const&) = 0; + virtual void setOnSelectionChange(std::function)>) = 0; + virtual void setOnActivate(std::function) = 0; }; +class DateTimeInputWidget : public Widget { public: virtual void setValue(std::optional const&) = 0; + virtual void setOnChange(std::function)>) = 0; }; +class SliderWidget : public Widget { public: virtual void setRange(std::int64_t minimum, std::int64_t maximum, std::int64_t step) = 0; + virtual void setValue(std::int64_t) = 0; virtual void setOnChange(std::function) = 0; }; +class FilePickerWidget : public Widget { public: virtual void setPath(std::string_view) = 0; + virtual void setOnPicked(std::function) = 0; }; +class IViewBackend { public: virtual ~IViewBackend() = default; // each factory appends to parent (null: the root) + virtual std::unique_ptr createText(ContainerWidget* parent) = 0; + virtual std::unique_ptr createButton(ContainerWidget* parent) = 0; + virtual std::unique_ptr createTextInput(ContainerWidget* parent, TextInputMode mode) = 0; + virtual std::unique_ptr createCheckbox(ContainerWidget* parent) = 0; + virtual std::unique_ptr createSelect(ContainerWidget* parent, SelectStyle style) = 0; + virtual std::unique_ptr createMenu(ContainerWidget* parent) = 0; + virtual std::unique_ptr createStack(ContainerWidget* parent, Axis axis) = 0; + virtual std::unique_ptr createGrid(ContainerWidget* parent) = 0; + virtual std::unique_ptr createSpacer(ContainerWidget* parent) = 0; + virtual std::unique_ptr createPanel(ContainerWidget* parent) = 0; + virtual std::unique_ptr createScroll(ContainerWidget* parent, Axis axis) = 0; + virtual std::unique_ptr createSlot(ContainerWidget* parent) = 0; + virtual std::unique_ptr createTabs(ContainerWidget* parent) = 0; + virtual std::unique_ptr createDialog(ContainerWidget* parent) = 0; + virtual std::unique_ptr createBusy(ContainerWidget* parent) = 0; + virtual std::unique_ptr createTable(ContainerWidget* parent) = 0; + virtual std::unique_ptr createDateTimeInput(ContainerWidget* parent, DateMode mode, int offsetMinutes) = 0; + virtual std::unique_ptr createSlider(ContainerWidget* parent) = 0; + virtual std::unique_ptr createFilePicker(ContainerWidget* parent, FilePickerMode mode) = 0; }; +} +``` + +### `mount.hpp`, `frontend.hpp` + +```cpp +namespace morph::ui { +namespace detail::site { inline constexpr char const* kDuplicateKey = "morph::ui: duplicate ForEach key"; } +class Mounted { // non-copyable, non-movable +public: + Mounted(reactive::Runtime& runtime, IViewBackend& backend, Node root, ContainerWidget* parent = nullptr); + [[nodiscard]] Widget& root() const noexcept; +}; +using Scheduler = reactive::Scheduler; +using TimerHandle = reactive::TimerHandle; +class AppContext { public: virtual ~AppContext() = default; + virtual reactive::Runtime& runtime() = 0; virtual exec::IExecutor& executor() = 0; + virtual Scheduler& scheduler() = 0; virtual exec::IoLoop* ioLoop() = 0; // null when the frontend has none + virtual void quit(int exitCode = 0) = 0; [[nodiscard]] virtual std::string_view frontendName() const = 0; }; +class Application { public: virtual ~Application() = default; [[nodiscard]] virtual Node view() = 0; }; +using ApplicationFactory = std::function(AppContext&)>; +class Frontend { public: virtual ~Frontend() = default; [[nodiscard]] virtual std::string_view name() const = 0; + virtual int run(ApplicationFactory const& factory) = 0; }; +struct FrontendOption { std::string name; std::function usable; std::function()> make; }; +class FrontendSelectionError : public std::runtime_error { public: using std::runtime_error::runtime_error; }; +using EnvironmentReader = std::function(std::string_view name)>; +[[nodiscard]] EnvironmentReader processEnvironment(); +// --ui=, else MORPH_UI, else the first usable option in the given order; throws FrontendSelectionError +[[nodiscard]] std::unique_ptr selectFrontend(std::span built, int argc, + char const* const* argv, EnvironmentReader const& env = processEnvironment()); +} +``` + +### `testing/recording_backend.hpp`, `testing/backend_conformance.hpp` + +```cpp +namespace morph::ui::testing { +class RecordingBackend final : public IViewBackend { +public: + [[nodiscard]] std::string dump() const; // golden tree: "Kind#id prop=value ..." indented + [[nodiscard]] std::vector const& log() const; // "create Kind#id in Parent#id", "set Kind#id prop=v", + void clearLog(); // "move Kind#id to n", "destroy Kind#id" + [[nodiscard]] std::optional find(std::string_view kind, std::string_view prop, std::string_view value) const; + [[nodiscard]] std::vector all(std::string_view kind) const; + [[nodiscard]] std::string prop(int id, std::string_view name) const; + [[nodiscard]] bool exists(int id) const; + void click(int id); void edit(int id, std::string text); void submit(int id, std::string text); + void toggle(int id); void choose(int id, Key key); void chooseIndex(int id, std::size_t index); + void collapse(int id, bool collapsed); void dismiss(int id); + void selectRows(int id, std::vector keys); void activateRow(int id, Key key); + void setDateTime(int id, std::optional value); void slide(int id, std::int64_t value); + void pick(int id, std::string path); + bool drag(int sourceId, int targetId); // true when the target accepted and onDrop ran +}; +class ConformanceProbe { // one per backend under test +public: + virtual ~ConformanceProbe() = default; + virtual IViewBackend& backend() = 0; + virtual reactive::Runtime& runtime() = 0; + virtual void settle() = 0; // run posted flushes and native events until idle + [[nodiscard]] virtual std::string textOf(Widget const& w) = 0; + [[nodiscard]] virtual bool visibleOf(Widget const& w) = 0; + [[nodiscard]] virtual bool enabledOf(Widget const& w) = 0; + [[nodiscard]] virtual std::size_t childCount(ContainerWidget const& c) = 0; + [[nodiscard]] virtual Widget const* childAt(ContainerWidget const& c, std::size_t index) = 0; + virtual void click(Widget& w) = 0; + virtual void type(Widget& w, std::string_view text) = 0; + virtual void drag(Widget& source, Widget& target) = 0; +}; +struct ConformanceCase { std::string_view name; std::function(ConformanceProbe&)> run; }; +[[nodiscard]] std::span conformanceCases(); // nullopt = pass, else the failure message +} +``` + +## Part 3 — `IoLoopDriver::Caller` and `morph::tui` + +```cpp +namespace morph::exec { +enum class IoLoopDriver : std::uint8_t { OwnThread, Caller }; +// IoLoop: explicit IoLoop(IoLoopDriver driver = IoLoopDriver::OwnThread); [[nodiscard]] IoLoopDriver driver() const noexcept; +} +namespace morph::tui { +class LoopExecutor final : public exec::IExecutor { public: explicit LoopExecutor(::core::net::EventLoop& loop); }; +struct FrontendConfig { + ::core::tui::Terminal* terminal = nullptr; // null: the process terminal + ::core::tui::runtime::InputSource* input = nullptr; // null: the terminal's input + ::core::tui::MouseTracking mouse = ::core::tui::MouseTracking::Drag; +}; +inline constexpr int kInterruptExitCode = 130; // Frontend::run's result when Ctrl+C ended it +class Frontend final : public ui::Frontend { public: explicit Frontend(FrontendConfig config = {}); }; +[[nodiscard]] ui::FrontendOption frontendOption(FrontendConfig config = {}); // name "tui"; usable: stdin is a tty +class Backend final : public ui::IViewBackend { public: explicit Backend(::core::tui::Screen& screen); }; +} +``` + +CMake: option `MORPH_BUILD_TUI`, target `morph_tui` / `morph::tui`, install component `tui`. + +## Part 4 — `morph::qt_quick` + +```cpp +namespace morph::qt_quick { +class Frontend final : public ui::Frontend { public: Frontend(int& argc, char** argv); }; +[[nodiscard]] ui::FrontendOption frontendOption(int& argc, char** argv, + ui::EnvironmentReader env = ui::processEnvironment()); // name "qt" +class Backend final : public ui::IViewBackend { public: Backend(QQmlEngine& engine, QQuickItem& root); }; +} +``` + +CMake: option `MORPH_BUILD_QT_QUICK` (requires `MORPH_BUILD_QT`), target `morph_qt_quick` / `morph::qt_quick`, +install component `qt_quick`, private QML module URI `MorphUi`. + +## Part 5 — the forms engine (`include/morph/forms/engine/`; `parseDecimal` in `include/morph/util/rational.hpp`) + +```cpp +namespace morph::math { +[[nodiscard]] std::optional parseDecimal(std::string_view text, render::NumericLocale const& locale, + std::optional maxDecimalPlaces = std::nullopt); +} +namespace morph::forms { +enum class FieldKind : std::uint8_t { Text, Multiline, Integer, Number, Decimal, Quantity, Boolean, Enum, Choice, + DateTime, Date, Slider, Array, Object, ObjectArray }; +enum class SubmitMode : std::uint8_t { Automatic, Explicit }; +enum class Tri : std::uint8_t { False, True, Unknown }; +struct SchemaError { std::string path; std::string message; }; +struct FieldSpec; // as spec 2 §2 lists; children for Object/ObjectArray +struct FieldGroupSpec; // title, kind (GroupKind), field names +class RuleExpr; // parsed x-rules +class FormModel { public: + [[nodiscard]] static std::expected fromSchema(std::string_view actionType, std::string_view schemaJson); + [[nodiscard]] std::string_view actionType() const noexcept; [[nodiscard]] std::string_view title() const noexcept; + [[nodiscard]] std::span fields() const noexcept; [[nodiscard]] std::span groups() const noexcept; + [[nodiscard]] std::span rules() const noexcept; [[nodiscard]] SubmitMode submitMode() const noexcept; }; +// FormModel also has: template [[nodiscard]] static FormModel forAction(); +// = fromSchema(ActionTraits::typeId(), schemaJson()), throwing std::logic_error naming the schema path when +// the engine cannot read the action's own schema (a programming error, not input). +using Submitter = std::function(std::string_view actionType, std::string bodyJson)>; +using ChoiceFetcher = std::function(std::string_view optionsAction, std::string bodyJson)>; +struct FormSessionOptions { render::NumericLocale locale{}; std::string bcp47 = "en"; render::TranslationProvider translations; + int displayOffsetMinutes = 0; }; +class FieldState; // text, unit, engaged (Signals); encoded, error, visible, readonly, required (Computeds); options (Choice) +class FormSession { public: + FormSession(reactive::Runtime& runtime, FormModel model, Submitter submit, ChoiceFetcher choices, FormSessionOptions options = {}); + [[nodiscard]] FormModel const& model() const noexcept; [[nodiscard]] FieldState& field(std::string_view path); + [[nodiscard]] bool ready() const; [[nodiscard]] std::optional body() const; + void submit(); void prefill(std::string_view bodyJson); void reset(); + [[nodiscard]] bool pending() const; [[nodiscard]] std::optional const& lastReply() const; + [[nodiscard]] std::exception_ptr lastError() const; }; +template class Form { public: + Form(reactive::Runtime& runtime, bridge::BridgeHandler& handler, ChoiceFetcher choices, FormSessionOptions options = {}); + [[nodiscard]] FormSession& session() noexcept; + template void set(/* the member's type */ auto value); + [[nodiscard]] std::optional value() const; [[nodiscard]] bool ready() const; + [[nodiscard]] std::optional::Result> const& lastResult() const; }; +[[nodiscard]] Submitter bridgeSubmitter(bridge::Bridge& bridge, exec::IExecutor& callbacks); +[[nodiscard]] ChoiceFetcher bridgeChoiceFetcher(bridge::Bridge& bridge, exec::IExecutor& callbacks); +// handler_submitter.hpp: route through the application's own handlers (in-memory or shared instances) +template [[nodiscard]] Submitter handlerSubmitter(exec::IExecutor& callbacks, Handlers&... handlers); +template [[nodiscard]] ChoiceFetcher handlerChoiceFetcher(exec::IExecutor& callbacks, Handlers&... handlers); +class FieldView; // a field's spec and state, handed to an override +class Overrides { public: + using Render = std::function; + Overrides& byField(std::string path, Render); Overrides& byWidget(std::string hint, Render); + Overrides& byUnit(std::string unit, Render); Overrides& byKind(FieldKind kind, Render); }; +struct FormViewOptions { Overrides overrides; int gridColumns = 2; std::string submitLabel = "Submit"; }; +[[nodiscard]] ui::Node formView(FormSession& session, FormViewOptions options = {}); +class CollectionModel; class CollectionSession; struct CollectionViewOptions; +[[nodiscard]] ui::Node collectionView(CollectionSession& session, CollectionViewOptions options = {}); +class WizardModel; class WizardSession; +[[nodiscard]] ui::Node wizardView(WizardSession& session); +class AppShellModel; class AppShellSession; +[[nodiscard]] ui::Node appShellView(AppShellSession& session); +} +namespace morph::app { template struct ViewScreen; } // kind "view" in app.hpp +``` + +Part 5's plan gives the full declarations of the forward-declared classes; parts 8–10 use only what it lists. + +## Part 6 — examples/common (Qt-free; target `morph_ladder_app_common`, headers `examples/common/app/`) + +```cpp +namespace morph::examples { +struct AppEnvironment { std::optional server; std::string db; std::string user; bool seed = false; + std::optional pollId; // polls' ?poll= (WASM) / --poll (native) + [[nodiscard]] static AppEnvironment fromArgs(int argc, char const* const* argv); }; +class Connection { public: // owns the pool (local) and the Bridge + [[nodiscard]] bridge::Bridge& bridge() noexcept; [[nodiscard]] exec::IExecutor& callbacks() noexcept; }; +struct LocalSetup { std::function setupDatabase; std::size_t workers = 4; }; +class TransportError : public std::runtime_error { public: using std::runtime_error::runtime_error; }; +[[nodiscard]] std::unique_ptr connect(ui::AppContext& ctx, AppEnvironment const& env, LocalSetup local); +[[nodiscard]] std::string newUuid(); // RFC 4122 v4, lower-case +struct Wiring { // examples/common/app/wiring.hpp; all four borrowed + reactive::Runtime& runtime; reactive::Scheduler& scheduler; bridge::Bridge& bridge; exec::IExecutor& callbacks; }; +// examples/common/app/completion_map.hpp: derive a completion on the owner, gated by token; a throw from onValue fails +// the derived completion; onError observes a failure before it is passed on. +template +[[nodiscard]] async::Completion mapCompletion(exec::IExecutor& owner, async::CallbackToken token, + async::Completion from, OnValue onValue, OnError onError); +template class Poller; // "events since cursor" over a Query with refreshEvery +} +namespace morph::examples::testing { +template bool pumpUntil(exec::MainThreadExecutor& owner, Pred done, + std::chrono::milliseconds budget = std::chrono::milliseconds{2000}); +enum class SmokeFrontend : std::uint8_t { Tui, QtQuick }; +void runFrontendSmoke(ui::ApplicationFactory const& factory, SmokeFrontend frontend); // mounts, quits, asserts exit 0 +} +``` + +`examples/common/app/` also holds the remote transport implementations: `QtWebSocketBackend` when the frontend is +`"qt"` (compiled only when `MORPH_BUILD_QT`), `morph::net::SocketBackend` on `ctx.ioLoop()` when it is `"tui"` +(compiled only when `MORPH_BUILD_NET` on POSIX); `connect` throws `TransportError` when `--server` is given and +neither applies. + +## Conventions for every example application (Parts 6–10) + +- Client code lives in `::client` (bank: `bank::client`, because `bank::app` is the domain library's `App`). +- Each app exposes `[[nodiscard]] std::unique_ptr ::client::makeApplication(ui::AppContext& ctx, + examples::AppEnvironment const& env)`; app-specific knobs come from `env` or are defaulted, so every `ui/main.cpp` + has the shape of spec 4 §3. +- View tests use `RecordingBackend`'s spellings exactly as Part 2 defines them (e.g. stacks log as `Column`/`Row`). diff --git a/docs/superpowers/plans/2026-10-04-declarative-ui-tui.md b/docs/superpowers/plans/2026-10-04-declarative-ui-tui.md new file mode 100644 index 000000000..da4ada755 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-declarative-ui-tui.md @@ -0,0 +1,156 @@ +# Declarative UI Program — Master Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement the part plans task-by-task. Read this file first, then +> `2026-10-04-declarative-ui-tui-interfaces.md`: together they fix the branch, the commit layout, the conventions +> every part follows, and the names parts share. + +**Goal:** Every example application under `examples/` describes its UI once — declarative controllers plus a +toolkit-free view tree — and one binary per app picks a terminal UI or Qt Quick at runtime by dependency injection; +the shipped QML forms renderer is replaced by a C++ forms engine. Delivered on **one branch, as one pull request, +with one commit per semantic unit**. + +**Specs** (`docs/superpowers/specs/`): `2026-10-04-core-cpp-mouse-design.md` (spec 0), +`2026-10-04-declarative-ui-tui-design.md` (spec 1), `2026-10-04-forms-engine-design.md` (spec 2), +`2026-10-04-qtquick-frontend-design.md` (spec 3), `2026-10-04-examples-migration-design.md` (spec 4). + +## The parts + +Execute in this order; each part assumes every earlier part has landed on the branch. + +| Part | Plan (`docs/superpowers/plans/2026-10-04-declarative-ui-tui-…`) | Spec | `wip` key(s) | Ends as commit(s) | +|---|---|---|---|---| +| 0 | `0-core-cpp.md` | 0 | (core-cpp repo), `corecpp` | `core: build against core-cpp 0.7` | +| 1 | `1-reactive.md` | 1 §3–4b | `reactive` | `reactive: signal graph, view state and declarative control` | +| 2 | `2-ui.md` | 1 §5–5b | `ui` | `ui: toolkit-agnostic view tree, mount, frontend seam and RecordingBackend` | +| 3 | `3-tui.md` | 1 §2, §6 | `ioloop`, then `tui` | `core: IoLoopDriver::Caller, the I/O loop on the calling thread`, then `tui: morph::tui terminal frontend` | +| 4 | `4-qt-quick.md` | 3 | `qtquick` | `qt_quick: morph::qt_quick frontend` | +| 5 | `5-forms-engine.md` | 2 | `forms` | `forms: the C++ forms engine, rendered through the view tree` | +| 6 | `6-examples-foundation.md` | 4 §2–4, 1 §8 | `examples-common` | `examples/common: Qt-free app environment, transport, poller and test waits; gallery and workout` | +| 7 | `7-examples-bank.md` | 4 §5 | `bank` | `examples/bank: one app, any frontend` | +| 8 | `8-examples-ladder-a.md` | 4 §5 | `pastebin`, `bookmarks`, `polls` | one commit per app | +| 9 | `9-examples-ladder-b.md` | 4 §5 | `kanban`, `ledger`, `lims` | one commit per app | +| 10 | `10-examples-forms-and-retire.md` | 4 §5, §8; 2 §11 | `forms-demo`, `retire` | `examples/forms: …`, then `forms, examples: retire the QML renderer and the Qt client stack` | + +## The pull request's history + +Branch `feature/declarative-ui`, cut from `master`. When every part has landed, `git log --oneline master..HEAD` +shows exactly, in order: + +1. `docs: declarative UI program — design and implementation plans` +2. `core: build against core-cpp 0.7` +3. `reactive: …` +4. `ui: …` +5. `core: IoLoopDriver::Caller …` +6. `tui: …` +7. `qt_quick: …` +8. `forms: …` +9. `examples/common: …` +10. `examples/bank: …` +11. `examples/pastebin: …` +12. `examples/bookmarks: …` +13. `examples/polls: …` +14. `examples/kanban: …` +15. `examples/ledger: …` +16. `examples/lims: …` +17. `examples/forms: …` +18. `forms, examples: retire the QML renderer and the Qt client stack` + +Each commit builds, passes its tests and carries its own `docs/spec/` files, map entries, `CHANGELOG.md` lines and +pinned facts. Example-only commits get no CHANGELOG entry (CONTRIBUTING), except where they add a framework seam; +commit 18 gets a "Removed" entry. + +## Conventions every part follows + +### `wip` commits, one per task + +Every task ends with a commit whose subject starts `wip(): `, `` from the table. They exist for review (a +reviewer reads `git show` of one task) and for bisecting inside a part; they never survive the part. + +### Squashing a part + +The last task of every part — and of each group in parts 3, 8 and 9 — runs: + +```bash +key=reactive # the group's key +pre=$(git rev-parse HEAD) +first=$(git log --reverse --format=%H --grep="^wip($key):" master..HEAD | head -1) +test -n "$first" || { echo "no wip($key) commits -- wrong key?"; exit 1; } +git log --format=%s "$first^..HEAD" | grep -v "^wip($key): " && { echo "foreign commit inside the part"; exit 1; } +git reset --soft "$first^" +git commit -F /path/to/message.txt # the message the part plan gives +git diff --exit-code "$pre" HEAD && echo "squash preserved the tree" +git log --oneline master..HEAD +``` + +`git diff --exit-code` proves the squash changed history and nothing else; the last line must show the expected +list so far. + +### The docs commit + +Before execution starts, the spec and plan commits already on the branch are squashed into commit 1 with the same +procedure over `master..HEAD` (all of them are `docs:` commits). Plans are not edited during execution: when a plan +turns out wrong, the code and its `docs/spec/` file are made right and the deviation is stated in the body of that +part's squashed commit — what the plan said, what was done instead, and why. AGENTS.md's filing rule applies to a +defect found in `include/morph` or `src` outside a part's scope. + +### Before execution starts: file the morph finding — ask the user first + +Planning found, by reading only, a defect in `include/morph` outside every part's scope; AGENTS.md files it. Confirm +with the user, then: + +```bash +cd /Users/christianparpart/projects/morph +sed -n '/static ::core::async::Task acceptFlow/,/^ }$/p' include/morph/net/socket_server.hpp +git -C ~/projects/core-cpp show v0.5.1:CHANGELOG.md | sed -n '/A POSIX accept that failed/,/accepted again at once/p' +``` + +and `gh issue create --repo LASTRADA-Software/morph --title "net: SocketServer's accept flow retries a dead listener +forever" --label bug --label "area: net"` with a body that states: **what** — `SocketServer::Core::acceptFlow` +returns only on `NetErrorCode::Cancelled` and retries every other failed `accept` after 50 ms; since core-cpp 0.5.1 +(`EINVAL` from accept → `BadHandle`) a listener that is no longer listening reports `BadHandle` on every attempt, +so the flow wakes every 50 ms forever instead of ending; **evidence** — the two outputs above, pasted; **verification +status** — inferred from reading the code, not reproduced; observed in no test or deployment (weak evidence, labelled +as such); **what would close it** — the flow ends on `BadHandle` (and the server reports the listener as dead), with a +test that closes the listening socket underneath it and asserts the flow exits; reopen if a `BadHandle` is found that +a retry can recover from. Record the issue number for the PR body. + +### Outward-facing steps are confirmed first + +Pushing core-cpp's branch, merging it, tagging `v0.7.0`, filing the core-cpp Shift+Tab issue, pushing this branch +and opening the pull request are each confirmed with the user immediately before they are taken. + +### Commit message trailer + +Every commit ends with `Signed-off-by: Christian Parpart `. + +## Build configurations used by the plans + +| Directory | Configure | +|---|---| +| `build/reactive` | `cmake -S . -B build/reactive -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_EXAMPLES=OFF` | +| `build/tui` | `build/reactive`'s flags plus `-DMORPH_BUILD_TUI=ON` | +| `build/qt` | `build/reactive`'s flags plus `-DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON` | +| `build/all` | `-DCMAKE_BUILD_TYPE=Debug -DMORPH_BUILD_TUI=ON -DMORPH_BUILD_QT=ON -DMORPH_BUILD_QT_QUICK=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_NET=ON` (plus `-DMORPH_BUILD_FORMS_QML=ON` until part 10 removes it) | + +Configuring prints `morph: warnings: ... strict=ON`; if it does not, stop and say so (CONTRIBUTING). + +## Finishing the branch + +After part 10's squash: + +- [ ] **Whole-branch gates** on `build/all` and on Linux CI's configurations: build; `ctest`; the sanitizer + presets with `scripts/check_sanitizer_instrumentation.sh`; the Docs build; clang-tidy-diff against + `origin/master...HEAD` with the file count asserted; `scripts/check_install_export.sh`. Then a build with + `MORPH_BUILD_TUI=OFF MORPH_BUILD_QT_QUICK=OFF`, proving both frontends are optional and the app libraries and + their tests still build. +- [ ] **WebAssembly:** the `wasm-ladder.yml` and `wasm-demo.yml` configurations build. +- [ ] **Every commit builds:** `git rebase --exec "cmake --build build/all && ctest --test-dir build/all" master` + (no `-i`). A failure names the commit that does not stand alone; fix it in that commit. +- [ ] **History check:** `git log --oneline master..HEAD` shows exactly the 18 commits above. +- [ ] **Push and open the PR — ask the user first.** Then `git push -u origin feature/declarative-ui` and + `gh pr create`, the body listing the commits, linking the specs, and stating what was verified on which + configurations. +- [ ] **Delete core-cpp's abandoned `feature/reactive-ui` — ask the user first.** Then + `git -C ~/projects/core-cpp branch -D feature/reactive-ui` and + `git -C ~/projects/core-cpp push origin --delete feature/reactive-ui`. diff --git a/docs/superpowers/specs/2026-10-04-core-cpp-mouse-design.md b/docs/superpowers/specs/2026-10-04-core-cpp-mouse-design.md new file mode 100644 index 000000000..e9f78a7a8 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-core-cpp-mouse-design.md @@ -0,0 +1,102 @@ +# core-cpp: standard mouse tracking and pointer capture — design (spec 0) + +core-cpp's `core::tui` decodes SGR mouse reports, including motion with a button held, but never +asks a standard terminal to send them: the only mouse mode it enables is Contour's private passive +mode 2029. xterm, kitty, WezTerm, iTerm2 and Windows Terminal therefore report no mouse events at +all. Its `Screen` also hit-tests every mouse event, so a drag that leaves the component it started +on loses its moves and its release. This spec closes both gaps so a `core::tui` application can +implement drag-and-drop, and morph's TUI frontend can let kanban's board move tasks by dragging. + +The work lands in the core-cpp repository as release **0.7.0**, which is the version morph pins. + +## Contents + +[1 Mouse tracking modes](#1-mouse-tracking-modes) · [2 Pointer capture](#2-pointer-capture) · +[3 Hit testing](#3-hit-testing) · [4 Tests](#4-tests) · [5 Release and pin](#5-release-and-pin) · +[6 Out of scope](#6-out-of-scope) + +## 1. Mouse tracking modes + +```cpp +namespace core::tui { +/// How much mouse input the terminal is asked to report. +enum class MouseTracking : std::uint8_t { + Off, ///< No reports (default). + Buttons, ///< Press and release (DEC 1000). + Drag, ///< Press, release and motion while a button is held (DEC 1002). + AnyMotion, ///< Press, release and all motion (DEC 1003). +}; +} +``` + +- **Opt-in, default `Off`.** Mouse reporting changes what a click-drag does in the user's terminal + (it stops selecting text; most terminals keep Shift+drag for selection), so no existing + `core::tui` program changes behaviour. An application asks for a mode. +- **API.** `TerminalInput::setMouseTracking(MouseTracking)` and `Terminal::setMouseTracking(…)` + (forwarding), effective at the next `enableProtocols()` — before `initialize()` for the common + case, and immediately when the protocols are already enabled. +- **Sequences.** For any mode but `Off`, `enableProtocols()` writes the mode's DEC private set + (`CSI ? 1000 h`, `1002 h` or `1003 h`) followed by SGR encoding `CSI ? 1006 h`; + `disableProtocols()` writes the matching resets in reverse order. Contour's passive mode 2029 is + still enabled as today; on Contour it carries the `uiHandled` flag and is harmless beside the + standard modes. +- **The existing hover path.** Today 1003 is enabled when the terminal reports mode 2029 as + changeable, for hover tooltips. That stays, and is expressed as "the requested mode is raised to + `AnyMotion`". +- **Windows.** The console input mode already includes `ENABLE_VIRTUAL_TERMINAL_INPUT`, under + which Windows Terminal delivers VT mouse reports; the same sequences apply. + +The parser needs no change: `CSI < b ; x ; y M` with bit 32 already becomes +`MouseEvent{Move, button = b & 3}` (button 3 means "none held", which only `AnyMotion` produces). + +## 2. Pointer capture + +`Screen::dispatchMouseEvent` gains implicit capture: + +- A `Press` delivered to component `C` (after bubbling, the component that returned non-`Ignored`) + makes `C` the **capture target**. +- While a capture target exists, every `Move` and the next `Release` go to it directly — no hit + test, no bubbling — with coordinates relative to `C`'s screen bounds, so they may be negative or + beyond its size. +- The `Release` ends the capture. So does the capture target's destruction or removal from the + tree (the component's teardown clears it) and `Screen::releasePointer()`. +- Scroll events are never captured. + +Without capture, a drag's motion lands on whatever is under the pointer, so a component cannot +follow its own drag; with it, the component that started the drag sees the whole gesture. + +## 3. Hit testing + +`Component* Screen::componentAt(int row, int col) const` becomes public: viewport-relative, +0-based, returning the top-most visible component at that cell — overlays first (last shown wins), +then the main tree in reverse z-order, deepest descendant first — or null. A drag source asks it +which component lies under the pointer when the button is released, to find the drop target. + +## 4. Tests + +In core-cpp's Catch2 suites: + +- **Parser:** `CSI <32;10;5M` → `Move`, button 0, x 10, y 5; `CSI <35;…M` → `Move`, button 3. +- **Protocols:** for each `MouseTracking` value, the bytes `enableProtocols()` and + `disableProtocols()` write (through a mock output), including `Off` writing no 1000/1002/1003. +- **Capture:** press on component A, move outside A, release elsewhere — A receives all three with + A-relative coordinates, B under the pointer receives nothing; destroying A mid-drag clears the + capture and the next move hit-tests normally; scroll is not captured. +- **`componentAt`:** overlay above tree, z-order, nested child, empty cell → null. + +## 5. Release and pin + +- core-cpp: CHANGELOG entry, version 0.7.0, tag `v0.7.0`. Pushing the branch, merging and tagging + in core-cpp are outward-facing steps, each confirmed with the user before it is taken. +- morph: `MORPH_CORE_CPP_VERSION 0.7`, `GIT_TAG v0.7.0` in the root `CMakeLists.txt`; the config + template follows (`find_dependency(core-cpp 0.7 CONFIG)`). The plan reviews core-cpp's 0.6.0 and + 0.7.0 changelogs for anything morph's existing code depends on and fixes it in the same commit. +- An issue is filed against core-cpp for decoding legacy Shift+Tab (`CSI Z`), which no release + decodes; until then Shift+Tab reaches an application only on terminals speaking the Kitty + keyboard protocol. Filing it is confirmed with the user first. + +## 6. Out of scope + +- Drag-and-drop semantics (payloads, drop targets, drag visuals) — those are morph's TUI frontend + (spec 1 §6) built on capture and `componentAt`. +- Mouse cursor shape changes, pixel-precise (SGR-Pixels, 1016) reporting, multi-button chords. 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 new file mode 100644 index 000000000..92e6f4922 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-declarative-ui-tui-design.md @@ -0,0 +1,450 @@ +# Declarative UI in morph: the reactive core, the view tree and the frontends — design (spec 1) + +A morph application's screens are defined on the server as UI documents (spec 5) and rendered by a +generic client. This spec defines the client's foundations: **`morph::reactive`**, the reactive core +that evaluates a document — signals, derived values, effects, and declarative server interaction +through `Query`, `Mutation` and `Subscription`; **`morph::ui`**, the view tree a document mounts into, +the backend contract every renderer implements, and the frontend seam that picks a renderer at +runtime; and **`morph::tui`**, the terminal renderer, which renders what a terminal can. + +This is spec 1 of a program delivered as a series of pull requests (§1, "The program"). + +## Contents + +[1 Intent](#1-intent) · [2 Packaging](#2-packaging-and-repository-boundaries) · +[3 Reactive core](#3-the-reactive-core-morphreactive) · [4 Store](#4-store-and-async-morphreactive) · +[4b Control](#4b-declarative-control-query-mutation-subscription) · +[5 View tree](#5-the-view-tree-and-mount-morphui) · [5b Frontend seam](#5b-the-frontend-seam-uifrontendhpp) · +[6 TUI](#6-the-terminal-renderer-morphtui-morph_build_tui) · [7 Tests](#7-tests-catch2) · +[8 Examples](#8-examples) · [9 Docs](#9-docs) · [10 Risks](#10-risks) + +## 1. Intent + +**Goals:** + +- morph is the framework for future desktop and web applications. +- A screen is defined once, on the server, as data (spec 5), and rendered by a generic client. +- **Server interaction is declarative.** The list/detail shape — a list, a selection, a detail that + reloads when the selection changes, `busy`, `lastError`, writes that refresh what they changed — + is the majority pattern among morph's QML-facing classes (12 of 19, measured in + `docs/analysis/qml-bridge-boilerplate.md`). It is written imperatively today, and that imperative + sequencing is where its defects live (§4b). The reactive core makes it declarative, and the + document declares it as data. +- Qt Quick is the primary renderer, native and WebAssembly (spec 3). The terminal renderer is best + effort. + +**Design decisions:** + +- Fine-grained signals, not re-render plus diff. State and control live in **`morph::reactive`**, + the view tree in **`morph::ui`**. An exception escaping an Effect is a defect. +- Server interaction is `Query`, `Mutation` and `Subscription` nodes (§4b), not callbacks sequenced + by hand. +- **The view tree is built from a document.** Its bindings are the interpreter's reactive values, + each with a stable slot id, so a renderer can either set widget properties (the terminal) or + generate code that reads the values (Qt Quick, spec 3). +- **The frontend is injected** (§5b): `main` picks a renderer at runtime, and nothing above the seam + names one. + +**Success for this spec:** + +- The reactive core passes the requirement tests below, and a document from spec 5's corpus runs on + it headless. +- The same view tree mounts on a headless `RecordingBackend`, which fixes the contract every + renderer implements; a backend-conformance suite runs the same scripted cases against each. +- `ui::selectFrontend` picks a renderer from `--ui=`, `MORPH_UI` or the environment. + +### The program + +| Spec | File (`docs/superpowers/specs/…`) | Delivers | +|---|---|---| +| 0 | `2026-10-04-core-cpp-mouse-design.md` | Standard VT mouse tracking and pointer capture, released as core-cpp 0.7.0; morph's pin moves to 0.7 | +| 1 | `2026-10-04-declarative-ui-tui-design.md` (this) | `morph::reactive`, `morph::ui` with the frontend seam, `IoLoopDriver::Caller`, `morph::tui` | +| 2 | `2026-10-04-forms-engine-design.md` | The C++ forms engine: schema forms, collections, wizards and the app shell as document nodes | +| 3 | `2026-10-04-qtquick-frontend-design.md` | `morph::qt_quick`: the QML generator and renderer, native and WebAssembly | +| 4 | `2026-10-04-examples-migration-design.md` | Every example's screens defined on its server, rendered by the generic client | +| 5 | `2026-10-07-ui-document-design.md` | The UI document: structure, expressions, control, relations, server-calculated values, components, delivery | +| 6 | `2026-10-08-app-hosting-design.md` | Hosting an application: screen lifecycle, the host contract, the app root scope and session, the design package, the workspace shell, application-scope preferences, offline as a backend switch | +| 7 | `2026-10-08-table-engine-design.md` | The table engine: a table declared from a model's list action, columns from the row type, sorting, filtering, selection, paging and cell editing | + +Each layer lands in its own pull request, in this order: the bridge seams for model-free dispatch +(spec 5 §2); the reactive core and view tree; the table engine (spec 7), which the renderer's list +model consumes; the document and its interpreter; the Qt Quick renderer; the forms engine; the +hosting layer (spec 6: lifecycle, `ScreenHost`, the app root scope, the design package, the workspace +shell); the generic client with delivery; the examples, a few per pull request; the terminal +renderer whenever it is ready. The shipped QML forms renderer stays until the examples no longer use +it. + +### Requirements the reactive core meets + +core-cpp's `core::rx` / `core::mvc` prototype is not reused. Each requirement below is one it did +not meet, and each has a test in §7. + +- **Graph:** glitch-free (an Effect reading `A` and `Computed(A)` never sees a stale Computed); + unsubscribe on destruction in both directions; dependencies pruned when a run stops reading them; + N writes in a batch are one flush; equality skip; cycle guard; a throw always restores the + tracking context and leaves every observer able to run again; nodes are non-copyable and + non-movable; `Computed` needs no default constructor; no Effect runs synchronously inside + `set()`, so writes are safe from Qt handlers and `Completion` callbacks. +- **Ownership order:** an effect owned by a scope runs after the effects of the scopes that own it, + so a row binding never evaluates against a parent that is about to remove it. +- **Messages:** exhaustive at compile time; `send()` inside an update is refused. + +## 2. Packaging and repository boundaries + +**`morph::reactive`** (`include/morph/reactive/`) + +- Header-only, in the base **`morph`** target's FILE_SET; depends on the standard library and + `morph/core` (executor, completion, callback_scope, bridge, owner probe). +- Headers: `runtime.hpp`, `signal.hpp` (Signal, Computed, Effect), `scope.hpp`, `store.hpp` + (Store, `ExhaustiveUpdate`, `request()`), `control.hpp` (Query, Mutation, Subscription, + `errorMessage`), `scheduler.hpp` (Scheduler, TimerHandle); graph internals in `detail/graph.hpp`. + +**`morph::ui`** (`include/morph/ui/`) + +- Header-only, in the base `morph` target. Depends on `morph::reactive`, never the reverse. +- Headers: `document.hpp` (the document model and its load-time validation, spec 5 §3), + `expression.hpp` (the `expr/1` evaluator), `interpreter.hpp` (document to reactive nodes), + `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), + `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. + +**`morph::table`** (`include/morph/table/`) + +- Header-only, in the base `morph` target, with no toolkit dependency: the table engine of spec 7. + Only `query_rows_source.hpp` depends on `morph::reactive`. + +**`morph::tui`** (`include/morph/tui/`, sources in `src/tui/`) + +- A compiled STATIC library (`morph_tui`, alias `morph::tui`), gated by **`MORPH_BUILD_TUI`** + (default OFF), linking `core::tui` and `morph` PUBLIC. +- Registered like `morph::net`: optional components list, header-set check, install and export, + `morphConfig.cmake.in`, sanitizers, strict warnings, the `*-everything` presets and CI. +- When ON: core-cpp gets `CORE_CPP_WITH_TUI ON` and `CORE_CPP_WITH_IMAGES OFF`; morph provides + libunicode 0.9.3 (found first, else fetched). Native only. + +**CONTRIBUTING** states the packaging rule: morph is header-only, except optional components that +wrap a compiled toolkit (`morph::tui`, `morph::qt_quick`). ARCHITECTURE and README say the same. + +**core-cpp pin:** 0.7 (spec 0). The config template and the CMake bound are minor-exact. + +**Conventions:** AGENTS.md and CONTRIBUTING.md — present-tense comments with no issue numbers; full +Doxygen (`WARN_AS_ERROR`); a spec per subsystem under `docs/spec/`; `ARCHITECTURE.md` maps; +CHANGELOG `[Unreleased]`; Catch2; strict warnings; clang-tidy-diff clean. + +## 3. The reactive core: `morph::reactive` + +**Types:** + +| Type | Behaviour | +|---|---| +| `Signal` | `get()` is tracked, `peek()` untracked. `set(v)` skips equal values under the signal's equality policy; `mutate(f)` always notifies. | +| `Computed` | Lazy, cached in a `std::optional` (so `T` need not be default-constructible), gated by its equality policy. An unchanged result stops propagation. | +| `Effect` | Runs once in its constructor, then after any of its dependencies changes. | +| `Scope` | Owns effects, computeds and adopted objects; destroys them in reverse creation order. A mounted view, a switch case, a dialog and a row each have one. | + +**Equality policy.** A value type is compared with `==` when `==` is usable for it, which the policy +checks by instantiating the comparison, not through `std::equality_comparable` alone: a container +declares `==` even when its element type has none. A type without a usable `==` always notifies. +`EqualityPolicy::Always` forces notification for a type that has one. + +**Runtime:** `Runtime(morph::exec::IExecutor& owner, RuntimeOptions = {})`; no `thread_local`, no +global — every node takes the `Runtime&`. `RuntimeOptions` carries `maxEffectRunsPerFlush` and +`afterFlush`, the hook a renderer uses to apply a frame. The owner must be serial. + +**Scheduling:** a `set()` outside a batch is a batch of one; `batch(f)` nests. When the outermost +batch ends with effects pending and no flush requested, the runtime marks a flush requested and then +calls `owner.post(flush)` once; a `post` that throws clears the mark. No flush runs synchronously +inside `set()`; writes made by effects during a flush are processed by that flush. The posted flush +holds the core weakly and is a no-op once the runtime is gone. + +**Widget events.** A renderer runs every user event inside `rt.widgetEvent(f)`. A flush that becomes +due inside a widget event is deferred until the outermost widget event returns, and runs then as a +posted flush. A handler that spins a nested event loop (a modal dialog) therefore neither runs the +flush under its own stack nor spins on it. + +**Algorithm** (push-dirty/pull-value, colours Clean, Check, Dirty, as in Reactively and Preact): +`set()` marks direct observers Dirty and propagates Check, queuing effects; `flush()` pulls each +queued effect through its computeds and runs it only if a source really changed (glitch-free); +queued effects run in scope-depth order, owners first; dependencies are re-tracked every run, so +stale ones are pruned; destructors unlink both ways. The cache and the graph links are `mutable` +members: reading a `const` node updates them without casting const away. + +**Errors and misuse:** + +- The RAII `TrackingFrame` always restores the previous tracking context. A throwing `Computed` + rethrows to its reader and retries on the next read. A source that throws while an observer + checks it marks the observer Dirty, so the observer re-reads it on its next pull and sees the + error, rather than staying stuck at Check. +- Each misuse is **reported, then refused**. The report is `detail::noteOwner(site, owner, false)`: + it asserts in a debug build, and a test's installed probe sees the site instead. The refusal is the + same in every build. + - An exception escaping an `Effect` (a defect): that flush stops and the runtime posts a new flush + for the effects still queued, at most a bounded number of times in a row, so a defect cannot + stall the queue until the next `set()` and cannot loop forever. + - An effect re-run more than `maxEffectRunsPerFlush` times in one flush (a write cycle): the flush + stops and its queue is dropped. + - `set()` inside a Computed: the write is dropped. A Computed reading itself: the read throws + `std::logic_error`. `send()` inside an update: the Msg is dropped. + - An operation off the owner: the operation is dropped. + - A Runtime destroyed while nodes are alive: nodes keep the core alive, and any flush is a no-op. + +**Relation to the bridge:** this layer consumes `Completion` and `subscribe` like any caller and +never enters `BridgeHandler`; nothing in the bridge knows a reactive runtime exists. The +interpreter's model-free dispatch uses the bridge seams of spec 5 §2, which are in the bridge, not +here. + +## 4. Store and async: `morph::reactive` + +**`Store`** holds user-intent state for C++ code that uses the core directly: tests, +local tools, the interpreter's own internals. + +- `ViewState` is a struct of `Signal` fields; `Msg` is a `std::variant`. `Update` must satisfy + **`ExhaustiveUpdate`**, checked at construction. +- `send(msg)` runs the update in one batch; `state()` returns `ViewState const&`. + +**The low-level async primitive:** `request(store, handler, scope, action, toMsg, toFailMsg)` +executes `action` and sends `toMsg(result)` or `toFailMsg(exception_ptr)` as one message on the +Store's owner, each delivery one batch. + +**Lifetime rule:** the Store, the Runtime and every mounted view outlive any callback that can still +fire. The `CallbackScope` is the last member of its owner, so it dies first and gates anything in +flight. + +## 4b. Declarative control: Query, Mutation, Subscription + +Header `morph/reactive/control.hpp`: reactive nodes like `Computed` — non-copyable, non-movable, +owned by a scope, affinity-checked against the Runtime's owner. A node built over a handler checks at +construction that the handler's callback executor is serial and runs on the Runtime's owner, by +affinity and not by pointer identity; no callback executor means the bridge's owner. + +### What this replaces + +An imperative controller sequences calls by hand, and two defects follow from the shape: **stale +replies win** (select A, then B; A's late reply shows A's history under B), and **invalidation is a +call graph** (what a write refreshes lives in whichever `.then` remembered to call `refresh()`). + +### `Query` — a derived async resource + +```cpp +template ::Result> class Query; +Query(Runtime& rt, BridgeHandler& handler, std::function()> key, QueryOptions = {}); +Query(Runtime& rt, std::function(A const&)> fetch, std::function()> key, QueryOptions = {}); +``` + +The fetcher constructor is the test seam and the path the document interpreter uses, whose fetcher +is model-free JSON dispatch with `A` and `R` as JSON text. + +- An internal Effect reads `key`, tracked. A changed key is fetched under a new generation of the + query's `CallbackScope`. `nullopt` is idle: nothing in flight, value and error cleared. +- **Latest wins:** a reply from a superseded generation is dropped, success and failure alike, and a + stoppable call is asked to stop. +- `QueryOptions::debounce` fetches a key only once it has been stable that long. +- Tracked reads: `pending()`; `value()`, kept while a refetch is in flight; `error()`, cleared by the + next success. `refetch()` re-issues the current key. +- `refreshOn(handler)` refetches on a published `Pub`; it fires per result type, so two actions + with one reply type fire each other. A document's `refreshOn` is the action-id form of spec 5 §5, + implemented over the interpreter's own dispatch. `QueryOptions::refreshEvery` refetches on a + `Scheduler` timer and skips a tick while a request is in flight. +- A fetch that throws synchronously is recorded as the query's error; `pending()` falls. + +### `Mutation` — a command + +```cpp +template ::Result> class Mutation; +Mutation(Runtime& rt, BridgeHandler& handler, MutationOptions options = {}); +Mutation(Runtime& rt, std::function(A)> run, MutationOptions options = {}); +``` + +- `run(A)` issues one call. Tracked reads: `pending()`, `error()` (cleared by the next success), + `lastResult()`, and `successCount()`, which ticks once per success. +- `MutationOptions::concurrency` decides what a run does while another is in flight: `Exclusive` + refuses it, `Serial` queues it (per key when `serialKey` is set), `Latest` sends it and applies + only the newest reply, `Parallel` applies every reply as it arrives. +- `MutationOptions::invalidates` holds `InvalidationLink`s: a query registers itself through a link + and unregisters when it is destroyed, so a mutation never refetches a query that is gone. A success + writes the result and refetches each live query in one batch: one flush, one frame. +- A run that throws synchronously is recorded as the mutation's error. + +### `Subscription` + +Built from `(Runtime&, BridgeHandler&)`. `latest()` is a tracked `std::optional` fed by +`handler.subscribe`. + +### `errorMessage(std::exception_ptr) -> std::string` + +The one place an `exception_ptr` becomes display text: `what()` for a `std::exception`, a fixed +fallback otherwise. + +### Scheduler + +`Scheduler::after(delay, fn)` and `every(period, fn)` return a move-only `TimerHandle` that cancels +on destruction; `active()` is false once a one-shot timer has fired. Callbacks run on the runtime's +owner. `testing::ManualScheduler` advances time only when told. + +## 5. The view tree and mount: `morph::ui` + +The interpreter builds the view tree from a document (spec 5 §9): one node per document node, with +every property either a constant or a **slot** — a reactive value with a stable id — and every event +a command sink. Node kinds, their properties and their events are spec 5 §9's palette; `Key` is +`std::variant`, never a double. + +**Mount:** `ui::Mounted(reactive::Runtime&, IViewBackend&, Node)` builds retained widgets through +typed factories, once. + +- Each slot becomes an Effect that calls the widget's setter when the value changes. A constant is + set once. +- **Switch, Tabs and Dialog** mount their content into a child scope and tear it down child-first. + Tabs mount lazily per tab. +- **ForEach and Table** keep one row scope per key. On a list change, existing keys are updated in + place, new keys mounted, gone keys unmounted, and children reordered with + `ContainerWidget::moveChild`; a duplicate key is refused. The new row list is built completely + before it replaces the old one, so a row whose mount throws leaves the old rows intact. +- Bindings die before their widget, children before their parent. + +**Backend contract (`ui/backend.hpp`):** `Widget` has `setVisible` and `setEnabled`, which apply to +the widget and everything inside it; `ContainerWidget` has `moveChild(Widget&, index)`; each node +kind has a widget interface with UTF-8 setters; `IViewBackend` has one typed factory per kind. A +setter called with the value the widget already shows changes nothing — in particular a text input's +cursor, selection and composition are kept. Input widgets are controlled: a user action reports the +request, and the widget shows what its slot says afterwards. A slot that transforms or clamps the typed value +shows the raw text for one posted turn first; the cursor and composition rules above keep that +turn harmless for input methods. + +**`ui::testing::RecordingBackend`** is a headless fake tree with an operation log and `click`, +`edit`, `choose`, `toggle`, `dismiss` and `drag` helpers. A helper acts only on a widget a user could +reach: visible and enabled together with all its ancestors. It is the test double for documents, +the forms engine and the interpreter, and the reference for the terminal renderer. + +The Qt Quick renderer (spec 3) does not use `Mounted`: it generates QML that reads the same slots. +Both are checked by the same behaviour cases (§7). + +## 5b. The frontend seam: `ui/frontend.hpp` + +```cpp +namespace morph::ui { +class AppContext { +public: + virtual ~AppContext() = default; + virtual reactive::Runtime& runtime() = 0; // owned by the frontend + virtual exec::IExecutor& executor() = 0; // the runtime's owner; every bridge's callback executor + virtual Scheduler& scheduler() = 0; + virtual exec::IoLoop* ioLoop() = 0; // null when the frontend has none + virtual void quit(int exitCode = 0) = 0; +}; +class AppSource { // where the screens and the models are +public: + virtual ~AppSource() = default; + virtual void open(AppContext& ctx, std::function)> ready) = 0; + virtual void switchBackend(Backend backend, std::function)> done) = 0; +}; +class Frontend { +public: + virtual ~Frontend() = default; + virtual std::string_view name() const = 0; + virtual int run(AppSource& source) = 0; +}; +std::expected, FrontendError> + selectFrontend(std::span built, std::vector& args, + EnvironmentReader const& env = processEnvironment()); +} +``` + +- An `AppSource` is either **remote** (connect, handshake, catalog, verified bundle; spec 5 §12) or + **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. +- 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 + private aliases, runs each screen's `onBackendChange` and refetches (spec 6 §5, §9). `Backend` is + the application's opaque handle. A host with its + own shell mounts screens through `ScreenHost` instead of `run`'s app shell (spec 6 §4). +- `selectFrontend`: `--ui=` or `--ui `, else `MORPH_UI`, else the first option whose + `usable()` holds; the flag is removed from `args`, which the caller passes on, and scanning stops at + `--`. An unknown or unbuilt name is a `FrontendError` naming the built ones. + +## 6. The terminal renderer: `morph::tui` (`MORPH_BUILD_TUI`) + +Best effort: the terminal renders every built-in node kind it can, renders a custom component's +fallback, and skips what a terminal cannot express. The screens are designed for desktop and web. + +**One loop:** `IoLoop` gains `enum class IoLoopDriver : std::uint8_t { OwnThread, Caller }` +(`OwnThread` the default, unchanged). `Caller` generalises morph's single-thread WebAssembly +semantics to native builds: no thread is started and the constructing thread is the loop's; +`runAndWait()` and a component's close run inline on that thread. A TUI app drives the one loop with +`TuiRuntime::blockOn`, so sockets, timers, bridge callbacks and the UI share one thread. +`docs/spec/core/executor.md` describes the mode; the IoLoop tests run in both. + +**`morph::tui::LoopExecutor : morph::exec::IExecutor`** posts to `EventLoop::post` and drops posts +after its destruction. It must be destroyed on the loop's thread, outside a turn. + +**`morph::tui::Backend : ui::IViewBackend`** over a `core::tui::Screen`. Its widgets are +`core::tui::Component` subclasses, private to `src/tui/`: a stack and grid layout solver in cells; +panels; labels with text roles as theme colours; buttons, checkboxes and text inputs over +`InputField`; selects as lists or overlays; tabs; dialogs as overlays with focus kept inside them; +scroll; busy spinners; tables; date-time fields; sliders; a file path field. A key reaches an +ancestor only when the ancestor opts in. Drag-and-drop (spec 5 §9) uses core-cpp 0.7's pointer +capture; a terminal without mouse reporting ignores `drag` and `drop`. + +**`tui::Frontend`** (`tui::frontendOption()`, named `"tui"`, usable when stdin is a terminal): +`IoLoop{Caller}`, a `LoopExecutor`, the runtime, `Screen` and `TuiRuntime` with interrupt handling +enabled, so Ctrl+C and SIGINT both quit with exit code 130 after restoring the terminal. A terminal +the frontend initialises is shut down when `run` returns. + +**Platforms:** Linux and macOS in CI; Windows builds when core-cpp's Windows console does. + +## 7. Tests (Catch2) + +`reactive`, `control` and `ui` tests are in morph's base test tree; `tests/tui/` is registered under +`MORPH_BUILD_TUI`. Owners are `morph::testing::StepExecutor`s drained by the test; misuse is observed +through `morph::testing::OwnerProbeRecorder`; both live in `include/morph/testing/` (§2). Every test states the mutation that would make it fail. + +- **reactive, Store, control:** glitch-freedom; pruning; unlinking; batching; one `post()` per + idle→pending transition and a recovered flush after a throwing `post`; the equality skip, and a + container of non-comparable elements that compiles and always notifies; a throwing source under a + checking observer, which errors again on the next read and lets an Effect run again; owner-first + effect order; deferral inside a nested widget event; every misuse seen once by the probe; Query + latest-wins under forced reply orders, idle, debounce, refetch, `refreshEvery` skipping while + pending; each Mutation concurrency mode; invalidation after a query's destruction; synchronous + throws; one-flush invalidation measured by counting the owner's posts; Subscription; + `errorMessage`. +- **ui:** document load-time validation; the expression corpus; the interpreter over spec 5's + corpus on `RecordingBackend`; a constant never binds; one setter call per real change; switch, tabs + and dialog remount child-first; ForEach identity, minimal operations, int64 and string keys, + duplicate refusal, and a throwing row mount that leaves the old rows intact; a hidden ancestor + blocks a helper's action. +- **frontend seam:** `selectFrontend` precedence and argument removal; destruction order; + `refreshEvery` on a `ManualScheduler`. +- **backend conformance:** the same scripted cases run against `RecordingBackend` and the terminal + backend; spec 3 runs the equivalent behaviour cases against generated QML. +- **tui:** each widget renders and handles its keys; the layout solver; the frontend end to end + through `ScriptedInputSource`; `LoopExecutor` lifetime; `IoLoop` `Caller` mode. + +## 8. Examples + +`examples/gallery` is one document that uses every node kind once, served by a small local +application, as a visual check for renderer authors. Every other application is spec 4. + +## 9. Docs + +New specs, each listed in the `docs/spec/README.md` map: `docs/spec/reactive/signals.md`, +`store.md`, `control.md`; `docs/spec/ui/document.md`, `expressions.md`, `view_tree.md`, +`backend_contract.md`, `frontend.md`; `docs/spec/tui/frontend.md`. `docs/spec/core/executor.md` gets +the IoLoop `Caller` mode; `ARCHITECTURE.md` its namespace and header maps; CHANGELOG +`[Unreleased]` → Added for each component as it lands. + +## 10. Risks + +- **The compiled-component exception.** `morph::tui` and `morph::qt_quick` are compiled; the + CONTRIBUTING rule names the exception and the install/export check covers both. +- **The expression library grows.** It is versioned (`expr/1`), total and bounded; a value it cannot + express is a server-computed field (spec 5 §1). +- **Query and mutation semantics are policies.** Latest-wins, keep-value-while-refetching, + clear-on-idle and the four mutation concurrency modes cover the migrated screens; another policy + becomes an option when two screens need it. +- **libunicode fetch** downloads UCD.zip at configure time for TUI builds; CI caches it. diff --git a/docs/superpowers/specs/2026-10-04-examples-migration-design.md b/docs/superpowers/specs/2026-10-04-examples-migration-design.md new file mode 100644 index 000000000..000c87631 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-examples-migration-design.md @@ -0,0 +1,230 @@ +# Every example on server-defined screens — design (spec 4) + +Every demo application under `examples/` defines its screens on its server, as UI documents +(spec 5), next to its models. One **generic client** renders any of them: it connects to a server, +fetches the application's screens and components, and renders them with Qt Quick (spec 3), natively +or in the browser. An application that runs without a server links its models and screens into the +same client (local mode). The hand-written QML, the QML-facing bridges and presenters, and the Qt +client infrastructure in `examples/common` go away; the last pull request removes them together with +the shipped QML forms renderer (spec 2 §11). + +## Contents + +[1 Scope](#1-scope) · [2 Shape of an app](#2-shape-of-an-app) · [3 The generic client](#3-the-generic-client) · +[4 examples/common](#4-examplescommon) · [5 The apps](#5-the-apps) · [6 Build](#6-build-and-gating) · +[7 Testing rules](#7-testing-rules-testingmd) · [8 Pull requests](#8-pull-requests) · [9 Docs](#9-docs) · +[10 Risks](#10-risks) + +## 1. Scope + +| App | Today | Screens to define | +|---|---|---| +| bank | 13 QML files, 7 controllers, desktop + WASM (in-memory models) | Login, shell with sidebar, Accounts, Move money, Cards, Payees, Loans | +| pastebin | 2 QML, 2 bridges + presenter, desktop + WASM | Create form, list, detail with delete | +| bookmarks | 3 QML, 4 bridges + 3 presenters, desktop + WASM | Login, list (archived toggle, multi-select, bulk archive), detail (archive, delete, edit, import), tags (rename, merge), shared feed | +| polls | 3 QML, bridge + presenter + forms controller, WASM only | Landing (open by id, create), create-poll (dynamic option list), vote grid, undo, comments, finalize, live activity feed | +| kanban | 7 QML, 2 bridges + 2 presenters, desktop + headless test client | Login, projects, members (role picker), board (swimlanes, columns, cards, drag to move), rules, task detail (comments, attachments) | +| ledger | 5 QML, 4 bridges + 4 presenters + report poller | Tabs: ledger (accounts, entries, store, undo), budgets, rules, statement (polled job) | +| lims | 3 QML, 2 bridges + 2 presenters | Tabs: sample lifecycle (forms + actions), results (live recalculation, verify, conflicts); its tab strip is the workspace shell (spec 6 §7) | +| forms demo | 2 QML + FormsController | App shell over the lab schemas: forms, the intake wizard, the samples collection | + +Not UI, unchanged: `concepts`, `qt_tls_client`, `vetted_hmac`, `crm`. **Servers stay on Qt** +(`QtWebSocketServer`); they gain the screens, and the framework's handshake, catalog and bundle +envelopes serve them. + +## 2. Shape of an app + +```text +examples// + include/, src/models/, src/db/ domain library — Qt-free + screens/ screen definitions (C++ builders, spec 5 §11) and the app shell + components/ the app's custom QML components, if any (spec 5 §10) + src/server/ server executable — Qt; registers models, screens, components + tests/ model tests, screen tests, render smoke tests +``` + +- **The domain library loses Qt.** pastebin's, bookmarks' and ledger's `App` classes are QObjects + with QTimers that only the server runs; they move into `src/server/`. +- **`screens/`** is target `_screens` (`ladder__screens` for a ladder rung, §6), linking `morph` and the domain library only. It is linked + by the server and by the app's local-mode client. A Qt include in it fails to build. +- **Screens replace controllers.** What the bridges, presenters and QML conditionals did becomes + declarations: queries, mutations, relations, checks and watches. Formatting is the expression + library's display functions; anything the library cannot express is a field the model computes + into its reply (spec 5 §1). +- **Custom components** reproduce what plain controls cannot, such as bank's account cards and + kanban's board styling. Each has a fallback of built-in nodes. + +## 3. The generic client + +`examples/client/main.cpp`, built once: + +```cpp +int main(int argc, char** argv) { + auto source = examples::appSource(argc, argv); // --server URL, or --app NAME for local mode + std::vector built; +#if MORPH_CLIENT_HAS_QT_QUICK + built.push_back(qt_quick::frontendOption(argc, argv)); +#endif +#if MORPH_CLIENT_HAS_TUI + built.push_back(tui::frontendOption()); +#endif + return ui::selectFrontend(built, argc, argv)->run(*source); +} +``` + +- **Remote** (`--server URL`): a `RemoteAppSource` connects with the transport for the chosen + frontend — `QtWebSocketBackend` under Qt Quick, `morph::net::SocketBackend` on the I/O loop under + the terminal — performs the handshake, and renders what the server serves. The generic client + binary contains no example code. +- **Local** (`--app NAME`): a `LocalAppSource` runs the named application's models and screens + in-process over `LocalBackend`, with the database set up from `--db`. A local-mode client links + the applications it offers; the example build produces `morph_examples_client`, which links all of + them. +- **WebAssembly:** the generic client built with Qt Quick only. Remote mode connects to any example + server. Local mode is available for the applications whose models build under Emscripten — bank, + with its in-memory models, is the browser demo that needs no server. kanban, ledger and lims need + the SQLite ORM and are remote-only in the browser. +- **Sessions.** Login is a screen of the application (bank, bookmarks, kanban). ledger and lims + sign in a development principal from `--user` in local mode and through their login screen + remotely. +- **The transport binds early.** A handler is bound as soon as a screen declares its model, which may + be before the socket has connected. Both transports queue a bind made before the first connect and + send it once connected; `SocketBackend` gains this queue, which `QtWebSocketBackend` already has. + +## 4. examples/common + +Qt-free, target `morph_examples_common`: + +- `app/app_source.hpp` — the argument reader and the remote and local sources of §3; + `app/transport.hpp` — transport selection by frontend. +- `testkit/screen_harness.hpp` — loads a registered screen, binds it to a multi-mode rig backend + (Local, Simulated, Socket), mounts it on `RecordingBackend`, and drives it: edit, choose, click, + wait. Every app's screen tests use it, so no app hand-writes a client rig. +- `testkit/wait.hpp` — `pumpUntil` over a `MainThreadExecutor` or `StepExecutor` with the + `deadline.hpp` budget. +- `testkit/render_smoke.hpp` — opens an application's every screen in the Qt Quick renderer + offscreen and asserts each mounts without a refused document, a failed component or a QML warning. + +Removed in the last pull request: `gui/presenter.*`, `gui/event_poller.*`, `gui/error_text.*`, +`gui/id_qml.hpp`, `gui/app_context.*`, `testkit/qml_surface.*` and its self-tests, and the +QGuiApplication branch of `testkit_main.cpp`. `backend_rig`, `fault_proxy`, `offline_rig`, +`process_pool` and the Qt-free fixtures stay. + +## 5. The apps + +Per app: the screens of §1, defined in `screens/`, and the behaviours its presenter and bridge tests +pin today re-expressed as screen tests on the harness. The plan for each app maps every existing test +file to its replacement. App-specific decisions: + +- **bank:** the shell is the app shell with a sidebar menu. Account cards, the transfer flow and the + loan schedule use custom components. The browser demo is local mode over the in-memory models. + The A/B stale-selection ordering gets a screen test. +- **pastebin:** create is a `form`; the list is a query; opening a paste is an `exclusive` mutation of + `GetPaste`, which consumes one of the paste's allowed reads, so it is never refetched and a second + open while one is in flight is refused; delete invalidates the list. +- **bookmarks:** Login, CreateBookmark, EditBookmark, ImportBookmarks, RenameTag and MergeTags are + `form` nodes over three models (AuthModel, BookmarkModel, TagModel) in one screen; the list is a + table with multiple selection and a bulk archive mutation. +- **polls:** create-poll's option list is a `forEach` over a state list of option drafts (2–20, + add/remove); the vote grid is one table whose columns are the options; opening a poll is a + `latest` mutation, so only the newest open applies; a reconnect re-opens the poll and keeps the + participant's picks, which are client state; the vote button sends `SubmitVotes` until a vote + succeeds for the current participant name and `UpdateVotes` after; the activity feed is a query + with `refreshOn`. +- **kanban:** the board is a grid of swimlanes by columns of cards; a card is draggable with its task + id and a column is a drop target whose drop runs the `moveTask` mutation, `serial` per task. + Dropping onto a card inserts before it in the target cell. Attachments use the client's file + commands — upload a picked file, download to a chosen path — over the server's attachment side + channel, which uses TLS whenever the WebSocket does. The offline queue and network monitor are + the client's, enabled by the screen. +- **ledger:** the statement job is a query with `refreshEvery` that skips while a poll is in flight, + and that stops once the job is finished or failed; each outcome has a screen test. +- **lims, sample entry:** a second reference screen, `lims.sample`, is the reference for hosting a + record-editing dialog (spec 6) and a model-derived table (spec 7). It is implemented and unit + tested as an example, with these parts, each chosen to exercise one feature of the program: + - *Lifecycle (spec 6 §3):* `params: {sampleId: int?}`, `identity: sampleId`, `title` the sample + number, `dirty` from the section edits, `onMount` loading or creating, a close guard. + - *Private instance:* the sample model is bound without `instance`, so each open screen holds its + own working copy, and Save commits it. + - *Sections as forms (spec 2):* header, sampling conditions and a measurement section, each a + `form` over its section action with a `fields` overlay (hidden member, label, unit, decimals, an + epoch-day date). + - *Optional measurements:* turbidity and nitrate cards that are added from a menu and removed by + a clear mutation that `invalidates` the snapshots, over a state list. + - *Computed on the server:* a dilution series whose rows carry measured value, recovery + percentage, limits and an `outsideLimits` flag, all computed by the model (spec 6 §10); the card + header counts the rows outside limits. + - *Host components (spec 6 §6):* `SeriesGrid` and `RecoveryCurve` over that list, each with a + `table` or `text` fallback. + - *Errors:* save returns an enum outcome and typed errors; messages are selected by `errorKind` + and by the enum. + - *Lookup table (spec 7):* a "choose project" dialog is a `table` over a server-mode list action, + with sort, a filter and single selection. + - *Backend switch (spec 6 §9):* the screen survives a switch from a remote to a local backend + through `onBackendChange`. + The lims `SampleModel` gains the section, snapshot and working-copy actions this needs (spec 6 + §11 lists them); its existing actions are reused. + The screen is authored with the C++ builders, registered with `MORPH_REGISTER_SCREEN`, validated at + registration, and mounted by the screen harness on `RecordingBackend`. Its tests are those listed + under spec 6 §12 for lifecycle, forms, errors and offline switching, run against this screen, and + the render smoke test opens it on Qt Quick offscreen. No name from any application outside this + repository appears in the example, its tests or its data. +- **lims:** the results tab is the reference for live recalculation (spec 5 §7): a row of capture + values per result; a preview per row keyed on its draft, through `evaluate` of the capture action + where its `computedFields` suffice; the calculated concentration shown stale while in flight; and + a write-through capture that patches the row. Its forms are `form` nodes. +- **forms demo:** `LabApp` is the app shell — forms, the intake wizard, and `SamplesView` as a + `collection`. `--typed` builds the same screens from `forms::Form` in local mode. The CLI + (`--schemas`, REPL, `--emit-html`) is unchanged. + +## 6. Build and gating + +- `_screens` and the screen tests need no frontend and no Qt. +- The generic client is built when `MORPH_BUILD_QT_QUICK` or `MORPH_BUILD_TUI` is on, and links the + renderers that are built. +- `morph_add_rung` learns the layout: `screens/*.cpp` → `ladder__screens`, + `components/*.qml` → the rung's component bundle installed beside its server. `MORPH_BUILD_LADDER` + still requires `MORPH_BUILD_QT` for the servers. +- CI: the ladder and bank jobs build the generic client with Qt Quick and run the render smoke tests + offscreen; `wasm-ladder.yml` builds the WebAssembly client and watches `src/qt_quick/**`; + `wasm-demo.yml` builds the bank browser demo in local mode. Test executables that link Qt pass + `DL_PATHS` on Windows. + +## 7. Testing rules (TESTING.md) + +The "Presenter architecture" rules and the QML-surface drift guard are replaced by: + +1. **Screens are toolkit-free**, enforced by their target's link set. +2. **Every registered screen validates** (spec 5 §3) in the server's own tests. +3. **Screen tests run headless** on the harness: a document on `RecordingBackend`, the multi-mode + rig, waits through `testkit/wait.hpp`, never sleeps. +4. **One render smoke test per app** on Qt Quick offscreen, through `render_smoke.hpp`. +5. Fingerprints, journeys, convergence and stress harnesses are unchanged; they drive screens through + the harness, each simulated client on its own rig bridge, and compare what each client shows. + +## 8. Pull requests + +Examples move a few per pull request, each building and passing on its own: + +1. `examples/common` and the generic client, with the gallery. +2. lims and the forms demo: the live-recalculation reference, the sample-entry reference screen + and the app shell. +3. bank. +4. pastebin, bookmarks, polls. +5. kanban, ledger. +6. The retirement: spec 2 §11, §4's removals, and `morph_add_rung`'s old conventions. + +## 9. Docs + +`examples/TESTING.md` (§7), `examples/IMPLEMENTATION.md` ("GUI minimalism" becomes "Screens on the +server"), `examples/LADDER.md` (clients are server-defined screens in a generic client), each app's +README client section, and `docs/GETTING-STARTED.md`'s client walkthrough. + +## 10. Risks + +- **Size.** Thirty-eight QML files and about 15,900 lines of Qt-facing C++ are re-expressed; per-app + pull requests keep each reviewable, and each app's existing test claims are the parity bar. +- **The expression library may not cover a screen.** A gap is closed by a server-computed field or a + custom component, and a gap that two apps share is an expression-library or vocabulary addition. +- **Behaviour drift during re-expression.** Each app's presenter and bridge tests move onto its + screens before the old code is deleted, in the same pull request. diff --git a/docs/superpowers/specs/2026-10-04-forms-engine-design.md b/docs/superpowers/specs/2026-10-04-forms-engine-design.md new file mode 100644 index 000000000..03d17690a --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-forms-engine-design.md @@ -0,0 +1,342 @@ +# The forms engine: schema-driven forms through the view tree — design (spec 2) + +morph's schema-driven forms render today only through the shipped QML module: about 4,000 lines of +QML/JS (`DynamicForm.qml`, `CollectionView.qml`, `WizardView.qml`, `SlotRegistry.qml`, +`DateTimePicker.qml`, `JsonExact.js`) that read the schemas C++ emits, keep the drafts, encode +values exactly, evaluate the rules, fetch Choice options, lay fields out and sequence wizards. C++ +has the schema emitters and the typed validators, but no runtime model of a form. This spec moves +that runtime into C++ as one engine on the client, interpreted from the UI document (spec 5): a +`form`, `collection` or `wizard` node of a document names an action or view schema, and the engine +builds the form's state and its view tree from it. The same form renders on Qt Quick and on the +terminal. The QML module is retired once no example uses it (spec 4). + +Two ways in, one engine: a form can be **built at runtime purely from schema JSON**, for +applications that assemble UIs from data, and **statically typed from an action type**, for +applications that want the compiler's help. Both produce the same runtime `FormModel`, so rules +are evaluated in exactly one place. + +## Contents + +[1 Shape](#1-shape) · [2 The field model](#2-the-field-model) · [3 Rules](#3-rules) · +[4 Values and exactness](#4-values-and-exactness) · [5 FormSession](#5-formsession) · +[6 Typed forms](#6-typed-forms-forma) · [7 Rendering](#7-rendering) · +[8 Collections, wizards, app shell](#8-collections-wizards-app-shell) · [9 Packaging](#9-packaging) · +[10 Parity and tests](#10-parity-and-tests) · [11 Retiring the QML renderer](#11-retiring-the-qml-renderer) · +[12 Docs](#12-docs) · [13 Risks](#13-risks) + +## 1. Shape + +``` +schema JSON ──FormModel::fromSchema──┐ + ├─► FormModel ─► FormSession (reactive) ─► formView() ─► ui::Node +schemaJson() ──Form────────────┘ │ + └─ Submitter / ChoiceFetcher (model-free dispatch) +``` + +On the client, a document's `form` node supplies the schema, the model alias and the prefill; the +engine supplies the rest. `Form` is for C++ code that knows the action type: server-side authoring +and tests. + +- **`FormModel`** — immutable, runtime: the fields, their kinds, constraints, layout and rules. +- **`FormSession`** — reactive state over a model: drafts, encoded values, readiness, presentation + rules, Choice options, submission. It is a controller in spec 1's sense (no toolkit, testable + headless). +- **`formView(session, options)`** — the view: view-tree nodes bound to the session's values. Each + field is an addressable element of the document (spec 5 §6), so a document's relations, checks and + watches can reference it as `{"ref": "

..value"}`. +- **`Form`** — a typed facade that builds the model from `schemaJson()` and adds typed + access. It owns a `FormSession`. + +The schema JSON that `schemaJson()` emits is the contract between the two entrances; it is +already pinned by the conformance and rule corpora, and nothing about it changes here. + +## 2. The field model + +`FormModel::fromSchema(std::string_view actionType, std::string_view schemaJson) +-> std::expected` reads the emitted keywords (glaze's generic JSON DOM), +resolving `$ref` and nullable `anyOf`/`oneOf`, and produces one `FieldSpec` per property in +`x-order`. + +| `FieldKind` | From | Wire encoding | +|---|---|---| +| `Text`, `Multiline` | `string`; `x-widget: textarea` | string | +| `Integer` | `integer` | int64, digits preserved beyond 2^53 | +| `Number` | `number` (with `x-displayDecimals`) | JSON number | +| `Decimal` | `x-decimalPlaces` on a `Rational` | `{num, den, dp}` | +| `Quantity` | `ExtUnits` (+ `x-unitAlternatives`) | `{num, den, dp}` in the canonical unit | +| `Boolean` | `boolean` | `true`/`false` | +| `Enum` | `enum` / `const` branches (`x-widget: radio` for radio) | the enum value | +| `Choice` | `x-optionsAction`, `x-optionValue`, `x-optionLabel`, `x-optionsDependsOn` | the option's value | +| `DateTime`, `Date` | `format: date-time` / `date` | ISO 8601 UTC | +| `EpochDateTime`, `EpochDate` | `x-widget: epochSeconds` / `epochDays` on an integer | int64 seconds or days since the epoch (UTC), digits preserved; displayed as the locale's date-time or date | +| `Slider` | `x-widget: slider` with `x-min/max/step` | integer | +| `Array` | array of scalars | JSON array | +| `Object` | nested object (recursion, cycle-safe) | nested object | +| `ObjectArray` | array of objects | array of nested objects | + +A `FieldSpec` carries: wire name and path; label, help, placeholder and their i18n keys +(`forms/i18n.hpp`; `render::resolveText` resolves through it); required (`required` and `optionalFields`), `x-blankAs`; `x-readonly`, +`x-hidden`, `x-computed`; exact bounds (`minimum`/`maximum`/`multipleOf`, `x-exactMinimum`/ +`x-exactMaximum`, instance `x-minimum`/`x-maximum` from `InstanceConstraints::decorate`), decimal +places, display decimals; unit and alternatives with their exact factors; enum values; the Choice +descriptor; layout (`x-section`, group kind Section/Tab/Accordion, `x-colspan`); the widget hint; +children for `Object`/`ObjectArray`. The model also carries the action type, title, groups, the +parsed rules (§3) and the submit mode (`x-submitMode`: automatic or explicit). + +**Nothing is unrepresentable.** Nested objects render as sub-panels and object arrays as rows with +Add/Remove (§7), so the "unrepresentable unless a slot claims it" state of the QML renderer goes +away. + +## 3. Rules + +`x-rules` is parsed into a `RuleExpr` tree mirroring the 16 rule kinds (`engaged`, `notEngaged`, +`greater`, `greaterOrEqual`, `less`, `lessOrEqual`, `equals`, `requiredWhen`, `exactlyOneOf`, +`atLeastOneOf`, `mutuallyExclusive`, `visibleWhen`, `readonlyWhen`, `andOf`, `orOf`, `notOf`). + +- Evaluation is over the session's encoded values and is **three-valued** — `True`, `False`, + `Unknown` (a field that does not encode yet) — because a half-typed form has fields with no + value; `Unknown` propagates through `andOf`/`orOf`/`notOf` by Kleene logic. +- **Gating rules** decide readiness: a gating rule blocks readiness while it is `False`; `Unknown` does not + block on its own (the fields it waits on are required, or do not encode, and block through readiness). + **Presentation queries** — `visible(field)`, `readonly(field)`, `dynamicallyRequired(field)` — + come from `visibleWhen`, `readonlyWhen` and `requiredWhen`; `Unknown` presents as visible, + editable and not required. +- Comparisons are exact: integers as digit strings, decimals and quantities as `Rational`, booleans + as booleans — `equals` against a bool, and against an int64 above 2^53, are exact, because nothing + passes through a double. +- Each rule is translated into the document's expression language (spec 5 §4), whose three-valued + logic is the same, so the client has **one** evaluator for rules, relations and checks. The typed + rule nodes (`allRulesSatisfied`) stay the server-side check; the agreement corpus (§10) + compares the two in one test binary. +- A rule's field list is matched by membership: a presentation rule applies to every field it names, + not only its first. +- An operand whose draft does not encode (invalid text) is `Unknown`, for every rule kind including + `engaged`: a value the server would refuse does not satisfy a condition. `engine.md`'s differences + table lists where this differs from the QML renderer. + +## 4. Values and exactness + +Per field the draft is what the user typed (text), the selected unit, and whether a blank-capable +field is engaged. Encoding a draft yields `std::expected`: + +- **`morph::render::parseDecimal(std::string_view, NumericLocale const&) -> std::optional`** + — new, in `render/locale_format.hpp`, beside the locale code it uses, so `util/` keeps no + dependency on `render/`: locale input normalised by + `render::normalizeLocaleNumber`, then digits → `Rational` with the declared decimal places, + rejecting more places than declared. It is the one decimal parser the forms + engine and any app share. +- Quantity unit conversion is exact `Rational` arithmetic with the factors `x-unitAlternatives` + carries; integers keep every digit; date-times convert from the display offset to UTC. +- Bounds, `multipleOf`, enum membership and instance bounds are checked exactly; each failure is a + `FieldError` with a stable code and the i18n'd message. +- `x-blankAs: Omit` leaves a blank field out of the body; `Empty` sends the kind's empty value. +- The **body** is assembled as JSON text in `x-order`; it is the byte-for-byte contract the parity + tests compare. Prefill decodes a JSON body back into drafts (the reverse of every encoder). +- Display uses `render::formatCanonicalNumber` and `render::resolveText` with the session's + `TranslationProvider` and locale. + +## 5. FormSession + +```cpp +class FormSession { +public: + FormSession(reactive::Runtime&, FormModel, Submitter, ChoiceFetcher, FormSessionOptions = {}); + FieldState& field(std::string_view path); // per-field signals and computeds + bool ready() const; // tracked + std::optional body() const; // tracked; nullopt until encodable + void submit(); // explicit mode, or forced + void prefill(std::string_view bodyJson); // programmatic; never auto-submits + void reset(); + // tracked: pending(), lastReply() (ok + payload text), lastError() +}; +using Submitter = std::function(std::string_view actionType, std::string bodyJson)>; +using ChoiceFetcher = std::function(std::string_view optionsAction, std::string bodyJson)>; +``` + +- `FieldState`: `text`, `unit`, `engaged` signals; `encoded`, `error`, `visible`, `readonly`, + `required` computeds; for a Choice, `options` (a `reactive::Query` keyed on its dependency + fields' encoded values, so a parent change refetches and a superseded reply is dropped). +- **Readiness:** every required field encodes, no field has an error, no gating rule is `False`. A form that is + already ready when it is built submits once in automatic mode. +- **Submission** is a `reactive::Mutation` over the `Submitter`. In automatic mode an Effect submits + whenever the form becomes ready with a changed body, + except while a programmatic change (prefill, reset) is applying; in explicit mode only `submit()` + does. +- **Choice options:** the rows are the result if it is an array, else its first array member. A + selection no longer among the options follows the field's `onStale` (spec 5 §6), `clear` by + default. An idle options query — a parent field blank — keeps the selection, because no options + have been fetched to judge it against. +- **Submitters:** on the client, a form submits through model-free dispatch (spec 5 §2) to the + model alias its document node names, so the client compiles in no model type. + `forms::handlerSubmitter(executor, handlers...)` routes through C++ code's own typed handlers, for + tests and local tools; `handlerChoiceFetcher` is the same for options. Tests pass a fake. +- **Overlapping submissions.** A form's submit is an `Exclusive` mutation (spec 1 §4b): the submit + control disables while one is in flight, and each success ticks the mutation's `successCount` (spec 1 §4b), which a document's + `watch` or a C++ caller observes per submission. + +## 6. Typed forms: `Form` + +```cpp +template +class Form { +public: + Form(reactive::Runtime&, bridge::BridgeHandler&, FormSessionOptions = {}); + FormSession& session(); + template void set(MemberType value); // programmatic, typed + std::optional value() const; // tracked: the body decoded into A + bool ready() const; // session readiness && ActionValidator::ready(*value()) + // tracked: lastResult() -> std::optional::Result>, pending(), error() +}; +``` + +- The model is `FormModel::forAction()` = `fromSchema(ActionTraits::typeId(), schemaJson())` — + the same reader, so a typed form behaves exactly like the runtime one for the same action. +- Submission decodes the body into `A` (glaze) and calls `handler.execute(a)`, so the result is + typed. `ActionValidator` adds the action's own `validate()` to readiness. +- `set<&A::field>(v)` encodes `v` with the field's encoder and applies it as a programmatic change. + +## 7. Rendering + +`forms::formView(FormSession&, FormViewOptions) -> ui::Node`; `FormViewOptions` carries the +`Overrides`, the grid column count, and chrome strings. + +| Kind | Node | +|---|---| +| Text / Multiline | `TextInput` (SingleLine / Multiline) | +| Integer, Number, Decimal | `TextInput` | +| Quantity | `Row{TextInput, Select(unit)}` (`Select` only with alternatives) | +| Boolean | `Checkbox` | +| Enum | `Select` (Dropdown, or Radio for `x-widget: radio`) | +| Choice | `Select` bound to the options query, `Busy` while it loads | +| DateTime / Date | `DateTimeInput` | +| Slider | `Slider` | +| Array | `TextInput`, comma-separated | +| Object | `Panel` with the child fields | +| ObjectArray | a `ForEach` of child `Panel`s with Remove, and an Add button | + +- Every field: label, the input, help text, the error (`TextRole::Error`), bound `visible` and + `enabled` (readonly). Layout: sections are `Panel`s, tab groups (consecutive runs merged) are + `Tabs`, accordions are collapsible `Panel`s, `x-colspan` places fields in a `Grid`. +- Explicit mode adds a Submit `Button` bound to readiness; every form shows the last reply or error. +- **Field overlay.** A `form` node's `fields` map (`omit`, `hidden`, `label`, `unit`, `decimals`, + `widget`, `blankAs`, per field) is applied to the parsed `FormModel` before the session is built + (`blankAs` takes the lower-case spelling of `x-blankAs`: `omit`, `empty`), so + an application states its labels, units and hidden members on the server and no client rewrites + the schema. `omit` removes the member from the form and from the body; `hidden` keeps it in the + body and out of the view. An overlay declared on the server's schema emitter is refined by the + document's (spec 6 §10). +- **Grid widget.** An `ObjectArray` member with `x-widget: grid` (or `"widget": "grid"` in a `fields` + overlay) renders as an editable grid over the form's draft rows, for typed rows entered in place: + - *Columns* derive from the item schema as the table engine's do (spec 7 §4) and take the same + keys (`label`, `unit`, `decimals`, `width`, `hidden`, `tone`). `input: false` makes a column + display-only; an `x-computed` member is display-only by default. + - *Cells edit the form's draft*, not a mutation: the form validates and submits the whole body, as + for any field, and a cell's error shows on the cell. + - `rowOps` is a subset of `add`, `duplicate`, `remove` and `exclude`. `exclude` toggles the boolean + member named by `excludeField`, and an excluded row stays in the body. `ghostRow: true` shows a + trailing empty row whose first edit appends a row. + - `summary` is a list of `{label, fn, of}` evaluated over a column with the expression library + (`mean`, `sum`, `min`, `max`, `count`) and shown in a footer row. + - *Keyboard.* Tab and Shift+Tab move between input cells, Enter commits the cell and moves down, + Esc reverts the cell, and the arrow keys move between cells that are not being edited. + - `tone` is an expression over `{"row": ...}` that yields `ok`, `warn` or `err`; a column's `tone` + colours its cell and a grid's `rowTone` colours the row. + - *Sibling members* of the form's object are ordinary fields; `x-section` places them with the grid + (a conditions strip above it). An override (below) takes props from any element reference and + sets any field's draft from its events, so a replacement cell editor needs no special access. +- **Overrides** (the replacement for `SlotRegistry`) are declared in the document: a `form` node's + `overrides` maps a field, a widget hint, a unit or a kind — resolved in that order, then the + default — to a document subtree or a custom component (spec 5 §10) whose props are the field's + value, draft, errors and options, and whose events set the draft. A designed money input or a + unit picker is therefore a component the server delivers, and the field stays schema-driven. + `forms::Overrides` with `std::function` is the same mechanism for C++ code. + +## 8. Collections, wizards, app shell + +- A collection's table is the table node of spec 7: the `v-columns` derivation and `ColumnOverride` + are shared with it, and sorting, filtering, selection and paging are the table engine's. +- **`CollectionModel::fromSchema(viewJson)`** (the `v-*` keywords; typed via `viewSchemaJson()`), + **`CollectionSession`**: the list is a `Query` on `v-query`; row and collection actions are + `Mutation`s whose bodies are built from `bind` entries with exact ids; an action with `confirm` + opens a `Dialog`; a row's edit action opens an editor `FormSession` prefilled from the row; a + success refetches the list. **`collectionView`** renders a `Table` (columns from `v-columns`, + cells formatted exactly — not through a double as today) plus the action buttons, or master-detail + with a side form. +- **`WizardModel`** (`w-*`), **`WizardSession`**: one `FormSession` per step, kept alive; a step is + done when its submission succeeded; Back/Next; entering a step applies its prefill from the + resolved values (draft first, then the reply, the reply winning on a name collision — the rule + `FlowSession` documents) as JSON values, not as re-encoded text. Returning to a step that already + succeeded shows its submitted values and does not submit it again; Next resubmits it only when its + body changed. **`wizardView`** renders the step title, the step's form and the navigation. +- **`AppShellModel`** (`app-*`), **`appShellView`**: a `Menu` from `app-menu` beside a `Switch` over + `app-screens`, kinds `form`, `wizard`, `view` and `screen` — a screen is a UI document by id + (spec 5). The app shell is what the catalog returns (spec 5 §12) and what the generic client + mounts first. Each screen's session is created when the screen is selected, outside any mount, and + kept while it stays in the menu. + +- **Workspace shell.** `app-shell: "workspace"` is a second shell kind beside the menu-and-switch + one: tabs over screens, with per-tab `dirty` and `title`, a close protocol, most-recently-used + switching, a navigator over the catalog, pins and a start page (`resume`, `overview` or a screen + id). A tab is a screen instance, one per `identity`; a `singleton` screen has one tab. Its + semantics, preferences and window-chrome boundary are spec 6 §7–8. + +Sections (`SectionSet`) keep their typed session and gain no renderer here. + +## 9. Packaging + +Header-only, in the base `morph` target, under `include/morph/forms/engine/`: `field_model.hpp`, +`rules.hpp`, `form_session.hpp`, `typed_form.hpp`, `form_view.hpp`, `overrides.hpp`, +`collection.hpp`, `wizard.hpp`, `app_shell.hpp`, `workspace.hpp` (spec 6 §7), `handler_submitter.hpp`; `parseDecimal` in +`render/locale_format.hpp`. It depends on `morph::reactive` and `morph::ui`, never on a renderer. +`rules.hpp` and `field_model.hpp` do not include the schema emitters; only `typed_form.hpp` does. + +## 10. Parity and tests + +Parity with the QML renderer is measured, not asserted: + +- **Rule corpus:** `rule_corpus.json` moves to `tests/data/`; all 46 rows' `ready`, `visible` and + `readonly` verdicts from the runtime evaluator, and the typed-vs-runtime agreement over the same + rows. +- **Bodies, measured against QML:** before the QML renderer is retired, a QuickTest drives + `DynamicForm` through every typed-text state the QuickTests use — exact `{num, den, dp}`, int64 + beyond 2^53, unit conversion, blank-as, enums, booleans, date-time zones — and writes each body to + `tests/data/body_corpus.json`. The engine's test reads that corpus and requires byte-identical + bodies. The expected bodies are the QML renderer's output, not hand-written strings. +- **Conformance, instance-bounds and locale corpora:** every fixture and row, read from the shared + data files (the hand-mirrored QML copies go away with the QML). +- **Behaviour:** each of the 35 QuickTest files' claims is re-expressed against `FormSession` and + `formView` on the `RecordingBackend` (prefill round-trip, dependent Choice refresh, automatic vs + explicit submission, collection populate/refresh/confirm, wizard gating and prefill, i18n + resolution, layout runs); the plan maps every file to its replacement. +- **Renderers:** a form document from the corpus mounts on the terminal backend and renders through + the Qt Quick generator (spec 3), and the behaviour claims above that involve input pass on both. + +## 11. Retiring the QML renderer + +Its own pull request, once no example uses it (spec 4): `src/qt/forms/` (QML, JS, +`I18nCatalog`, the QuickTest suite), `include/morph/qt/forms/` (`FormsControllerCore`, +`MultiModelFormsControllerCore`, `GenericModelBridgeCore`, `MultiModelBridgeCore`), the +`morph_qt_forms` target and the MorphForms module, `MORPH_BUILD_FORMS_QML`, the `qt_forms`, +`forms_qml` and `forms_qmlplugin` install components and their `morphConfig.cmake.in` blocks, +`scripts/check_forms_qml_install.sh` and its CI job. Headers the `morph::qt` component also uses +stay with it. CHANGELOG → Removed, naming the replacement. + +The forms demo's self-contained HTML/JS renderer (`examples/forms --emit-html`) stays: it is an +example of consuming the schemas from a browser, not a renderer morph ships. + +## 12. Docs + +`docs/spec/forms/engine.md` (new: model, rules, values, sessions, rendering, overrides); +`forms.md`, `views.md`, `workflows_navigation.md` and `widget_hints.md` lose their QML-renderer +sections and point at the engine; `docs/spec/README.md` map; `ARCHITECTURE.md`; CHANGELOG +`[Unreleased]` → Added (the engine, `parseDecimal`). + +## 13. Risks + +- **Size.** The engine re-implements about 4,400 lines of QML/JS in C++; header-only keeps morph's + packaging but costs compile time in every TU that includes `form_view.hpp`. The headers are split + so a controller that only needs `FormSession` does not pull in rendering. +- **Automatic submission on every ready change** is kept for parity; an app that wants explicit + submission says so with `x-submitMode`, as today. +- **Parity is only as good as the claims re-expressed**; the plan lists each QuickTest file's + replacement so a dropped claim is visible in review. diff --git a/docs/superpowers/specs/2026-10-04-qtquick-frontend-design.md b/docs/superpowers/specs/2026-10-04-qtquick-frontend-design.md new file mode 100644 index 000000000..e138500c7 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-qtquick-frontend-design.md @@ -0,0 +1,210 @@ +# The Qt Quick renderer: `morph::qt_quick` — design (spec 3) + +The primary renderer of the program, native and WebAssembly. It turns a UI document (spec 5) into +generated QML, connects that QML to the values the document interpreter computes in C++, and loads +the application's custom components. Qt does the layout, the input handling and the painting; the +document interpreter does every evaluation; the generated QML holds bindings and event calls and +nothing else. + +## Contents + +[1 Packaging](#1-packaging) · [2 Pipeline](#2-pipeline) · [3 Generating QML](#3-generating-qml) · +[4 Values and events](#4-values-and-events) · [5 Lists](#5-lists) · [6 Components](#6-components) · +[7 Controlled widgets](#7-controlled-widgets) · [8 Layout](#8-layout) · [9 The frontend](#9-the-frontend) · +[10 WebAssembly](#10-webassembly) · [11 Tests](#11-tests) · [12 Risks](#12-risks) + +## 1. Packaging + +- **`morph_qt_quick`**, alias **`morph::qt_quick`**: a compiled STATIC library gated by + **`MORPH_BUILD_QT_QUICK`** (default OFF; requires `MORPH_BUILD_QT` for `QtExecutor`), linking + `morph`, `morph::qt` and Qt 6.5+ `Quick`, `QuickControls2` and `Qml` PUBLIC. +- Public headers in `include/morph/qt_quick/`: `frontend.hpp` (`qt_quick::Frontend`, + `qt_quick::frontendOption`), `host_components.hpp` (`qt_quick::HostComponents`, `HostCommands`, `HostValues` and + `ScreenHostRegistry`; spec 6 §6, §14, §15) and + `renderer.hpp` (`qt_quick::Renderer`, for embedding a document + view in an existing Qt Quick application). Sources and the private QML module `MorphUi` (the + built-in kinds' base components) live in `src/qt_quick/`. +- Registered as an optional component: header-set check, install and export as component + `qt_quick`, `find_dependency(Qt6 6.5 COMPONENTS Quick QuickControls2 Qml)` in + `morphConfig.cmake.in`, warnings and sanitizers, and the CI jobs that build Qt. The package version + file is not `ARCH_INDEPENDENT` when a compiled component is installed. +- `qt_quick::Renderer` implements `ui::ScreenHost` (spec 6 §4): a host with its own shell mounts a + screen by id and params and receives a handle (`title`, `dirty`, `requestClose`) and the screen's + root `QQuickItem`. `qt_quick::HostComponents` registers the application's named components + (spec 6 §6). +- CONTRIBUTING states the rule this relies on: morph is header-only, except optional components that + wrap a compiled toolkit. + +## 2. Pipeline + +```text +document ──interpreter (spec 5 §2)──► values, lists, command sinks (C++) + │ ▲ │ + └──generator──► QML text ──QQmlComponent──┤ ▼ + bindings read values; events call sinks +``` + +1. The interpreter mounts the document: every bound property becomes a reactive value with a stable + **slot id**, every list a list model, every event a command sink. +2. The generator writes one QML document per screen scope and per row template, referring to slots + by id. +3. The renderer compiles the text with `QQmlComponent::setData` in the screen's engine and + instantiates it with the screen's value object, list models and action object in its context. +4. Generated text is cached by the hash of the document and the generator version, so a screen + opened twice compiles once. + +## 3. Generating QML + +Each node kind maps to one `MorphUi` base component (a thin wrapper over a Controls type that fixes +its property and signal names), or to the custom component the bundle maps it to (§6): + +```qml +// document: { "kind": "button", "label": "Deposit", "enabled": , "onClick": [ { "run": "deposit" } ] } +MorphButton { text: "Deposit"; enabled: v.s17; onActivated: a.fire(18) } +``` + +- A constant becomes a QML literal, escaped. A bound property becomes `v.s`. +- An event becomes `a.fire()`, passing the event's payload (`a.fire(21, text)`). +- `switch` becomes a `Loader` whose `sourceComponent` follows the selector slot; each case is an + inline `component`. `tabs` becomes a `TabBar` over a `StackLayout` whose pages load lazily. + `dialog` becomes a modal `Popup` whose `visible` follows its `open` slot. +- `forEach` and `table` become views over list models (§5), with the row template as the delegate. +- A node's `a11y` and `testId` become `Accessible.name`, `Accessible.role` and `objectName`; a node + bound to a form field takes its accessible name from the field's label unless the document sets + one. Automated UI tests locate elements by these (spec 6 §6). +- The generator emits no JavaScript other than the event calls. The output passes `qmllint`, which + its tests run over every golden. + +## 4. Values and events + +- **The value object** is one `QObject` per screen scope exposing every slot as a property with a + change signal (a `QQmlPropertyMap`, or a generated meta-object when the property count is large). + When the interpreter's value for a slot changes, the renderer writes the property; Qt re-evaluates + the bindings that read it. Writes happen after each reactive flush, in one batch, so a flush is one + frame. +- **Value conversion.** `string`, `bool` and `key` cross as `QString` and `bool`. `int`, `decimal` + and `quantity` cross **already formatted** by the expression that binds them, or as exact text, + never as a JavaScript number. A custom component's `number` prop is the only double (spec 5 §10). +- **The action object** exposes `fire(sink, payload)`. Every call runs inside the reactive runtime's + `widgetEvent`; the commands it triggers are applied in one batch, and their flush is posted, so a + remount never destroys an item whose signal handler is on the stack. +- **Nested event loops.** A flush requested inside a widget event is deferred until the outermost + widget event returns. A modal native dialog that spins a nested loop therefore neither blocks the + flush forever nor runs it under its own handler. + +## 5. Lists + +- Every `forEach` and `table` is a `QAbstractListModel` owned by the renderer. Its rows are the + interpreter's row scopes; its roles are the slots of the row template. +- A list change is applied as keyed operations — `beginInsertRows`, `beginRemoveRows`, + `beginMoveRows`, and `dataChanged` for rows whose values changed — computed from the old and new + key order. Row identity, focus, selection and scroll position survive a refetch and a reorder. +- Keys cross as text (`i:` or `s:`), so no id becomes a double. +- An editable cell writes through its row scope's command sinks; its value role reflects the draft. +- A `table` over the table engine (spec 7) receives the engine's `ViewChange` as these keyed + operations, so the engine's view order, not the model's, is what the user sees, and selection, + focus and scroll survive a sort or filter. + +## 6. Components + +- **Built-in kinds** are `MorphUi` components compiled into the library. +- **Custom components** come from the bundle (spec 5 §10). The renderer loads each verified file into + a dedicated `QQmlEngine` for components, whose root context holds nothing but the component's + props and an event sink. A component that fails to compile, or whose hash does not verify, is + replaced by its fallback subtree, and the failure is reported once. +- **Host components** are QML components the application registered with the client. The renderer + loads them into the main engine, not the contained component engine, so they may import one + another and the `Theme` singleton. One that is missing, or registered at a lower version than the + bundle names, renders its fallback and is reported once. +- **Restyling a built-in kind** maps the kind's generated type name to the bundle's component or a + host component; the component must declare the base kind's props and events, which the renderer + checks on load; the server runs the same check when it registers the bundle. +- Components are cached by hash on disk. The QML engine's own disk cache keeps their compiled form. + +## 7. Controlled widgets + +The document is the single source of truth: a widget shows what its slot says, and a user action is +a request the document may refuse. + +- A `MorphUi` input component emits `edited`, `chosen`, `toggled` or `activated` and does **not** + change its own displayed state. After the event's commands run, the renderer re-asserts the slot's + value, so a refused change snaps back. Select, tabs, radio groups, checkboxes and sliders all + follow this rule. +- A text input does not re-assert while it has focus and its slot equals its text, so the cursor, + the selection and an in-progress input-method composition are kept. +- A dialog dismissed by Escape or a click outside fires its dismiss commands and re-asserts `open`: + a document that keeps it open keeps it open, and one that closes it can open it again. +- A table's double-click activates the row without toggling its selection. + +## 8. Layout + +- `Sizing` maps to `QtQuick.Layouts`: `Content` to implicit size, `Fixed(n)` to a preferred size of + `n` units, `Stretch(w)` to fill with stretch factor `w`. A unit is the control font's average + character width horizontally and its line height vertically, measured from the style's font. +- `grid` spans map to `Layout.columnSpan`; gaps to spacing. +- `{"env": "widthClass"}` follows the window's width: below 600 logical pixels `compact`, below 1200 + `medium`, else `expanded`. Expressions over it re-evaluate when the class changes, not on every + resize. +- `stale` dims an element; `errors` shows under it in the error colour; `readonly` uses the + control's read-only state rather than disabling it, so its text stays selectable. +- **Theme.** The `MorphUi` components read one `Theme` singleton, filled from the bundle's + `ui-theme/1` tokens (colour roles, spacing, radius and type scales, density; spec 6 §6), with the + `MorphUi` defaults for any token the bundle omits. Text roles map to the theme's colour roles. The + style is `Basic` unless the application's bundle maps kinds to its own components. + +## 9. The frontend + +`qt_quick::Frontend(int& argc, char** argv)`: + +- `run(…)` constructs `QGuiApplication`, a `QtExecutor` (the reactive runtime's owner and every + bridge's callback executor), the runtime, the engines and the window; connects (spec 5 §12); + opens the app shell; and runs the event loop. On return it destroys the screens, then the + connection, then the runtime, then Qt. +- `Scheduler` is `QTimer`-based; a `TimerHandle` stops its timer on destruction. +- `quit(code)` calls `QCoreApplication::exit(code)`; closing the window quits with 0. +- `qt_quick::frontendOption` is named `"qt"`. It is usable on macOS, Windows and WebAssembly, and + elsewhere when `DISPLAY` or `WAYLAND_DISPLAY` is set. +- Rendering uses the platform's default graphics API. Tests and headless runs select the software + renderer explicitly. + +## 10. WebAssembly + +- The same library builds under Emscripten, single-threaded: `QtExecutor` is the only executor and + the I/O loop is host-driven. The table engine runs as cooperative chunks on the owner (spec 7 §8). +- The frontend's state lives on the heap and is released at page unload, because Qt's WebAssembly + event loop does not return to `run`'s frame. +- Generated QML and custom components are compiled at runtime, so the QML modules they import — + `QtQuick.Controls` and its `Basic` style, `QtQuick.Layouts`, `QtQuick.Templates` — must be linked + into the static WebAssembly client explicitly. The client's CMake imports them by name with + `qt_import_qml_plugins`, because no QML file in the binary imports them for the scanner to find. + A WebAssembly smoke executable built and linked in CI proves it. +- The file picker uses `QFileDialog::getOpenFileContent`, because a browser exposes file contents, + not paths. + +## 11. Tests + +`tests/qt_quick/`, registered under `MORPH_BUILD_QT_QUICK`, offscreen with the software renderer: + +- **Generator goldens:** every node kind and every expression shape of the spec 5 corpus produces + the expected QML, and every golden passes `qmllint`. +- **Behaviour on the real engine:** mount a document, drive it with synthesized input, observe item + properties: a bound property follows its slot; one frame per flush; a refused selection snaps + back; typing keeps the cursor; dismiss and reopen a dialog. +- **Lists:** insert, remove, move and update produce the minimal model operations; an int64 key + above 2^53 survives a round trip; focus survives a reorder. +- **Server-calculated values:** spec 5 §7's scenarios against a fake server, observed through the + rendered cells, including `stale`. +- **Components:** a custom component receives its props and fires its events; a hash mismatch, a + compile error and a missing component render the fallback; a restyle mapping with a missing prop is + refused on load. +- **Frontend:** run, quit, destruction order; `frontendOption().usable()` follows the environment. +- **WebAssembly:** the smoke executable builds and links in the WebAssembly CI job. + +## 12. Risks + +- **Runtime QML compilation** costs startup time per screen. The generated-text cache and Qt's disk + cache bound it to the first open of each screen version. +- **The generator is a second compiler** whose output Qt must accept. `qmllint` over every golden + and the behaviour tests on the real engine are its guard. +- **Custom components can contain arbitrary JavaScript.** Containment (§6) limits what they reach; + their correctness is the application's. diff --git a/docs/superpowers/specs/2026-10-07-ui-document-design.md b/docs/superpowers/specs/2026-10-07-ui-document-design.md new file mode 100644 index 000000000..2a9f78ef4 --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-ui-document-design.md @@ -0,0 +1,753 @@ +# The UI document: server-defined screens for a generic client — design (spec 5) + +A morph server defines its screens as data. Each screen is a **UI document**: JSON that declares the +screen's state, the models it talks to, the queries and mutations it runs, the relations between its +elements, and its view tree. A generic client fetches the document at runtime, evaluates it with the +reactive core of spec 1, and renders it: as generated QML on the desktop and in the browser (spec 3), +and on a best-effort basis in a terminal. A screen changes when the server changes, without a new +client build, and a client and a server of different versions agree on what both understand. + +This spec defines the document — its structure, its expression language, its control, relation and +command vocabulary, custom components, server-side authoring, and delivery. It is the view +representation of the program: spec 1 §5 refers here. + +## Contents + +[1 Intent](#1-intent) · [2 Architecture](#2-architecture) · [3 Structure](#3-document-structure) · +[4 Expressions](#4-expressions) · [5 Models, queries, mutations](#5-models-queries-and-mutations) · +[6 Relations](#6-relations-between-elements) · [7 Server-calculated values](#7-server-calculated-values) · +[8 Commands](#8-commands) · [9 View nodes](#9-view-nodes) · [10 Custom components](#10-custom-components) · +[11 Authoring](#11-authoring-on-the-server) · [12 Delivery](#12-delivery-versioning-and-discovery) · +[12b Permissions and customization](#12b-permissions-and-per-user-customization) · +[13 Security](#13-security) · [14 Tests](#14-tests) · [15 Out of scope](#15-out-of-scope) + +## 1. Intent + +**Goals:** + +- **The server owns the UI.** Screens are emitted by the server at runtime, so the server and the + client can be released independently; the handshake (§12) settles what both understand. +- **Desktop and web first.** The primary renderer is Qt Quick, native and WebAssembly. A terminal + renderer covers what it can. +- **Live server calculation is the primary interaction.** A user edits one value of a row; the + client sends the row to a server action; the reply updates the values the server calculates (§7). +- **Complex relations between elements** — visibility, options, validation, derived values and + cascades that depend on other elements — are declared, not coded (§6). +- **Several models in one screen or dialog**, each addressed by name (§5). +- **Custom components** give screens a designed look and may carry their own logic, such as a chart + (§10). + +**Decisions:** + +- **JSON travels, QML is generated.** The wire format is the document; the client generates QML from + it. A document can be validated and versioned before anything runs, a non-Qt renderer (a terminal, + a future HTML renderer) reads the same document, and no number passes through QML's JavaScript + doubles. Delivered code is limited to custom components. +- **Every expression is evaluated in C++ on the client.** QML binds only to finished values. Exact + integers, decimals and quantities stay exact. +- **UI state lives on the client.** The server is stateless with respect to the UI: no per-session + view process, no round trip per keystroke, and the offline queue keeps working. Server-side view + state (the LiveView model) is rejected for those reasons. +- **What the expression library cannot express is computed by the server** and returned as a field + of an action's reply (§7). The library stays small, total and versioned. +- **The client is generic.** One client binary serves every application. An application that runs + without a server runs its server parts in-process (local mode, §2) and is rendered the same way. + +## 2. Architecture + +```text +server (or in-process, local mode) client (one generic binary) + models + actions ─────────────┐ ┌─ document interpreter (morph::reactive) + screen definitions (C++, §11) ├─ UI registry ──┤ values, scopes, queries, mutations + custom components (QML, §10) ─┘ │ ├─ forms engine (spec 2): form nodes + │ ├─ dispatch: Bridge::executeJson, model-free + handshake, catalog, ui envelopes └─ renderers: QML generator (spec 3), + terminal (best effort) +``` + +- **Model-free dispatch.** The client sends `(model type, instance, action type, JSON body)` through + `Bridge` and receives the JSON reply, with the session, timeout, cancellation and in-flight + accounting `Bridge` applies to typed calls. The client compiles in no model or action type. This + needs seams `Bridge` does not have today (below). +- **Local mode.** A desktop application without a server links its models and screen definitions + into the client process: the UI registry is read in-process and dispatch goes to `LocalBackend`. + The document and its rendering are identical to remote mode. +- **The interpreter** turns a document into reactive nodes (spec 1 §3–4b): state into `Signal`s, + `let` and every bound property into `Computed`s, queries into `Query`s, mutations into `Mutation`s, + relations into `Computed`s and `Effect`s. Renderers observe those values; they evaluate nothing. + +### Bridge seams this design adds + +`BridgeHandler::executeJson` needs a handler built for a concrete `Model`, and a handler is +created only through templates over `Model`. A client that compiles in no model needs three additions: + +- **A binding by type id.** `Bridge::bindByType(typeId, sharing, instanceKey)` returns a + `HandlerBinding` keyed by the model's registered type id string, with the same registration, + deregistration, owner principal and `switchBackend` re-binding as a typed handler. Local mode + resolves the type id in the model registry; a remote backend already carries it as a string. +- **A raw execute.** `Bridge::executeRaw(binding, actionId, bodyJson)` returns the JSON reply with the + session, timeout, cancellation and in-flight accounting of a typed call. It does not run the + client-side `recomputeAll` or `ActionValidator`, which need the action type: the forms engine + validates in the client (spec 2), and the server validates every call. +- **A string-keyed attach.** `attach(binding, instanceKey)` for a shared instance, as + `attachHandler` does with a typed key. + +The catalog tells the client, per model type, whether it is **stateless** (a shared singleton is +equivalent to a private instance) and whether it is **keyed** (it can be shared by an instance key). A +model that is not keyed cannot be given an `instance` expression; the document is refused at load. +These seams, the `ui` envelope kinds of §12 and their authorisation rules are specified in the wire +and bridge documents when the interpreter lands, and come before it in the delivery order. + +## 3. Document structure + +A **bundle** is everything an application serves: its screens, its app shell (menu and routes, the +`app-*` vocabulary of `forms/engine/app_shell.hpp`), and its custom components. A **screen** document: + +```json +{ "vocab": "ui/1", "id": "lims.results", "title": { "t": "lims.results.title", "fallback": "Results" }, + "params": { "sampleId": "int" }, + "models": { ... }, "state": { ... }, "let": { ... }, + "queries": { ... }, "mutations": { ... }, "watch": [ ... ], "checks": [ ... ], + "view": { ... } } +``` + +- **Names.** Every declaration has a name matching `[A-Za-z_][A-Za-z0-9_]*`, unique within its + scope. A reference resolves in the innermost scope first, then outward. +- **Scopes.** The screen is a scope. So is every dialog, every `switch` case, and every row of a + `forEach` or `table`. Any scope may declare `models`, `state`, `let`, `queries`, `mutations`, + `watch` and `checks`. A scope is created when its content mounts and destroyed when it unmounts. A + row scope is keyed by its row key, so its state survives reordering and is kept while the row + exists. +- **Params** are the screen's typed inputs, set by navigation (§8). +- **Lifecycle keys.** A screen may also declare `title` (an expression), `dirty` (a bool expression), + `identity` (an expression; equal identities are one screen), `onMount`, `onUnmount`, `onClose` + (command lists; `{"cancelClose": true}` is valid only in `onClose`), `onSave` and `onBackendChange`. Their + semantics, and the host's side of them, are spec 6 §3–4. These keys, the commands `cancelClose`, + `signIn` and `signOut`, and the node and expression additions of spec 6 are part of `ui/1`. +- **Types.** `bool`, `int` (int64, exact), `string`, `decimal` (`{num, den, dp}`, exact), + `quantity` (`{num, den, dp, unit}`), `timestamp` (UTC), `key` (`int` or `string`), `list`, + records, and `T?` for an optional value (`null`). Query and mutation results take their record + types from the action schemas the server already emits; a document refers to them by action id + and never restates them. +- **Load-time validation.** A client refuses a document before mounting it when a reference does not + resolve, a type does not check, the `let` graph or the `watch` graph has a cycle (§6), an + expression exceeds the size bound (§4), or a node kind or function is not in the document's + `vocab`. The refusal names the document, the path inside it and the reason. The server runs the + same validation when a screen is registered (§11), so a refused document is a server defect found + in the server's own tests. + +## 4. Expressions + +An expression is a JSON tree. Every bound property, query body, condition and command argument is +one. + +| Form | Meaning | +|---|---| +| a JSON scalar, or `{"lit": v}` | A constant. `lit` wraps an object or array. | +| `{"ref": "name.path"}` | A param, state, `let`, element value (§6) or record field, by name then path. | +| `{"row": "path"}` | A field of the innermost row. | +| `{"query": "q"}`, `{"pending": "q"}`, `{"error": "q"}` | A query's value, whether it is in flight, its error text. | +| `{"result": "m"}`, `{"pending": "m"}`, `{"error": "m"}` | A mutation's last result, in flight, error. | +| `{"can": "m"}` | Whether the signed-in principal may run mutation or query `m` (§12b). | +| `{"pref": "point.path"}` | The user's choice at a customization point (§12b). | +| `{"fn": "name", "args": [e, ...]}` | A library function (below). | +| `{"t": "key", "fallback": "text", "args": [e, ...]}` | The text of `t(key, fallback, args...)`: shorthand for the library function. | +| `{"ref": "event"}` | The payload of the event whose command list is running: the new text, the chosen key, the dropped payload. Valid only inside a command list. | +| `{"if": [cond, then, else]}` | Choice. | +| `{"obj": {"k": e}}`, `{"list": [e, ...]}` | Constructors, used for action bodies. | +| `{"map": e, "as": "x", "to": e}`, `{"filter": e, "as": "x", "where": e}` | Comprehensions over a list. | +| `{"env": "widthClass"}` | Client environment: `widthClass` (`compact`, `medium`, `expanded`), `locale`, `platform`, `online`, `backend` (spec 6 §5, §9). | +| `{"errorKind": "m"}` | The kind of query or mutation `m`'s error: `notFound`, `validation`, `connectionLost`, `denied`, `conflict`, `backendChanged`, `other` (spec 6 §10). | +| `{"session": "displayName"}` | A session attribute: `principal`, `userName`, `displayName`, `userKey`, `tenant`, `authenticated`. | +| `{"ref": "app.name"}` | A name of the app document (spec 6 §5). | +| `{"host": "name"}` | The value an application registered with the client (`host/1`, spec 6 §14); `null` when the client lacks it. | + +**Semantics.** + +- Expressions are pure and total. There is no recursion, no user-defined function and no loop other + than a comprehension over a list the data already holds, so evaluation always ends. +- **Absence propagates.** A function whose required argument is `null` yields `null`. Logic is + three-valued, as `x-rules` is: `and`, `or` and `not` follow Kleene logic over `true`, `false` and + `null`. A condition that is `null` presents as its declared default (§6). +- **Errors are values.** Overflow, division by zero or a type mismatch at runtime yields `null` and + is reported once per expression path on the client's diagnostic channel; it never throws into a + renderer. +- **Size bound.** A document whose single expression exceeds 256 nodes is refused at load. + +**Function library `expr/1`.** Every function is exact where its inputs are: + +- logic and comparison: `and`, `or`, `not`, `eq`, `ne`, `lt`, `le`, `gt`, `ge` — comparing `int`, + `decimal` and `quantity` exactly, converting quantity units by their declared exact factors; +- arithmetic: `add`, `sub`, `mul`, `div`, `neg`, `round(x, dp)` over `int`, `decimal`, `quantity`; +- optional: `engaged`, `coalesce`; +- string: `concat`, `format(template, args...)`, `trim`, `length`, `isEmpty`, `contains`; +- list: `count`, `sum`, `mean`, `min`, `max`, `any`, `all`, `first`, `includes`, `indexOf` + (`mean` is the exact sum over the count, at the operands' declared places, rounded half to even); +- display: `number`, `decimal(x, dp)`, `quantity(x, unit?)`, `money(x, currency)`, + `date(x, style)`, `dateTime(x, style)` — using the client's locale and display zone through + `render/locale_format.hpp`; +- text: `t(key, fallback, args...)`, the i18n lookup of `render/i18n.hpp` (a `TranslationProvider` + over `(key, locale)`). + +The forms engine's `x-rules` (spec 2 §3) translate into this language, so a client has one +evaluator. + +## 5. Models, queries and mutations + +### Models + +```json +"models": { + "sample": { "type": "Lims_SampleModel", "instance": { "ref": "sampleId" } }, + "catalog": { "type": "Lims_AnalysisCatalogModel" }, + "clients": { "type": "Lims_ClientModel" } +} +``` + +- A scope names every model it uses, under an alias. A screen or a dialog may use any number of + models; each query and mutation names the alias it goes through. +- `instance` is `"private"`, `"shared"` or an expression: + - an expression selects a shared model instance (the bridge's shared-instance key); when it + changes, the alias rebinds, and the queries through it refetch. The model must be keyed; + - `"private"` binds an instance of its own to the scope, so the model's state, such as an open + record, is that scope's alone; it is released when the scope unmounts (spec 6 §3); + - `"shared"` is the model's singleton and needs no key. + An alias that names none is `"shared"` when the catalog marks the model stateless and `"private"` + otherwise. +- A private instance costs a server-side model instance and, for a database-backed model, its own + connection. A row scope's alias must therefore name an `instance` (a document that leaves it out + there is refused at load), and the server's `maxLiveModels` limit is the brake on the total. +- A shared instance runs on one strand, so calls through it serialise, whichever scopes make them. +- A model handle belongs to the scope that declares it. A dialog that declares its own handle + releases it when it closes. + +### Queries + +```json +"results": { "model": "sample", "action": "Lims_ListResults", + "body": { "obj": { "sampleId": { "ref": "sampleId" } } }, + "when": true, "debounce": 0, "refreshOn": ["Lims_CaptureConcentration"], "refreshEvery": 0 } +``` + +A query is spec 1 §4b's `Query`, declared as data: + +- `body` is the key. When it changes, the query fetches under a new generation; **the latest + request wins** and a reply from an older generation is dropped. +- `when` false, or a `body` that evaluates to `null`, makes the query idle. +- `debounce` delays a fetch until the key has been stable for that many milliseconds. +- The last value is kept while a refetch is in flight, so lists do not blank. +- `refreshOn` names actions of the query's own model alias: the query refetches when a call to one of + them, made through that alias by this client, completes successfully. It is the interpreter's own + bookkeeping over its dispatch, not a bridge subscription, so it works without the result type and + two actions with one reply type do not trigger each other. A change made by another client is not + pushed; `refreshEvery` is the polling answer, and it refetches on a timer and **skips a tick while a + request is in flight**. + +### Mutations + +```json +"capture": { "model": "sample", "action": "Lims_CaptureConcentration", "body": { ... }, + "concurrency": "exclusive", "invalidates": ["results"], + "patch": { "query": "results", "key": { "ref": "result.id" }, "with": { "ref": "result" } }, + "onSuccess": [ ... ], "onError": [ ... ] } +``` + +- `concurrency` decides what a run does while another is in flight: + - `exclusive` (default): the run is refused. Elements bound to `{"pending": "capture"}` disable. + - `serial`: runs are queued and sent in order; `serialKey` (an expression) makes one queue per key. + - `latest`: every run is sent; only the newest reply is applied. + - `parallel`: every run is sent; replies are applied as they arrive. +- `invalidates` refetches the named queries after a success, in one batch with the result, so the + screen updates in one frame. +- `patch` replaces one row of a query's cached list (the row whose key equals `key`) with `with`, + without refetching. `key` and `with` are evaluated against the reply, bound as `result`. +- `onSuccess` and `onError` are command lists (§8). The reply is readable as `{"result": "capture"}` + until the next run. +- `"offline": "queue"` sends a run that cannot reach the server through the client's offline queue, + which replays it on reconnect; the mutation stays `pending` until the replay settles, and its + elements show `stale`. Without it, a run while offline fails at once. Offline queuing is part of + `ui/1`; a replay the server rejects runs the mutation's `onError`, as an online failure would. A + mutation that sends a credential or returns a token is never queued: it is refused at load with + `"offline": "queue"`. +- A reply field the server marks `secret` (a session token) is never readable as `{"result": "m"}` + after the mutation's own `onSuccess` has run, and is left out of diagnostics, the offline queue and + journals. + +### Several models in one dialog + +A dialog that registers a client, then a sample for that client, then schedules its analyses, uses +three models and one sequence: + +```json +"onClick": { "seq": [ + { "run": "createClient", "as": "c" }, + { "run": "registerSample", "body": { "obj": { "clientId": { "ref": "c.id" }, "matrix": { "ref": "matrix.value" } } }, "as": "s" }, + { "all": [ { "run": "scheduleAnalysis", "each": { "ref": "analyses.selection" }, "as": "a", + "body": { "obj": { "sampleId": { "ref": "s.id" }, "analysisId": { "ref": "a" } } } } ] }, + { "close": "registerDialog" } ] } +``` + +- Each step's reply is bound under its `as` name for later steps. +- A failed step stops the sequence and runs the failing mutation's `onError`. Steps already applied + stay applied: a sequence across models **is not atomic**. +- When the steps must commit together, the server exposes one action that performs them, and the + dialog runs that action. Atomicity is a server decision, never a client sequence. Composite + actions that span several models in one transaction are not part of this design. + +## 6. Relations between elements + +Every element may carry an `id`. An element's live values are addressable as `{"ref": "id.value"}`: + +| Element | Values | +|---|---| +| any | `visible`, `enabled`, `valid`, `errors` | +| text input, date, slider | `value`, `draft`, `dirty`, `focused` | +| checkbox | `checked` | +| select | `selected`, `selectedOption` | +| table | `selection`, `activeKey`, `sort`, `filters`, `viewCount`, `sourceCount`, `selectedCount`, `pending`, `error`, `hoveredKey` (spec 7 §12) | +| dialog, drawer | `open` | +| a transfer (`upload`, `download` with an `id`) | `progress` (0 to 1, or `null`), `pending`, `error` (spec 6 §18) | +| form (spec 2) | each field's `value`, `draft`, `valid`, `required`, and the form's `ready`, `body` | + +Relations are expressions over those values and the scope's declarations: + +| Relation | Declared as | +|---|---| +| show, enable, require, make read-only | the element's `visible`, `enabled`, `required`, `readonly` | +| options that depend on another element | `options` from a query whose `body` references it; `onStale: "clear" \| "keep"` decides what happens to a selection the new options no longer contain | +| a value derived on the client | a `let` entry; `let` entries may reference each other | +| a value derived by the server | a query or mutation (§7) | +| validation across elements | a `checks` entry (below) | +| a cascade (A changes, so B resets) | a `watch` entry (below) | +| an aggregate over rows | a `let` with `sum`, `count`, `map`, `filter` over a query's rows | + +**Checks.** + +```json +"checks": [ { "id": "dilutionRange", "assert": { "fn": "le", "args": [ { "ref": "dilution.value" }, { "ref": "limits.maxDilution" } ] }, + "message": { "t": "lims.dilution.tooHigh", "fallback": "Dilution above the method limit" }, + "targets": ["dilution"], "blocking": true } ] +``` + +A check fails when `assert` is `false`; `null` does not fail it, because an incomplete input blocks +through `required` instead. A failing check adds its message to each target's `errors` and clears +its `valid`. A dialog's or form's `ready` holds when every required element is engaged, every element +is valid and no blocking check fails. The server re-validates every action it receives: client checks +are guidance, never authority. + +**Watches.** + +```json +"watch": [ { "on": { "ref": "analysis.selected" }, "source": "user", + "do": [ { "set": "unit", "value": null }, { "set": "dilution", "value": null } ] } ] +``` + +A watch runs its commands when the value of `on` changes; never on the first evaluation. +`source: "user"` restricts it to changes caused by the user's own edits, so a server patch or a +prefill does not trigger the cascade; `source: "any"` (the default) runs for every change. Watches +run after the flush that changed the value, in declaration order, as one batch. + +**Ordering and cycles.** + +- The `let` graph must be acyclic. +- A `watch` that writes state another `watch` observes forms an edge. A cycle among watches is + refused at load. At runtime, the reactive core's per-flush effect limit stops a cycle that only + data can create. + +## 7. Server-calculated values + +**The use case.** A LIMS result row holds ten values. The user edits one. The client sends the row's +values to a declared server action. The server calculates the derived quantities, such as a +concentration from mass, volume and dilution, and replies. The client updates the calculated cells of +that row. Calculation stays on the server, where it is authoritative and has access to model state; +the client shows the result as soon as it arrives. + +Two declarations cover it. + +**Preview: calculate without saving.** The row scope keeps a draft and runs a query keyed on it: + +```json +{ "kind": "table", "rows": { "query": "results" }, "key": { "row": "id" }, + "scope": { + "state": { "draft": { "init": { "row": "inputs" } } }, + "queries": { "calc": { "model": "sample", "action": "Lims_EvaluateResult", "debounce": 300, + "body": { "obj": { "resultId": { "row": "id" }, "inputs": { "ref": "draft" } } } } } }, + "columns": [ + { "label": "Mass", "cell": { "kind": "textInput", "id": "mass", "commitOn": "change", + "value": { "ref": "draft.mass" }, "onChange": { "set": "draft.mass", "value": { "ref": "event" } } } }, + { "label": "Result", "cell": { "kind": "text", + "text": { "fn": "quantity", "args": [ { "fn": "coalesce", "args": [ { "ref": "calc.concentration" }, { "row": "concentration" } ] } ] }, + "stale": { "pending": "calc" } } } ] } +``` + +**Write-through: save each edit.** Committing a value runs a mutation that persists it and replies +with the recalculated row. `concurrency: "serial"` with `serialKey` set to the row id sends one row's +edits in order. The mutation's `patch` replaces the row in the list, so the calculated cells update +without refetching the list. + +**Rules for both:** + +1. **Ownership.** Inputs are client-owned drafts. Calculated values are server-owned. A reply never + overwrites an input the user changed after the request was sent: each request carries the + draft's generation, and the client applies a reply's input values only to inputs whose draft is + unchanged since that generation. The server may normalise an untouched input, such as rounding it + to its declared decimals. +2. **Triggering.** An input's `commitOn` is `change`, `blur` or `enter`. `debounce` on the query + batches fast typing into one request. +3. **Latest wins, per row.** A reply from a superseded request is dropped. Rows are independent: + editing row B while row A is in flight affects neither. +4. **Staleness.** While a calculation is in flight, its cells keep the last value and carry `stale`, + which the renderer shows, for example by dimming. They never blank. +5. **Errors.** A reply's field errors (`errors: {field: message}` in the action's result) attach to + the row's elements of the same name. A transport failure becomes the row's error and keeps the + stale value. +6. **Exactness.** Inputs and results travel as `int`, `decimal` and `quantity`, and are formatted in + C++. +7. **Offline.** A preview that cannot reach the server leaves the cells stale and shows the row's + error. A write-through edit goes through the client's offline queue when its mutation declares + `"offline": "queue"` (§5). + +**Preview through `evaluate`, with no dedicated action.** When the calculation depends only on the +action's own fields — its `computedFields` — a query asks the server to evaluate a body of the action +the row will eventually run: + +```json +"calc": { "model": "sample", "evaluate": "Lims_CaptureConcentration", "debounce": 300, + "body": { "obj": { "resultId": { "row": "id" }, "mass": { "ref": "draft.mass" }, + "volume": { "ref": "draft.volume" }, "dilution": { "ref": "draft.dilution" } } } } +``` + +The server answers an `evaluate` envelope by decoding the body, running `recomputeAll` and the +action's validation, and replying with the body, its computed fields filled, and its field errors. +It never calls the model's `execute`, so an evaluation has no effect and can be repeated freely. It +is authorised as the action itself: a principal who may not run the action may not evaluate it. An +evaluation writes no journal or action-log entry, counts against the server's in-flight and rate +limits per principal, and is not protected by the client's `debounce`, which only spares an honest +client. A calculation that reads model state — a calibration, a method limit — is a dedicated action +whose model computes it. + +**On the server,** every calculation is authoritative: `computedFields` are recomputed on every +execute and every evaluation, whatever the client sent. + +## 8. Commands + +| Command | Effect | +|---|---| +| `{"set": "name.path", "value": e}` | Writes state, or an element's draft. | +| `{"reset": "name"}` | Restores state to its `init`. | +| `{"run": "m", "body": e?, "as": "x"?, "each": e?}` | Runs a mutation, optionally with a body override, once per element of `each`. | +| `{"refetch": "q"}` | Refetches a query. | +| `{"open": "dialog"}`, `{"close": "dialog"}` | Opens or closes a dialog. | +| `{"navigate": "screen", "params": e, "as": "tab"\|"dialog"\|"drawer"\|"window", "onResult": [...], "onCancel": [...]}` | Opens another screen: as a tab the shell decides (spec 6 §7), as a dialog, drawer or window it can answer (spec 6 §16). | +| `{"return": e}`, `{"close": "self"}` | Ends a screen opened as a dialog, drawer or window: the opener's `onResult` gets `e`, or its `onCancel` runs. | +| `{"focus": "id"}` | Moves keyboard focus to a node (spec 6 §17). | +| `{"host": "name", "args": e?}` | Runs a command an application registered with the client (`host/1`, spec 6 §14). | +| `{"switchBackend": "name"}` | Asks the application's `AppSource` to switch to a backend it lists (spec 6 §9). | +| `{"resetPrefs": "point"\|"all"}` | Restores the document's defaults for a customization point, or for all of them (§12b). | +| `{"setLocale": "de"}` | Changes the active locale and re-resolves every `t`; stored as a preference (spec 6 §19). | +| `{"notify": e, "role": "success"}` | Shows a transient message. | +| `{"seq": [...]}`, `{"all": [...]}` | Runs commands in order, stopping at a failure; or together, waiting for all. | +| `{"if": [cond, [...], [...]]}` | Runs one branch. | +| `{"cancelClose": true}` | Keeps the screen open; valid only in `onClose` (spec 6 §3). | +| `{"signIn": {"from": "m"}}`, `{"signOut": true}` | Installs the session token mutation `m` replied with and reloads the catalog; or ends the session and unmounts every screen (spec 6 §5). | +| `{"upload": e, "to": "m", "as": "x"?, "id": "t"?}` | Sends a file the user picked or dropped through the server's file side channel, then runs mutation `m` with its reference (vocabulary `files/1`); `id` names the transfer for `progress`. | +| `{"download": e, "save": "dialog", "name": e?}` or `{"download": e, "to": "preview", "as": "url"}` | Fetches a file by reference through the side channel and lets the user save it, or binds a local temporary reference to state `url` for a viewer (`files/1`, spec 6 §18). | + +A command list triggered by one event runs in one batch: the screen updates once. + +## 9. View nodes + +This section is the one normative palette of the program (spec 1 §5 refers here). The kinds are 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, written as +`{"kind": "...", ...}` with every property an expression and every event a command list. + +**Common properties.** `id`, `visible`, `enabled`, `a11y`, `testId`, `tooltip`, `keys` and `autofocus` +(spec 6 §17), `surface` (a named token set for the subtree, spec 6 §6), `sizing` (`"content"`, +`{"fixed": n}` or `{"stretch": w}`; units are spec 3 §8's), `gap` on containers, and `columns` and +`span` on grids. + +**Events** (command lists; the payload is `{"ref": "event"}`): `onClick` and `onActivate` (button, +row, menu item), `onChange` (every edit), `onCommit` (Enter or loss of focus), `onToggle`, +`onChoose`, `onDismiss` (dialog and drawer), `onSubmit` (a single-line text input), `onPick` (file +picker), `onDrop` (a node with `drop`, or a `dropZone`), and `on: {name: [...]}` for a custom component's own events. + +**Drag and drop.** A node may declare `"drag": {"payload": e}` and `"drop": {"accepts": e, +"onDrop": [...]}`. A drag carries the payload, never a node; a drop target runs `onDrop` with the +payload as `{"ref": "event"}` when the dragged payload matches `accepts`. A file picker or a `dropZone` +panel also accepts files the operating system drops on it, as `{"ref": "event"}` references usable by +`upload` (`files/1`). A renderer that cannot drag ignores `drag` and `drop`. + +**Further kinds.** Each has a built-in fallback in the terminal renderer (a text line or a plain +list) and is restylable (§10). + +| Kind | Properties and events | +|---|---| +| `banner` | `tone`, `text`, `action` (`{label, onClick}`), `dismissible`, `onDismiss` | +| `badge` | `tone`, `text`, `icon` | +| `progress` | `value` (0 to 1; `null` is indeterminate), `label` | +| `steps` | `items` (`[{label, state}]`), `current` | +| `keyValue` | `items` (`[{label, value}]`) | +| `emptyState` | `title`, `text`, `icon`, `action` | +| `drawer` | As `dialog`, plus `side` (`start` or `end`) | +| `splitter` | `orientation`, `sizes` (the user's sizes are a `layout` customization point), children | +| `collapsible` | `title`, `open`, `onToggle`, `header` (nodes shown beside the title), children | +| `dropZone` | `accept`, `multiple`, `onDrop` (spec 6 §18) | +| `customize` | `point`: the editor of a customization point (spec 6 §19) | + +A `menu` item is `{label, icon, keys, checked, enabled, onClick, items}`; `items` nests a submenu and +`checked` makes the item checkable. A status bar, a timeline, a diff table, a readout tile and a +signature strip are compositions of the kinds above or host components, not kinds. `icon` names an +entry of the theme's icon map (spec 6 §6). + +The document adds: + +- `id` on any node (§6); `readonly`, `required`, `errors` and `stale` on inputs and text; +- `a11y` (`{"name": e, "role": "..."}`) and `testId` on any node: the accessible name and role, and a + stable identifier for automated tests (spec 6 §6); +- `form`: a schema form of the forms engine (spec 2), `{"kind": "form", "model": "sample", + "action": "Lims_CaptureConcentration", "prefill": e, "submit": "explicit"}`, whose fields are + addressable as elements, which may carry the `overrides` of spec 2 §7 and a `fields` overlay + (`omit`, `hidden`, `label`, `unit`, `decimals`, `widget`, `blankAs`) applied to the parsed form + model before the session is built (spec 6 §10); +- `collection` and `wizard`, the forms engine's list and wizard screens; +- `custom` (§10); +- editable cells: a table cell is any node, and its row scope is the row's. A `table` may also be + declared from a model's list action, with its columns derived from the row type and its sorting, + filtering, selection and paging provided by the table engine (spec 7); +- **adaptive layout:** `grid.columns`, `visible` and sizing accept expressions over + `{"env": "widthClass"}`, so one document lays out for a phone-width browser, a laptop and a large + screen. + +## 10. Custom components + +```json +"components": { + "ConcentrationChart": { + "qml": "sha256:4b1e...", + "props": { "series": "json", "title": "string", "limit": "exact" }, + "events": { "pointSelected": "key" }, + "fallback": { "kind": "table", "rows": { "ref": "props.series" }, "...": "..." } } } +``` + +```json +{ "kind": "custom", "name": "ConcentrationChart", + "props": { "series": { "ref": "trend" }, "title": "Trend", "limit": { "ref": "limits.max" } }, + "on": { "pointSelected": [ { "set": "selectedResult", "value": { "ref": "event" } } ] } } +``` + +- A component is a QML file delivered in the bundle and identified by its content hash. It receives + its declared props and emits its declared events. It may contain JavaScript for its own behaviour, + such as scaling, hit-testing or animation in a chart. +- **Prop types** say how a value crosses into QML: `string`, `bool`, `int` (as text, so no id loses + precision), `exact` (an exact decimal or quantity, as text), `number` (a double, for charts, where + rounding is acceptable), and `json` (structured data, exact values inside it as text). +- **Containment.** A component sees its props and nothing else: no session, no bridge, no other + element. Its only way out is its declared events, which run the document's commands. +- **Restyling built-in kinds.** The bundle's `restyle` map names a component for a built-in kind, + such as `"restyle": {"button": "BrandButton"}`; the component must have the same props and events, + which is checked when the server registers the bundle and again when the client loads it. +- **Host components.** A component may instead name one the client carries, `"host": "ResultGrid"` + with a `version`, in place of `"qml"`. It needs no hash and no verified TLS connection, because it + is part of the client binary; the handshake (§12) says which ones a client has. Theme tokens + (`ui-theme/1`) come first, host components second, delivered components third (spec 6 §6). Because + a document is data that loads on any connection the client accepts, a host component's props and + event payloads are untrusted input, and a component with a side effect is usable only from a + verified connection (§13). +- **Hover link.** A custom or host component may declare the event `pointHovered` (a key, or + `null`) and the prop `highlightKey`, so a chart and a table that share keys highlight each other: + the table's `hoveredKey` element value feeds the chart's `highlightKey`, and the chart's event + sets the table's `highlightKey`. +- **Fallback.** Every component names a fallback subtree of built-in nodes. A renderer that cannot + load the component — a terminal, a client without the right Qt version, a failed hash check — + renders the fallback. + +## 11. Authoring on the server + +Screens are written in C++ with typed builders that produce the document: + +```cpp +auto screen = ui::screen<"lims.results">([](ui::Scope& s) { + auto sampleId = s.param("sampleId"); + auto sample = s.model("sample", {.instance = sampleId}); + auto results = s.query(sample, {.sampleId = sampleId}); + return ui::table(results, ui::key(&ResultView::id), [&](ui::RowScope& r) { + auto draft = r.state("draft", r.field(&ResultView::inputs)); + auto calc = r.query(sample, {.resultId = r.field(&ResultView::id), .inputs = draft}, + {.debounce = 300ms}); + return ui::cells( + ui::textInput({.value = draft.at(&ResultInputs::mass), .commitOn = ui::Commit::Change}), + ui::text({.text = ui::fn::quantity(calc.value().at(&Evaluated::concentration) + .orElse(r.field(&ResultView::concentration))), + .stale = calc.pending()})); + }); +}); +MORPH_REGISTER_SCREEN(screen); +``` + +- Operators on expression handles build trees: `a + b`, `a < b`, `a && b`, `!a`, `x.orElse(y)`. +- Fields are member pointers and actions are types, so a renamed field or a wrong body type is a + compile error on the server. Query and mutation bodies are the action's own struct with expression + handles in place of values. +- Registration emits the JSON once and runs the load-time validation of §3; a screen that fails is a + failing server test, never a client surprise. +- The builders cover every construct of the document: the lifecycle keys (`identity`, `title`, + `dirty`, `onMount`, `onUnmount`, `onClose`, `onBackendChange`), `refreshOn` over action types, + `form` with its `fields` overlay, `dataTable`, `custom` and host components, and the command + forms. The reference screen of spec 6 §11 is the first full use and fixes the API. +- Inline lambdas cannot appear in a screen. A value the library cannot express is a server-computed + field. + +## 12. Delivery, versioning and discovery + +- **Handshake.** The wire `hello` is unchanged: it is unauthorised, carries no session and names no + model, so it cannot carry an application's inventory. The UI service has its own envelope kinds, + each added to the wire kind table: + + | Kind | Carries | Authorisation | + |---|---|---| + | `ui-hello` | Request: the vocabularies the client speaks (`ui/1`, `expr/1`, `forms/1`, `ui-theme/1`, `files/1`), its Qt version, whether it loads custom components, its host components, host commands and host values with versions. Reply: the application's id and version, the vocabularies it accepts, the manifest digest | An authoriser call for the pseudo-action `ui/hello`, so an application that serves a login screen before sign-in allows it for an empty principal | + | `ui-catalog` | The catalog and app shell for the principal | The principal's session | + | `ui-fetch` | A document, component or theme by hash | The session, except for entries the catalog marks `public` (a login screen) | + | `ui-evaluate` | An `evaluate` request (§7) | As the action it evaluates | + | `ui-file` | The file side channel (`files/1`) | As the mutation that references the file | + + A server without the UI service answers `unknown envelope kind`; the client then reports that the + peer serves no UI. An old client against a new server is unchanged, since it sends none of these + kinds. A client that is missing a vocabulary the bundle needs is refused after `ui-hello` with a + message naming it. New kinds are additive and do not bump `kProtocolVersion`: a peer without the UI + service answers `unknown envelope kind: ui-hello`, which a client reads as "no UI service", the way + it reads the same reply to `hello` as a peer that predates the handshake. The kinds are added to + the wire document's kind table, with their authorisation rules, as part of the change. +- **Catalog.** A `ui-catalog` request returns the application's screens and its app shell, filtered + for the signed-in principal (§12b). A screen document is fetched by id. Each entry carries `id`, + `title`, `module`, `group`, `icon`, `singleton`, `available`, `reason` and `whenDenied` (`hide` or + `disable`), `public`, `host` and `reuseEmpty`, so a navigator can list what an application has not built, disabled with its reason + (spec 6 §8). The bundle may also carry an **app document** and a **theme** (spec 6 §5–6). +- **Content addressing.** The bundle's manifest lists every document, component, theme and message catalogue by SHA-256. The + client verifies each file against the manifest, and the manifest against the digest from the + `ui-hello` reply, before using it. The digest is only as trustworthy as the channel that carried it. Verified files are cached by hash; an unchanged manifest fetches + nothing, and a client without a connection starts from its last verified bundle. +- **Versions.** Within a vocabulary version, changes are additive: a client ignores an optional key it + does not know, unless the key's name begins with `!` (`"!requires"`), which marks it + must-understand: a client that does not know it refuses the node and renders its fallback, or + refuses the document. A new node kind, function or command is a new vocabulary version + (`ui/2`, `expr/2`). A server may keep documents for more than one version and sends the newest the + client speaks. + +## 12b. Permissions and per-user customization + +A screen's document is the same for every user of a given vocabulary version, so it is cached and +verified by one hash, and validated and tested once. What differs between users comes from three +places, none of which changes the document: + +- **Permissions.** By default (`whenDenied: "hide"`) the catalog lists a screen only when the + principal may run every query the screen declares at its top level, and the app shell's menu omits + what the catalog omits; an entry with `whenDenied: "disable"` is listed, disabled, with the reason + `denied`. Within a screen, `{"can": "m"}` is true when the principal may run `m`: the catalog reply + carries the principal's permitted action ids for the application, answered by the same + `IAuthorizer` the server applies to each call. The set is a snapshot: the client asks again after + `signIn` and after any reply of error kind `denied`. A screen binds `visible` or `enabled` to it, so a verifier sees a + Verify button and a technician does not. The server still authorises every call; `can` decides + only what is shown. +- **Data.** What a user sees in a list or a form is what the queries return for that user. +- **Customization points.** A document declares where a user may adjust it: + + ```json + "customize": { + "resultColumns": { "kind": "columns", "of": "resultsTable" }, + "density": { "kind": "choice", "options": ["comfortable", "compact"], "default": "comfortable" }, + "resultFilter": { "kind": "value", "type": "string?", "default": null } } + ``` + + - `columns` lets the user reorder, hide and resize a table's columns; `choice` and `value` hold one + typed setting, readable in expressions as `{"pref": "density"}`; `layout` lets the user collapse + panels and choose the default tab of a `tabs` node; `saved` keeps named sets of `value` points, + such as saved filters. + - The app document may declare a `customize` block as well, with two more kinds: `pins` (an ordered, + optionally grouped list of catalog ids) and `arrange` (an ordered list of the declared children + of a container node, each enabled or hidden; it orders and hides, and never positions or sizes). + - The client stores each choice through an `IPreferencesStore` (spec 6 §8), by default a framework + preferences model on the server, keyed by the person the server derives from the verified + principal, the application, the screen (or `app`) and the point id, so a user's choices follow + them to every device. A key the client supplies is never trusted; an application with its own + storage supplies its own store, which maps the principal to the person on the server. A stored + value has a size bound. Choices are fetched with the screen and written as the user makes them; + offline, they are kept locally and written on reconnect. + - A choice that no longer fits its point — a column the document no longer has, an option it no + longer offers, a value of the wrong type — is ignored, so a new document version never breaks on + an old preference. + - A user can reset a screen's customization to the document's defaults. + +## 13. Security + +- The server is trusted with code execution: custom components are code. Loading a component + therefore requires a verified TLS connection to the server, or loopback with an explicit + development flag. Documents are data and load on any connection the client accepts. +- Documents cannot execute code: expressions are total and bounded, commands are a closed set, and + every action they run is authorised by the server like any other call. +- Components run in a dedicated QML engine whose context holds only their props and event sinks. +- An `evaluate` request is authorised as the action it evaluates, and is limited per principal (§7). + A preference is readable and writable only by its own principal, which the preferences model checks + against the verified session, since the authoriser sees no request body. +- A host component runs client code with props a document chose. Props and event payloads are + untrusted input and a host component validates them. A component that opens files, reaches the + network or touches the host marks itself `safeForUntrusted: false`, and the client mounts it only + when the connection is verified; on any other connection its fallback renders. +- A session token never appears in reactive state after its use, in diagnostics, in the offline + queue or in a journal (§5). + +## 14. Tests + +- **Document validation:** a corpus of valid and invalid documents, each invalid one with the path + and reason the refusal must name. +- **Expressions:** a shared JSON corpus of expressions, inputs and results, covering exact arithmetic + across units, null propagation, three-valued logic, every function, and the size bound. +- **Control:** query latest-wins under forced reply orders; `debounce`; `refreshEvery` skipping while + pending; each mutation concurrency mode; `patch`; one-batch invalidation. +- **Relations:** cascading `watch` with `source: "user"` against a server patch; check targets and + `ready`; dependent options with `onStale`; cycle refusal. +- **Server-calculated values (§7):** replies out of order; typing while a calculation is in flight; + an input edited after its request was sent; field errors; transport failure; serial write-through + per row; edits in two rows at once. +- **Several models:** a sequence across three models; failure in the middle; rebinding when an + `instance` changes. +- **Evaluate:** computed fields and field errors come back, the model's `execute` is never called, + and a principal who may not run the action is refused. +- **Offline:** a queued mutation stays pending and stale until its replay, and a rejected replay runs + `onError`. +- **Permissions:** the catalog omits a screen whose queries the principal may not run, and `can` + follows the principal's permitted actions. +- **Customization:** each point kind round-trips through the preferences model; an outdated choice is + ignored; reset restores the defaults. +- **Hosting (spec 6 §12):** lifecycle keys, the host contract, the app document and session, the + design package, the workspace shell, application-scope preferences, the backend switch, the form + overlay and error kinds. +- **Palette:** each further kind renders, falls back in the terminal, and is restylable; menus nest + and check; `tooltip`, `surface`, `keys` and `autofocus` apply; `mean` is exact. +- **Commands:** `navigate` as a dialog returns through `onResult` or `onCancel`; `focus`; `host` + with a missing command; `switchBackend` for a listed and an unlisted name; `resetPrefs`; + `setLocale`. +- **Authoring:** every registered screen of every example validates; builder output matches golden + JSON. +- **Compatibility:** an old client against a new server and the reverse, through the `ui-hello` + handshake; a server without the UI service; a `!`-prefixed key an old client does not know. +- **Bridge seams:** a binding by type id, a raw execute and a string-keyed attach behave as their + typed counterparts for session, timeout, cancellation, accounting and `switchBackend`. +- **Dispatch refresh:** `refreshOn` fires on the named action through the alias and not on another + action with the same reply type. +- **Security:** a credential-bearing mutation is refused with `"offline": "queue"`; a `secret` field + is absent from `result`, diagnostics and the queue; a side-effecting host component renders its + fallback on an unverified connection. + +## 15. Out of scope + +- **Free layout editing.** A user adjusts a screen only through its declared customization points + (§12b); positioning or sizing panels freely is not part of this design. Ordering and hiding the + children a document declares is the `arrange` point. +- **Server push.** A document learns of a change by its own calls, by `refreshOn` over its own + dispatch and by polling with `refreshEvery`. A server-initiated message (pushed progress, another + client's change) needs a wire kind the protocol does not have; it is a candidate for `ui/2`, as a + `ui-event` kind negotiated by `ui-hello`. +- **Server-side composite actions.** Committing several models in one transaction is not part of this + design; a dialog that must commit atomically runs one server action (§5). diff --git a/docs/superpowers/specs/2026-10-08-app-hosting-design.md b/docs/superpowers/specs/2026-10-08-app-hosting-design.md new file mode 100644 index 000000000..d443c1891 --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-app-hosting-design.md @@ -0,0 +1,775 @@ +# Hosting an application: lifecycle, design package, workspace shell — design (spec 6) + +An application with its own visual identity and its own native pieces — a sign-in against an +existing user table, a licence check, window chrome, a corporate design kit — runs on the generic +client of the program without writing a screen, a view-model or a QML layout of its own. What the +application keeps is small and is all *design* or *integration*: a theme, the few components a theme +cannot express, a `main`, and the seams that connect its own sign-in and preferences storage. +Everything else is served by its server as UI documents (spec 5). + +This spec defines what an application and the client agree on beyond the document itself: the +lifecycle of a mounted screen, the contract between a host and a renderer, the app root scope, the +design package, the workspace shell, application-scope preferences, offline as a backend switch, and +the error and schema vocabulary a real application needs. Its worked example is a sample-entry +screen of the lims example (§11). + +## Contents + +[1 Intent](#1-intent) · [2 Roles](#2-roles) · [3 Screen lifecycle](#3-screen-lifecycle) · +[4 Host contract](#4-host-contract) · [5 App root scope and session](#5-app-root-scope-and-session) · +[6 Design package](#6-design-package) · [7 Workspace shell](#7-the-workspace-shell) · +[8 Preferences and catalog](#8-preferences-and-catalog) · [9 Offline as a backend switch](#9-offline-as-a-backend-switch) · +[10 Forms and errors](#10-forms-errors-and-calculation) · [11 Worked example](#11-worked-example-sample-entry) · +[12 Tests](#12-tests) · [13 Risks](#13-risks) · [14 Host vocabulary](#14-host-vocabulary-host1) · +[15 Native panes and the close guard](#15-native-panes-tabs-and-the-close-guard) · +[16 Call and return](#16-call-and-return) · [17 Keyboard and focus](#17-keyboard-and-focus) · +[18 Files and long jobs](#18-files-and-long-jobs) · [19 Settings and messages](#19-settings-and-messages) + +## 1. Intent + +**Goals:** + +- **Minimal application GUI code.** What an application writes in a GUI toolkit is limited to what + defines its look: tokens and the components tokens cannot express. +- **Reusable by construction.** A component written for one application is reusable by any: it is + named, declares its props and events, and has a fallback (spec 5 §10). +- **Native integration stays native, through narrow seams.** Sign-in, licensing, window chrome and + preference storage are the application's, behind interfaces the framework defines. + +**Decisions:** + +- **Tokens before components, components before screens.** A look is first data (§6 tokens); what + data cannot express is a named component; a screen is never hand-written. +- **A framework workspace shell, not an application shell.** Tabs, per-tab dirty state, a close + guard, most-recently-used switching, pins and a start page are not specific to any application, so + the framework provides them (§7). Window chrome, sign-in and licensing are not, and stay with the + application. +- **The host embeds or the client owns, with one contract.** An application with a native shell + mounts documents through the renderer's `ScreenHost` (§4); the generic client mounts the same + documents through the workspace shell. A document does not know which. + +## 2. Roles + +| Role | Owns | Does not own | +|---|---|---| +| **Server** | Models, actions, screen documents, the catalog, custom components, theme tokens, the app root document | Anything about pixels on a particular client | +| **Framework client** | Interpreter, renderers, workspace shell, `ScreenHost`, theme application, host-component registry | Application names, models, colours | +| **Application executable** | `main`, a theme (if not served), host components, `AppSource` and `IPreferencesStore` implementations, window chrome | Screens, view-models, layouts | + +An application whose models run in-process (local mode, spec 5 §2) plays the server role in the same +executable. + +## 3. Screen lifecycle + +A screen document gains these optional top-level keys (spec 5 §3): + +```json +{ "id": "lims.sample", "params": { "sampleId": "int?" }, + "identity": { "ref": "sampleId" }, + "title": { "fn": "coalesce", "args": [ { "ref": "sampleNumber" }, { "t": "lims.sample.new", "fallback": "New sample" } ] }, + "dirty": { "ref": "unsaved" }, + "onMount": [ { "if": [ { "fn": "engaged", "args": [ { "ref": "sampleId" } ] }, [ { "run": "load" } ], [ { "run": "create" } ] ] } ], + "onUnmount": [], + "onClose": [], + "onBackendChange": [ { "run": "load" } ] } +``` + +- **`title`** is an expression (a literal `{ "t": ... }` still works). The host shows it in the tab + and window title and re-reads it when it changes. +- **`dirty`** is a bool expression. A `true` value shows the unsaved indicator and arms the close + guard. +- **`identity`** is an expression. Two openings of the same screen id with equal `identity` are the + same screen: opening the second focuses the first. A screen without `identity` is a singleton when + the catalog entry says `singleton`, and one instance per opening otherwise. A catalog entry's + `singleton` is the only place that flag lives. +- **`onMount`** runs once, after the screen's scope exists and before its queries start: the + interpreter issues the commands of `onMount`'s batch, then lets the scope's queries run their first + fetch. Commands that start a call are issued, not awaited, so a query that depends on a call made + in `onMount` declares it: the call's mutation `invalidates` the query (spec 5 §5), or the query is + gated with `when`. `watch` does not run on the first evaluation (spec 5 §6); `onMount` is how a + screen starts an action on open. +- **`onUnmount`** runs before the scope is destroyed. Commands that need a reply are not awaited; a + screen whose teardown must complete runs it in `onClose`. +- **`onClose`** runs when the host asks to close the screen. Without `onClose`, a screen whose + `dirty` is `true` is guarded by the host's own confirmation, and one whose `dirty` is not `true` + closes at once. With `onClose`, the document owns the decision: it opens its own confirm dialog and + runs `{"cancelClose": true}` to keep the screen open. `cancelClose` is valid only inside `onClose` + and is refused at load elsewhere. + - An open dialog and a mutation in flight count as pending: the screen closes when `onClose`'s + batch has ended and nothing it started is pending, and `cancelClose` anywhere in that time keeps + it open. + - The host's `done(closed)` is called exactly once per `requestClose`, always posted to the owner + and never from inside the document's own handler. A second `requestClose` while one is pending + returns the pending one. +- **`onSave`** is the command list that saves the screen. It is what the shell's guard runs for + *save and close* (§15); a screen without it is not offered that choice. +- **`onBackendChange`** runs after the interpreter has rebound the screen to a new backend (§9). + +**Private model instances.** A model alias's `instance` is `"private"`, `"shared"` or an expression +(spec 5 §5). A private alias is bound to an instance of its own: the model's state, such as an open +record, is the scope's alone, and the binding is released when the scope unmounts. The binding is a +`NoSharing` handler owned by the scope. + +## 4. Host contract + +```cpp +namespace morph::ui { // include/morph/ui/host.hpp +using ParamMap = std::map; // Value: the document's typed value variant +struct MountError { std::string screenId; std::string reason; }; + +class ScreenHandle { +public: + virtual reactive::Computed const& title() const = 0; + virtual reactive::Computed const& dirty() const = 0; + virtual void requestClose(std::function done) = 0; // the close protocol of §3 + virtual void forceClose() = 0; // for application quit: skips onClose, runs onUnmount, never vetoed + virtual ~ScreenHandle() = default; +}; +class ScreenHost { // implemented by a host that owns its own shell +public: + virtual std::expected, MountError> + mount(std::string_view screenId, ParamMap params) = 0; + virtual std::optional identityOf(std::string_view screenId, ParamMap const& params) = 0; + virtual void onNavigate(std::string_view screenId, ParamMap const& params) = 0; // implemented by the host + virtual ~ScreenHost() = default; +}; +} +``` + +- `qt_quick::Renderer` (spec 3 §1) implements `ScreenHost` over a connected source and exposes each + mounted screen's root `QQuickItem` beside its handle. The host places the item wherever its shell + wants it. `onNavigate` is called by the renderer and implemented by the host. +- A document's `{"navigate": "screen", "params": e}` that targets another screen is delivered to + `onNavigate`; the host decides whether that is a new tab, a focus or a dialog. The workspace shell + (§7) is the framework's implementation of that decision. +- The host calls `requestClose` and waits for `done(closed)`. It never destroys a handle whose close + is pending, except through `forceClose`. + +## 5. App root scope and session + +The bundle may carry one **app document** (id `app`) with `state`, `let`, `queries`, `mutations` and +`watch`, mounted once per connection and destroyed on sign-out. Screens read its names as +`{"ref": "app.name"}` and may write its `state` with `{"set": "app.name", ...}`. + +Its use is what no single screen owns: lookup lists loaded once per session (analysis +methods, clients, units), the current tenant, a connection banner. + +Two expression forms read the client and the session: + +| Form | Meaning | +|---|---| +| `{"session": "displayName"}` | `principal`, `userName`, `displayName`, `userKey`, `tenant`, `authenticated` | +| `{"env": "online"}`, `{"env": "backend"}` | Whether the active backend is reachable, and its name (`primary`, `local`, `remote`) | + +**Session attributes.** `session::Context` carries a principal, a token, a request id, a locale and +metadata; `Principal` carries an id, roles and a claims bag. `userName`, `displayName`, `userKey` and +`tenant` are entries of the principal's claims, and `authenticated` is true once the server has +accepted the token. They are what the client shows; the server never trusts a value the client sets. + +**Establishing a session.** Two ways, both through `AppSource`: + +- **Native.** The application's `AppSource::open` signs the user in with its own code before the + catalog is requested, on the owner, and installs the principal and claims through the bridge's + session API. A licence check runs there too; a refusal is a `ConnectError` that the frontend shows + before any screen exists. +- **Document.** A login screen runs a mutation whose reply carries a session token, then + `{"signIn": {"from": "login"}}`: the client builds a new session context from the current one with + the token replaced, so the locale, request id and metadata are kept, installs it, reloads the + catalog and mounts the start screen. The login mutation is never queued offline, and its token + field is `secret` (spec 5 §5). +- **Sign-out** unmounts every screen and the app document first, so their private instances are + deregistered under the session that owns them, and then clears the session. A person who signs in + as a different user on the same connection therefore cannot be left with instances the new session + cannot release. `{"signOut": true}` shows the login screen. + +`userKey` is a claim the application's sign-in sets for its own use. The preferences model does not +trust it (§8). + +## 6. Design package + +The look of an application is data first. + +**Theme tokens** (vocabulary `ui-theme/1`) are part of the bundle, or of the client when the +application ships its own: + +```json +"theme": { "vocab": "ui-theme/1", + "light": { "color.surface": "#FFFFFF", "color.surfaceAlt": "#F1F5F9", "color.text": "#0F172A", + "color.textMuted": "#64748B", "color.accent": "#2563EB", "color.accentText": "#FFFFFF", + "color.ok": "#15803D", "color.warn": "#B45309", "color.err": "#B91C1C", + "color.border": "#CBD5E1", "color.focus": "#2563EB" }, + "dark": { "color.surface": "#0B1220" }, + "scale": { "space": [0, 4, 8, 12, 16, 24, 32], "radius": [0, 4, 8, 12], "font.size": [11, 12, 13, 15, 18, 24], + "font.family": "Inter", "control.height": 32 }, + "density": { "comfortable": { "control.height": 32 }, "compact": { "control.height": 26 } } } +``` + +- The names are the closed set `ui-theme/1` defines; an unknown name is ignored, a missing one takes + the `MorphUi` default. The `MorphUi` base components (spec 3 §3) read the tokens through one + `Theme` singleton, so every generated screen and every host component sees the same values. +- `{"pref": "density"}` (spec 5 §12b) selects a `density` entry at runtime. +- The terminal renderer maps colour roles to its palette and ignores the scale. +- **Semantic sets.** Each of `ok`, `warn`, `err` and `info` has `color.`, `color..bg`, + `color..border` and `color..dot`; `elevation.1` to `elevation.3` are shadow tokens. +- **Surfaces.** A theme may declare `"surfaces": {"sidebar": {"color.surface": "#0F172A", + "color.text": "#E2E8F0"}}`: a named token set that overrides the base for a subtree. A node with + `"surface": "sidebar"` resolves every token under it, including a host component's, through that + set. This is how a dark rail sits inside a light theme. +- **Extension tokens.** Names under `ext.` (`ext.rail.width`, `ext.row.dense`) are free-form, typed + by their value, and readable by host components and restyled kinds; the built-in components ignore + them. +- **Icons.** `"icons": {"alert": "asset:sha256:…"}` maps an icon name to an SVG in the bundle; a node's + `icon` property names one. A small built-in set covers the names the built-in components use. + +**Host components.** A component the client carries, named and versioned, registered by the +application executable: + +```cpp +qt_quick::HostComponents::add({ .name = "ResultGrid", .version = 1, + .url = QUrl("qrc:/kit/ResultGrid.qml"), + .props = { {"columns", "json"}, {"rows", "json"}, {"results", "json"} }, + .events = { {"edited", "json"} }, + .safeForUntrusted = true }); +``` + +- A bundle declares one the same way as a delivered component (spec 5 §10) with `"host"` in place of + `"qml"`: + + ```json + "components": { "ResultGrid": { "host": "ResultGrid", "version": 1, + "props": { "columns": "json", "rows": "json", "results": "json" }, "events": { "edited": "json" }, + "fallback": { "kind": "table", "...": "..." } } } + ``` + +- The `ui-hello` request (spec 5 §12) lists the client's host components with their versions. A + component the client lacks, or has at a lower version, renders its fallback, chosen when the screen + mounts, and is reported once; the fallback's props must be type-compatible with the component's, so + load-time validation covers both. +- A host component needs no hash and no verified TLS connection: it is part of the client binary. + That makes its props and event payloads untrusted input from a document, which the component + validates. A component with a side effect (it opens files, reaches the network, touches the host) + sets `safeForUntrusted: false` and is mounted only from a verified connection (spec 5 §13). +- It sees its props and event sinks and, in addition to a delivered component, may import other host + components and the `Theme` singleton. +- **Restyling a built-in kind** (spec 5 §10) maps to a host component with `"restyle": {"button": + "BrandButton"}`. The mapping is checked when the server registers the bundle and when the client + loads it, against the base kind's props and events. + +**Accessibility and test identity.** Every node takes `"a11y": {"name": e, "role": "..."}` and +`"testId": "..."`. The Qt Quick generator emits `Accessible.name`, `Accessible.role` and +`objectName`; the terminal renderer uses the name as the widget's label. + +- Roles are `group`, `heading`, `text`, `button`, `edit`, `checkbox`, `list`, `table`, `chart` and + `status`. A node with a click handler has the role `button` and the press action unless the + document gives it another. +- A field bound to a form takes its accessible name from the field's label. A read-only value is + named `": "`, so a test reads it without a separate query. +- `testId` is a stable identifier for the node, and is how a test finds a node whose name is + generated. The generator emits it as `objectName` and, on a Qt version that has the property, as + `Accessible.id`. Qt's documentation does not say how either maps to the platform's automation id, + so the framework does not assume it: an automated-UI conformance test on each platform that has + one reads the properties a driver sees (name, role, automation id) from a generated screen in a + real window, and the supported way to locate a node is the one that test passes. Lookup by + accessible name and role is the primary route and works wherever the platform exposes them. + +## 7. The workspace shell + +The app shell vocabulary of spec 2 §8 (`app-menu`, `app-screens`) gains a second kind, **workspace**: +a set of tabs over screens, as most desktop applications of this kind need. Its keys use the same +`app-` prefix. + +```json +{ "app-shell": "workspace", + "app-start": "resume", "app-start-fallback": "overview", "app-max-pins": 12, + "app-screens": [ "lims.overview", "lims.sample", "lims.history" ] } +``` + +The screens are catalog ids; whether a screen is a singleton, and its title, group and availability, +are the catalog entry's (§8). + +- **Tabs.** A tab is a screen instance. Opening a screen whose `identity` equals an open tab's + focuses that tab. A `singleton` screen has at most one tab. The tab shows the screen's `title` + and, when its `dirty` is `true`, the unsaved indicator. A hidden tab keeps its scope and its state. +- **Close.** Closing runs the close protocol of §3 and the guard of §15. Close-others and close-all + run it per tab, one tab at a time, and stop at the first veto. +- **Recent order.** Ctrl+Tab walks tabs most recently used first; the shell shows a switcher while + the modifier is held. +- **Start.** `resume` reopens the screens recorded for the person, as `screenId` or + `screenId:identity`; `overview` or any screen id opens that screen; `app-start-fallback` applies + when nothing is recorded or a recorded screen is no longer available. The record is written as + tabs open and close, debounced, and flushed before the session is cleared on sign-out. +- **Navigator.** The catalog (§8) is listed with its module and group; an entry that is + `available: false` is listed disabled with its reason. A search over titles (in every locale, §19) + and a keyboard shortcut open it; its keys are §17's. +- **Pins.** The user's shortcuts are an application-scope customization point of kind `pins` (§8), + ordered, optionally grouped, limited to `app-max-pins`. +- **Window chrome** (title bar, window controls, user menu) is the host's: the shell is an item the + host places in its own window. The generic client supplies a plain default. + +The shell is a document-free framework component, in `include/morph/forms/engine/workspace.hpp` +beside `app_shell.hpp`: its state is reactive nodes (spec 1), its view is a view tree (spec 1 §5), +and it mounts screens through the same `ScreenHost` an embedding host uses. + +## 8. Preferences and catalog + +**Application-scope customization.** Spec 5 §12b's `customize` block may appear on the app document +as well as on a screen. Two point kinds are added: + +| Kind | Holds | +|---|---| +| `pins` | An ordered list of catalog ids with optional group labels, limited by `app-max-pins` | +| `arrange` | An ordered list of ids of declared children of a container node, each enabled or hidden | + +`arrange` lets a user reorder and hide the widgets of an overview screen; the node's children carry +the ids. It orders and hides what the document declares and never positions or sizes anything, so +free layout editing stays out of scope (spec 5 §15). + +**Storage seam.** + +```cpp +struct PrefKey { std::string person, app, scope /* screen id or "app" */, point; }; +class IPreferencesStore { +public: + virtual async::Completion> get(PrefKey const&) = 0; // JSON text + virtual async::Completion set(PrefKey const&, std::string json) = 0; + virtual async::Completion remove(PrefKey const&) = 0; // reset to the defaults + virtual ~IPreferencesStore() = default; +}; +``` + +- `person` is derived on the server from the verified principal. The request carries no person: the + authoriser sees only the model and the action, never the body, so the preferences model itself + takes the key from the verified session, and a client that names another person's key reads and + writes nothing of theirs. The framework's store therefore never accepts a client-supplied key. +- An application with its own storage supplies its own store, which maps the principal to the person + on the server: an application with an existing per-person settings table keys it by the person the + login resolves to. Choices then follow the person across logins, tenants and workstations. +- A stored value has a size bound, applied before it is written. +- An absent entry is the document's default, never an error. A stored entry that no longer fits its + point is ignored (spec 5 §12b). + +**Catalog entries** (spec 5 §12) carry what a navigator needs: + +```json +{ "id": "lims.sample", "title": { "t": "lims.sample.title", "fallback": "Sample entry" }, + "module": "Samples", "group": "Testing", "icon": "flask", + "singleton": false, "available": true, "reason": null, "whenDenied": "disable", "public": false, + "host": null, "reuseEmpty": false } +``` + +- `available: false` lists the entry disabled, with `reason` shown. A catalog that lists what an + application has not built yet lets a navigator's counts stay true. +- `whenDenied` says what a permission refusal does to the entry: `hide` (the default, spec 5 §12b) + or `disable`, with the reason `denied`. +- `public: true` marks an entry the client may fetch before sign-in, such as a login screen + (spec 5 §12). +- `host` names a screen the application registered with the client instead of a document (§15); + `reuseEmpty` makes opening the entry replace an empty, unmodified tab of it (§15). + +## 9. Offline as a backend switch + +An application whose offline mode is a second database with its own models (a local database that +later synchronises) implements it as a backend switch, not as a queue of mutations (spec 5 §5 +`"offline": "queue"`, which an application without a local database keeps using). + +The bridge already switches backends under live handlers. The interpreter relies on that and adds the +document side: + +- `AppSource::switchBackend(Backend)` posts to the bridge's owner and calls `Bridge::switchBackend`, + with the offline reconnect coordinator's sequencing where the application has one. `Backend` is the + application's opaque backend handle; its name (`primary`, `local`, `remote`) is what + `{"env": "backend"}` reads. +- The bridge re-issues a bind for every live binding: a private instance comes back as a fresh, + empty instance from the model factory, and a shared one re-attaches by its key. A switch is + all-or-nothing: if it fails, the old backend stays, nothing is rebound, and the interpreter reports + `backendSwitchFailed` to the `onError` path of the command that asked for it. +- A call in flight on the old backend is rejected with `BackendChangedError`, which has the error + kind `backendChanged` (§10). +- Order in the interpreter: the switch commits; the queries on private aliases are held idle; each + mounted screen's `onBackendChange` runs; then the held queries run their fetch and the others + refetch, in one batch; `{"env": "backend"}` and `{"env": "online"}` publish with it. A query on a + private alias therefore never fetches against the empty new instance. +- A private model instance does not survive a switch. A screen holding an open record reloads it in + `onBackendChange`, from state it keeps, and decides what the user is told about edits the old + instance held: `dirty` is whatever the screen sets, never silently carried over. +- The model's own `onBackendChanged` hook and the document's `onBackendChange` are two things: the + first lets a model re-prepare its resources, the second lets a screen recover its view. +- What a switch implies for data — transferring an unsaved draft to the other database, reserving a + key range for rows created offline, remembering which records to synchronise on reconnect — is the + model's: it happens in the model's own actions and in the application's `AppSource`, so every + client of the model behaves the same and no screen carries it. + +## 10. Forms, errors and calculation + +**A form's field overlay.** A `form` node takes `fields`, applied to the parsed form model before the +session is built (spec 2 §7): + +```json +{ "kind": "form", "model": "sample", "action": "Lims_CaptureConcentration", + "fields": { "rowIndex": { "omit": true }, + "analystId": { "hidden": true }, + "mass": { "label": { "t": "lims.capture.mass", "fallback": "Mass" }, "unit": "g", "decimals": 1 }, + "receivedOn": { "widget": "epochDays" }, + "remarks": { "blankAs": "empty" } } } +``` + +`omit` removes the member from the form and its body; `hidden` keeps it in the body and out of the +view; `label`, `unit`, `decimals`, `widget` and `blankAs` replace the schema's. `blankAs` takes the +lower-case spelling of the schema's `x-blankAs` values (`omit`, `empty`). An application that needs +the same overlay for several screens declares it once on the model's schema emitter (the server's +`x-` keywords), and the document's overlay refines it. + +**Integer-backed dates.** `x-widget: epochDays` and `epochSeconds` select the field kinds `EpochDate` +and `EpochDateTime` (spec 2 §2), whose wire value is an integer count of days or seconds since the +epoch (UTC) and whose display is the locale's date or date-time. `x-widget` is an advisory hint +today; these two values are new vocabulary items of `forms/1`, so a client that does not know them +shows the integer, and the handshake says which a client speaks. + +**Error kinds.** `{"errorKind": "m"}`, for a query or mutation `m`, is one of `notFound`, +`validation`, `connectionLost`, `denied`, `conflict`, `backendChanged`, `other`; `{"error": "m"}` +stays the display text. The server's typed errors map onto the kinds through one table the +application registers; `BackendChangedError` maps to `backendChanged`. A reply enum such as a save's +validation outcome selects a message with the display functions: +`t(concat("lims.save.", result.error), "Not saved")`, with one catalog entry per enum value. + +**Calculation stays on the server.** A value derived from several rows, such as a share of a total +with its limits and deviations, is a field of an action's reply (spec 5 §7). The +client does not recompute it, so the displayed value and the value the server judges against are one +and the same. + +## 11. Worked example: sample entry + +The reference is the lims example's sample-entry screen, `lims.sample` (spec 4 §5). A record-editing +dialog of the kind an application hand-writes as a view-model, a set of read-only data types, a +schema annotator and a layout per section is one document. + +**Model additions.** The lims `SampleModel` already has `RegisterSample`, `OpenSample`, `GetSample`, +`ListResults`, `ListDilutionModes` and `CaptureConcentration`. The example adds what a +record-editing dialog needs and the lifecycle model lacks: + +| Addition | Why | +|---|---| +| `NewSample`, `LoadSample`, `SaveSample`, `DeleteSample` over a working copy held by the model instance | The screen edits a private working copy and Save commits it (§3) | +| `HeaderSection`, `SamplingSection`, `TurbiditySection`, `NitrateSection` | One action per editable section, each with a `fields` overlay | +| `ClearOptionalMeasurement` | Removes an optional measurement's stored values | +| `GetHeader`, `GetMeasurements`, `GetDilutionSeries` | Snapshots of the working copy; `GetMeasurements` reports which optional measurements hold data | +| A dilution-series row with `measured`, `recoveryPct`, `limitMin`, `limitMax`, `outsideLimits` | Computed by the model, never by the client (§10) | +| `ListProjects` with `TableQuery` and `Page`, and `SelectProject` | The lookup table (spec 7 §2) and its choice | + +**The screen**, authored in C++ with the builders of spec 5 §11. `page`, `pageHeader`, `card` and +`pill` are compositions of the palette's `column`, `row`, `panel`, `scroll` and `text`: + +```cpp +auto limsSample = ui::screen<"lims.sample">([](ui::Scope& s) { + auto sampleId = s.param>("sampleId"); + auto sample = s.model("sample", {.instance = ui::Instance::Private}); // a working copy + auto unsaved = s.state("unsaved", false); + auto optional = s.state("optional", ui::List{}); // optional measurements shown + auto project = s.dialog("project"); + + auto header = s.query(sample, {}, {.refreshOn = ui::actions()}); + auto series = s.query(sample, {}, {.refreshOn = ui::actions()}); + auto measure = s.query(sample, {}, {.refreshOn = ui::actions()}); + + s.identity(sampleId); // one tab per sample + s.title(ui::coalesce(header.at(&Header::sampleNumber), ui::t("lims.sample.new", "New sample"))); + s.dirty(unsaved); + + // load and create fill the working copy after the queries started, so they invalidate them + auto load = s.mutation(sample, {.invalidates = {header, series, measure}, + .onSuccess = {ui::set(unsaved, false)}}); + auto make = s.mutation(sample, {.invalidates = {header, series, measure}, + .onSuccess = {ui::set(unsaved, false)}}); + auto clear = s.mutation(sample, {.invalidates = {measure, series}, + .onSuccess = {ui::set(unsaved, true)}}); + auto pick = s.mutation(sample, {.invalidates = {header}, + .onSuccess = {ui::set(unsaved, true), ui::close(project)}}); + auto save = s.mutation(sample, {.onSuccess = {ui::set(unsaved, false)}, + .onError = {ui::notify(ui::errorText(ui::self), ui::Role::Error)}}); + s.onMount(ui::if_(ui::engaged(sampleId), ui::run(load, {.id = sampleId}), ui::run(make))); + s.onBackendChange(ui::seq(ui::if_(unsaved, ui::notify(ui::t("lims.lost", "Unsaved edits were lost"), ui::Role::Warn)), + ui::run(load, {.id = sampleId}))); + + return ui::page( + ui::pageHeader(ui::title(s.titleExpr()), + ui::button({.label = ui::t("lims.project", "Project"), .onClick = ui::open(project)}), + ui::button({.label = ui::t("lims.save", "Save"), .busy = ui::pending(save), .onClick = ui::run(save)})), + ui::scroll( + ui::card(ui::t("lims.header", "Header"), + ui::form(sample, {.prefill = header.value(), .onSuccess = ui::set(unsaved, true)})), + ui::card(ui::t("lims.measurements", "Measurements"), + ui::column( + ui::form(sample, {.onSuccess = ui::set(unsaved, true)}), + ui::form(sample, {.visible = ui::includes(optional, "turbidity")}), + ui::form(sample, {.visible = ui::includes(optional, "nitrate")}), + ui::optionalMenu(optional, {"turbidity", "nitrate"}, {.onRemove = ui::run(clear)}))), + ui::card(ui::t("lims.series", "Dilution series"), + ui::column( + ui::custom<"SeriesGrid">({.props = {.rows = series.at(&Series::rows)}}), + ui::custom<"RecoveryCurve">({.props = {.rows = series.at(&Series::rows)}})), + ui::pill({.text = ui::format(ui::t("lims.outside", "{0} outside limits"), + ui::count(ui::filter(series.at(&Series::rows), &Row::outsideLimits)))}))), + ui::dialog(project, {.title = ui::t("lims.chooseProject", "Choose project")}, + ui::dataTable(sample, {.mode = ui::TableMode::Server, .selection = ui::Selection::Single, + .onActivate = {ui::run(pick, {.projectId = ui::row("id")})}}))); +}); +MORPH_REGISTER_SCREEN(limsSample); +``` + +The "choose project" dialog is declared in the sample screen's scope, so its mutation reaches the +sample's private working copy. A picker reusable across screens borrows the owner's instance with +`"instance": {"from": "sample"}` (§16). + +**What the document replaces in a hand-written application:** + +- the view-model and its read-only data types, since queries and `refreshOn` replace the + `subscribe` plus `refresh*` pairs; +- the schema annotator, since the `fields` overlay replaces it; +- a layout per section, since each is a `form` node; +- the shell wiring, since `identity`, `title` and `dirty` replace tab bookkeeping. + +This screen is built, as `lims.sample`, in the lims example and unit tested there (spec 4 §5): the +framework's claims in this spec are tested against it, not against a description of it. + +**What remains in the application executable:** + +| Hand-written today | After | +|---|---| +| View-models, section layouts, the shell, the workspace and dialog registries | Documents on the server; the workspace shell of §7 | +| A component kit, tokens, a theme script | `ui-theme/1` tokens; host components only where a token cannot express the control; the rest restyle built-in kinds or go | +| `main`, config, bootstrap | `main` that selects the frontend and builds the `AppSource` | +| Sign-in, licence check, window chrome | The same code, behind `AppSource` and the host's window | +| Offline queue, key-range reservation, draft transfer | The models' actions (§9) | + +## 12. Tests + +- **Lifecycle:** `title` and `dirty` follow their expressions; `identity` focuses instead of + duplicating; `onMount` runs once, before the scope's queries start, and a query that depends on a + call made there is refreshed by `invalidates`; `cancelClose` keeps a screen open and is refused + outside `onClose`; a dirty screen without `onClose` is guarded by the host; `done` is called once + and posted; a second `requestClose` returns the pending one; `forceClose` skips `onClose`; a + private alias is a new instance per mount and is released on unmount; a row scope's alias without + `instance` is refused at load. +- **Host contract:** a fake `ScreenHost` mounts, reads `title` and `dirty`, closes with and without a + veto, and receives `navigate`; the Qt Quick renderer exposes the root item. +- **App root and session:** an app-document query is shared by two screens and runs once; `signIn` + installs the token and keeps locale, request id and metadata; `signOut` deregisters every private + instance before the session is cleared; native sign-in runs on the owner before the catalog; a + `secret` token is absent from `result`, diagnostics and the queue. +- **Design package:** a token set reaches a generated screen and a host component; a missing token + takes the default; a missing or old host component renders its fallback, chosen at mount, and is + reported once; a host component that is not safe for untrusted documents renders its fallback on an + unverified connection; a restyle mapping with a missing prop is refused; `a11y` and `testId` + appear as `Accessible.name` and `objectName`. +- **Workspace shell:** open, focus-instead-of-duplicate, singleton, hidden tabs keep state, close + protocol per tab, close-all stops at the first veto, MRU order, `resume` with an unavailable + screen, disabled entries with their reason, pins up to the limit, `arrange` reorder and hide, + reset. +- **Preferences:** a custom `IPreferencesStore` receives the person the server derived; a request + naming another person's key reads and writes nothing of theirs; an oversized value is refused; an + absent entry is the default; an outdated entry is ignored. +- **Offline switch:** aliases rebind, a failed switch leaves the old backend, in-flight calls settle as + `backendChanged`, queries on private aliases are held until `onBackendChange` has run, queries + refetch in one batch, `env` changes. +- **Forms:** the overlay applies each key; `omit` removes a member from the body; epoch dates round + trip exactly; a client that does not know the epoch widgets shows the integer. +- **Errors:** every kind from the registered table; an enum reply selects its message. +- **Host vocabulary:** a registered command runs with validated arguments and a mismatch is a + reported error; an unsafe command is skipped on an unverified connection; a missing command is + skipped and `can` is false; a host value is reactive and `null` when absent; `switchBackend` is + refused for an unlisted backend. +- **Native panes and guard:** a host screen shares tabs, MRU order, dirty state and the session record + with document screens; the guard offers save and close only with `onSave`, waits for pending work, + and keeps the screen open if `dirty` stays `true`; discard does not run `onSave`; close-all stops + at the first veto; a re-keyed tab is focused by a later opening of its identity; `reuseEmpty` + replaces an empty tab. +- **Call and return:** `onResult` receives `return`'s value; closing by the platform runs `onCancel`; + `window` falls back to a dialog; a borrowed instance is the opener's and is not released by the + child; `from` outside a dialog, drawer or window is refused at load. +- **Keyboard:** chords fire inside their node and the innermost wins; a reserved chord is refused at + load; `autofocus` and `focus`; `default` and `cancel`; `onSubmit`; a menu shows the chord of its + item. +- **Files and jobs:** `accept` and `multiple`; an operating-system drop reaches `onDrop`; a transfer + reports `progress`, `pending` and `error`, and a large one is chunked; preview binds a local + reference and falls back without the viewer; a job poll stops when `state` is terminal. +- **Settings and messages:** each `customize` editor edits its point and `resetPrefs` restores the + default; `setLocale` re-resolves every `t`; a missing catalogue shows the fallback; search matches + titles in every manifest locale. +- **Automation conformance:** on each platform with an automation interface, a generated screen + exposes the names, roles and ids §6 promises. +- **Port check:** an application's existing UI-automation suite, restricted to lookups by accessible + name and role (and `testId` where the platform maps it), runs against the generated screen. A lookup + that depends on component names or file names of the old QML does not carry over and is rewritten + to a name or a `testId`. + +## 13. Risks + +- **Tokens may not reproduce a designed look.** `ui-theme/1` is a closed set; an application whose + design needs more restyles kinds with host components. The size of that residue is measured when + the first application is ported, not assumed. +- **Private instances cost resources.** Each private alias is a server-side model instance and, + for a database-backed model, a connection. Dialogs and row scopes are where they multiply; the + load-time rule for row scopes and the server's `maxLiveModels` are the brakes (spec 5 §5). +- **The workspace shell is the framework's largest new component.** It is justified by two users in + the program (the lims example and any application with tabbed documents) and is built after the + document lifecycle it depends on. +- **New bridge seams.** Model-free dispatch needs a binding by type id, a raw execute and a + string-keyed attach (spec 5 §2). They are the first change of the program and block the interpreter. +- **Host component drift.** A host component's props are a contract between a client and a server + that release independently; the version in the handshake and the fallback are what keep that + safe. +- **Scope.** §14–§19 are the largest part of this spec, and the application they come from is the + only one that has exercised them. Each is specified at the level an implementation needs; the + reference screens of spec 4 §5 exercise the ones the lims example can, and the others are first + exercised by the first ported application. +- **The builder API is a sketch.** Spec 5 §11 shows one short example; the screen in §11 needs, at + least, `identity`, `title`, `dirty`, `onMount`, `onBackendChange`, `actions<...>()` for `refreshOn`, + `form`, `dataTable`, `custom`, `optionalMenu`, `includes`, `filter` and `format`. The first + implementation of the reference screen fixes the API; this spec's names are not final. +- **Optional state comes from the model.** The reference screen shows an optional measurement when + the working copy holds data for it, so `GetMeasurements` reports which ones; a model that cannot + report it keeps that state on the client and loses it on a backend switch. + +## 14. Host vocabulary (`host/1`) + +An application has commands and values no document can define: restart the process, open a path in +the operating system, a count the application's own sync engine keeps. They are registered by the +application executable, named and typed, and declared to the server in the handshake exactly as host +components are (§6): + +```cpp +qt_quick::HostCommands::add({ .name = "restartApp", .version = 1, .args = {}, .safeForUntrusted = false }); +qt_quick::HostCommands::add({ .name = "openPath", .version = 1, .args = { {"path", "string"} }, + .safeForUntrusted = false }); +qt_quick::HostValues::add({ .name = "sync.needsYouCount", .version = 1, .type = "int", .signal = &syncNeedsYou }); +``` + +- **Use in a document.** The command `{"host": "openPath", "args": {"path": e}}` runs the registered + command; the expression `{"host": "sync.needsYouCount"}` reads the registered value, which is a + `reactive::Signal`, so a binding on it updates. `{"can": "host:openPath"}` is true when the client + has the command, so a document shows a control only where it works. +- **Arguments are validated** against the declared types before the command runs; a mismatch is a + reported error, not a call. +- **Absent is safe.** A command the client lacks is skipped and reported once; a value it lacks reads + as `null`. A document guards with `can` or with `coalesce`. +- **Trust.** A command marked `safeForUntrusted: false` runs only from a document that came over a + verified connection (spec 5 §13); otherwise it is skipped and reported. A value is read-only and + needs no gate. +- **`switchBackend`** is not a host command. `{"switchBackend": "local"}` is a `ui/1` command that + calls `AppSource::switchBackend` (§9) for a backend name the application's `AppSource` lists in + `backends()`; a name it does not list is refused. +- **The handshake** carries the registered names and versions in `ui-hello` (spec 5 §12), so a + server knows what a client can do before it serves a document that needs it. + +## 15. Native panes, tabs and the close guard + +- **Host screens.** A catalog entry may name `"host": "DatabaseSync"` instead of a document. The + application registers the screen with the renderer (`ScreenHostRegistry::add(name, factory)`); the + factory returns a `ScreenHandle` (§4) and the screen's `QQuickItem`. The workspace shell mounts it + like a document screen, in a tab, with the handle's `title`, `dirty` and close protocol. A native + and a document screen therefore share tabs, most-recently-used order, the unsaved indicator and the + session record. A host screen has no `identity` unless its factory gives one. +- **Close guard.** A dirty screen without `onClose` is guarded by the shell with three choices: save + and close, discard and close, cancel. *Save and close* is offered only when the screen declares + `onSave` (a command list; a host screen's handle offers `save()`); the shell runs it, waits until + nothing it started is pending, and closes the screen if `dirty` is then `false`; otherwise it keeps + the screen open and shows the failure. *Discard and close* does not run `onSave`. +- **Tab menu.** Close, close others, close to the right, close all saved (the tabs whose `dirty` is + not `true`), and Ctrl+W for the active tab. Each runs the close protocol per tab, one at a time, + and stops at the first veto. +- **Re-keying.** When a screen's `identity` changes (a new record receives its key on the first + save), the shell re-keys the tab. A later opening of that identity focuses the first tab that holds + it; two tabs never merge. +- **Placeholders.** A catalog entry with `"reuseEmpty": true` is replaced rather than stacked: opening + it with an `identity` when a tab of the same screen exists with no `identity` and `dirty` not + `true` reuses that tab. +- **Persistence.** The session record (§7) lists host screens by their catalog id, like document + screens. + +## 16. Call and return + +A screen can open another and receive an answer: + +```json +{ "navigate": "lims.chooseProject", "params": { "obj": { "clientId": { "ref": "clientId" } } }, + "as": "dialog", "onResult": [ { "set": "projectId", "value": { "ref": "event" } } ] } +``` + +- `as` is `tab` (the default; the shell decides, §7), `dialog`, `drawer` or `window`. A `window` is a + top-level window the user may move to another monitor; a renderer or host that cannot show one shows + a dialog. +- A screen opened as a dialog, drawer or window ends with `{"return": e}`, which closes it and runs + the opener's `onResult` with the value as `{"ref": "event"}`, or with `{"close": "self"}`, which + closes it and runs the opener's `onCancel`. Closing it by the platform (Esc, the window's close) + is `onCancel`. +- **Borrowing.** A model alias of the opened screen may declare `"instance": {"from": "sample"}` + naming an alias of the opener. The child then uses the opener's binding: the same instance, not + released when the child closes. It is allowed only for a screen opened as a dialog, drawer or + window, and a document that declares it elsewhere is refused at load. A picker is therefore one + document used by every screen that needs it. +- Params are typed by the callee's `params` and are validated when the screen opens. + +## 17. Keyboard and focus + +- **`keys`** on a screen, dialog or node maps a chord to a command list: `"keys": {"Ctrl+S": [...], + "Ctrl+Shift+K": [...]}`. Chords are modifiers (`Ctrl`, `Shift`, `Alt`, `Meta`) joined to a key + name (a letter, a digit, `F1`–`F24`, `Enter`, `Esc`, `Tab`, `Up`, `Down`, `Left`, `Right`, + `Space`). A chord is active while focus is inside the node; a screen's chords are active while + focus is inside its tab. The innermost node wins. +- **Reserved chords** are the workspace shell's (Ctrl+Tab, Ctrl+Shift+Tab, Ctrl+W, Ctrl+K for the + navigator) and the host's; a document that declares one is refused at load. +- **`autofocus: true`** focuses a node when its scope mounts; the first in document order wins. + **`{"focus": "id"}`** moves focus to a node. Focus order is document order. +- **Dialogs** take `default` (Enter, when focus is not in a multi-line input) and `cancel` (Esc, + which closes the dialog if unset) command lists; a single-line text input takes `onSubmit`. +- **Menus** show a chord for an item whose `keys` is declared on the same screen, so the label and + the binding cannot disagree. +- **The navigator** (§7) moves with Up and Down, opens with Enter, closes with Esc and pins or unpins + the highlighted entry with Ctrl+P. It searches the title in every locale the client holds (§19), + and the module and group. + +## 18. Files and long jobs + +**Files (`files/1`).** + +- A `filePicker` takes `accept` (extensions and media types) and `multiple`. A `dropZone` is a panel + that accepts files dropped from the operating system; both deliver a list of file references to + `onPick` or `onDrop` as `{"ref": "event"}`, usable by `upload`. +- `{"upload": e, "to": "m", "id": "t1"}` names the transfer; `{"ref": "t1.progress"}` (0 to 1, or + `null` when unknown), `t1.pending` and `t1.error` are element values, and a transfer too large for + one envelope is chunked. +- `{"download": e, "save": "dialog", "name": e}` offers a suggested file name. + `{"download": e, "to": "preview", "as": "url"}` fetches to a local temporary reference bound to the + state `url`, which a viewer takes as a prop. The viewer (a PDF viewer, an image viewer) is a host + component with a fallback of a "Save as…" button, so a client built without it still works. +- Opening a file in the operating system's own application is a host command (§14). + +**Long jobs.** A job is an action that starts work and a query that reports it: + +- The action returns a job id; `GetJob` returns `{state, phase, phases, done, total, message}`. The + document polls it with `refreshEvery` and `when: state is running`, and shows `progress` and + `steps`. A `watch` on `state` runs the follow-up when it becomes `done` or `failed`. +- Pushing progress needs a server-initiated message the wire does not have; it is not part of + `ui/1`. A later vocabulary version may add a `ui-event` kind negotiated by `ui-hello`. + +## 19. Settings and messages + +**Settings.** A settings screen is an ordinary document. Its customization editors are `customize` +nodes (spec 5 §9): `{"kind": "customize", "point": "pins"}` renders the editor for a point of kind +`pins`, `arrange`, `columns`, `choice`, `value` or `saved`, and `{"resetPrefs": "pins"}` or +`{"resetPrefs": "all"}` restores the defaults. The start page is a `choice` point of the app +document, with the options `resume`, `overview` and `screen` (any catalog id); `app-start` (§7) is its +default. An application section (a logging panel) is a host component, or host values and commands +(§14), placed in the same document. + +**Messages.** + +- The bundle manifest lists a catalogue per locale, `i18n/.json`, a flat map of key to text, + fetched by hash (spec 5 §12) and cached. The client's `TranslationProvider` resolves + `(key, locale)` from them; `{"env": "locale"}` is the active locale and `{"setLocale": "de"}` + changes it, which re-resolves every `t`, and is stored as a preference. +- Keys are authored, `.`; there are no keys derived from text. Porting a screen keeps + its source text as the `fallback` and gives it an authored key, so a missing catalogue shows the + source text. +- Search across locales (§17) asks the provider for each locale the manifest lists. diff --git a/docs/superpowers/specs/2026-10-08-table-engine-design.md b/docs/superpowers/specs/2026-10-08-table-engine-design.md new file mode 100644 index 000000000..b6de5e09c --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-table-engine-design.md @@ -0,0 +1,467 @@ +# The table engine: a table for a model, declared — design (spec 7) + +An application that is mostly tables — long result sets, many typed columns, sorting and filtering +that must stay fast, cells the user edits — declares a table by naming the model's list action. The +server implements that action once; the client shows a sortable, filterable, selectable, editable +table with no GUI code written for it. This spec defines the **table engine** that does the sorting, +filtering and view mapping, the **conventions** that connect it to a model, and the **`table` node** +that exposes it in a UI document (spec 5). + +The engine is a toolkit-free component of the base library. It is used on the client over the rows +a query returned, and may be used on a server over the rows it holds. + +## Contents + +[1 Intent](#1-intent) · [2 A table for a model](#2-a-table-for-a-model) · +[3 Architecture](#3-architecture) · [4 Columns](#4-columns) · [5 Sort](#5-sort) · [6 Filter](#6-filter) · +[7 Sort, filter and change](#7-sort-filter-and-change) · [8 Execution](#8-execution-and-threading) · +[9 Client and server modes](#9-client-and-server-modes) · [10 Selection](#10-selection-and-activation) · +[11 Editing](#11-editing-cells) · [12 The table node](#12-the-table-node) · +[13 Rendering](#13-rendering) · [14 Packaging](#14-packaging) · [15 Measurement](#15-measurement) · +[16 Tests](#16-tests) · [17 Risks](#17-risks) + +## 1. Intent + +**Goals:** + +- **A table is declared, not built.** Naming a model's list action gives a working table. Columns, + headers, units, decimals and sort behaviour come from the row type; a declaration overrides what + the type cannot say. +- **Sorting and filtering are fast and never block the UI.** A result of about 1,000 rows sorts and + filters imperceptibly; 10,000 to 100,000 rows are handled off the UI thread, and a superseded + request is abandoned. +- **One engine, no toolkit.** The engine includes no GUI toolkit. A renderer applies its output. +- **Exact values.** Integers, decimals and quantities are compared exactly (spec 5 §1); no key passes + through a double unless the column says it is a double. + +**Decisions:** + +- **The engine owns the view.** Sort order, filter result and the mapping between source rows and + view rows live in the engine, not in a renderer's list model, so every renderer shows the same + table and the logic is tested once. +- **Two modes, one declaration.** For a result that fits the client, the engine sorts and filters + there (§9). For a result that does not, the same sort and filter values are sent to the server in + the query body and the engine only maps the view. The document chooses per table. +- **Semantics are normative.** Filter combination, the order of unparsable cells and the stability + of the sort are specified here (§5–6), so a client and a server that both use the engine agree + row for row. + +## 2. A table for a model + +On the server, a list action's body embeds the table query and its reply is a page of rows: + +```cpp +struct ListResults { + std::optional sampleId; // the action's own parameters + morph::table::TableQuery table; // sort, filters, page; the schema marks it `x-table` +}; +struct ResultRow { std::int64_t id; std::string analysis; Quantity mass; DateTime measuredOn; }; +using ListResultsReply = morph::table::Page; // { rows, total, offset } +``` + +```cpp +auto screen = ui::screen<"lims.results">([](ui::Scope& s) { + auto sample = s.model("sample", {.instance = s.param("sampleId")}); + return ui::dataTable(sample, {.mode = ui::TableMode::Server}); +}); +``` + +The same declaration as a document node: + +```json +{ "kind": "table", "model": "sample", "action": "Lims_ListResults", "mode": "server", + "body": { "obj": { "sampleId": { "ref": "sampleId" } } } } +``` + +The `x-table` marker on the body member is how the client knows the action takes a table query and +sends sort, filters and page in it. The client derives the columns from `ResultRow`'s schema (§4), builds the sort and filter controls, +fetches pages as the view needs them, and renders the table. Nothing else is written. A node that +needs more adds to it (`columns`, `selection`, `onActivate`; §12). + +`morph::views::viewSchemaJson()` (the collection view of spec 2 §8) already derives a column list +from a query action's row type. The table node is the same declaration seen from the document: a +`collection` is a `table` node with row and collection actions, and both read the same column +metadata. + +## 3. Architecture + +```text + table node (spec 5 §9) declared in a document + │ uses + renderer list model (spec 3 §5) applies ViewChange as keyed list operations + │ reads + table::Engine (toolkit-free) sort, filter, view mapping, column layout + │ reads │ uses injected services + table::DataSource TextCollator · DateParser · StopToken · ProgressSink +``` + +### DataSource + +```cpp +namespace morph::table { +enum class ColumnKind : std::uint8_t { Integer, Decimal, Quantity, Number, Text, Date, DateTime, Bool, Key, Custom }; + +struct ColumnInfo { std::string id; ColumnKind kind; std::string comparator; /* Custom only */ }; + +using Cell = std::variant; + +class DataSource { +public: + virtual ~DataSource() = default; + [[nodiscard]] virtual std::size_t rowCount() const = 0; + [[nodiscard]] virtual std::span columns() const = 0; + [[nodiscard]] virtual RowId rowId(std::size_t row) const = 0; // stable across reloads + [[nodiscard]] virtual Cell cell(std::size_t row, std::size_t column) const = 0; + virtual void readColumn(std::size_t column, ColumnSink& sink) const; // bulk read; the default loops over cell() + virtual void subscribe(ChangeListener&) = 0; // rows added, removed, updated +}; +} +``` + +- A column is identified by id and kind. `Quantity` and `Decimal` cells are `Rational`s (an int64 + numerator and denominator with decimal places); a value that does not fit is invalid, never + saturated. Only a `Number` column holds a double. +- The engine builds keys with `readColumn`, which hands a column's cells to a sink in one pass, so + 100,000 rows do not cost 100,000 virtual calls and as many variant copies. A source with a faster + path (the query's row array) overrides it. +- `RowId` is the table's `key` (spec 5 §9): `std::variant`. +- The framework supplies **`QueryRowsSource`**, a `DataSource` over a query's row list. A refetch is + diffed by key into added, removed and updated rows; a mutation's `patch` (spec 5 §5) is a one-row + update. + +### Injected services + +Each is a small interface with a deterministic default for tests. A service used on a worker is +thread-safe, or the engine clones one per task (§8). + +| Service | Purpose | Default | Renderer-side implementation | +|---|---|---|---| +| `TextCollator` | Locale-aware compare and sort key | Code-point order, case-folded | The toolkit's collator for `{"env": "locale"}` | +| `DateParser` | Text to date or date-time for a format | ISO 8601 | The toolkit's parser | +| `StopToken` | Cancel a long sort or filter | `morph::async::StopToken` | — | +| `ProgressSink` | Optional progress | none | A progress indicator | + +### Engine + +`setFilter(FilterSpec)`, `setSort(SortChain)`, `viewRowCount()`, `sourceRowOf(viewRow)`, +`viewRowOf(sourceRow)`, and a `ViewChange` event that describes what moved, in the vocabulary of a +list model (inserted, removed, moved, changed, reset). Column order and visibility are a separate +`ColumnLayout`. Fallible calls return `std::expected` (a filter value that cannot be parsed for its +column kind is an error naming the column); the engine does not throw across its API. + +## 4. Columns + +**Derivation.** The columns of a table are derived from the row type's schema, as `v-columns` is +(spec 2 §8), one per member in `x-order`, skipping members the schema marks `x-hidden`. Each carries +what the schema knows: label (the schema's `title`, else the member name), kind, unit and its +alternatives, decimal places, enum values. + +`morph::views::deriveColumns` emits only `field`, `label`, `v-hidden`, `x-decimalPlaces` and +`ExtUnits` today. It is extended to emit `kind`, `x-unitAlternatives`, enum values and `title`, and to +honour `x-hidden`. The kind is inferred from the member's schema: `integer` is `Integer`; `number` +with `x-displayDecimals` is `Number`; a `Rational` with `x-decimalPlaces` is `Decimal`; an `ExtUnits` +type is `Quantity`; `string` with `format: date` or `date-time` is `Date` or `DateTime`; `x-widget: +epochDays` or `epochSeconds` on an integer is `Date` or `DateTime`; `boolean` is `Bool`; a string +otherwise is `Text`; `x-comparator` names a `Custom` column. + +`morph::views::ColumnOverride` grows to carry the rest, and a table node's `columns` entry accepts +the same keys: + +| Key | Meaning | +|---|---| +| `field`, `label`, `hidden` | As `ColumnOverride` has them | +| `kind` | Replaces the derived kind | +| `sortable`, `filterable` | Default `true`; `false` removes the column's sort and filter controls | +| `comparator` | Names an injected comparator for a `Custom` column; an unknown name sorts by the display text | +| `width`, `align`, `pinned` | Presentation hints; a renderer may ignore them | +| `tone` | An expression over `{"row": ...}` that yields `ok`, `warn` or `err` for the cell | +| `cell` | A document node rendering the cell (spec 5 §9), for a column the default cannot show | +| `edit`, `readonly` | Make the column editable (§11), and make it read-only by an expression | + +**Data-driven columns.** `columns` may be an expression that evaluates to a list (a query's result +mapped to column entries), for tables whose columns are data, such as per-attribute columns. The +engine rebuilds its layout when the list changes and keeps the sort and filter entries whose columns +remain. + +**Kinds and exactness.** + +| Kind | Key built | Compare | +|---|---|---| +| `Integer`, `Key` | `int64` | numeric | +| `Decimal`, `Quantity` | `Rational`; a quantity is converted to the canonical unit with checked arithmetic, and a value that overflows int64 is invalid | exact cross-multiplication in 128 bits, no allocation | +| `Number` | `double` | numeric; NaN sorts as invalid | +| `Bool` | 0 or 1 | false before true | +| `Date`, `DateTime` | `int64` days or UTC seconds | numeric | +| `Text` | Collation key from `TextCollator` | key order | +| `Custom` | Per comparator | per comparator | + +A cell that is empty or cannot be parsed for its kind is **invalid**. + +## 5. Sort + +- Each column gets a typed key array, built once per column and cached, plus a validity flag per + cell. A comparison is a direct compare of two keys with no parsing and no allocation. +- A multi-key sort is a comparator chain over the arrays; each key has its own direction. The sort is + **stable**: ties break by source row. +- **Invalid cells sort after valid ones**, in either direction. A filter on a numeric or date column + never matches an invalid cell. +- The engine caches the sorted order of the full source per sort chain. + +## 6. Filter + +A `FilterSpec` is data, so a document can hold it as a value, a user can save it, and a server can +receive it: + +```json +{ "columns": { + "analysis": { "include": [ { "contains": "pH" } ] }, + "mass": { "combine": "all", "include": [ { "ge": "1.5" }, { "lt": "10" } ] }, + "status": { "include": [ { "eq": "open" }, { "eq": "hold" } ], "exclude": [ { "eq": "void" } ] } }, + "groups": [ { "columns": ["analysis", "analyst"], "include": [ { "contains": "ph" } ] } ] } +``` + +- **Operators by kind.** Text: `contains`, `startsWith`, `endsWith`, `eq`, `ne`. Numeric, date and + date-time: `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `between`. Any kind: `isEmpty`, `notEmpty`. Values + are exact text (`"1.5"`), parsed once for the column's kind when the filter is set. +- **Combination.** The `include` entries of one column combine with `combine`: `any` (the default) or + `all`. A match on an `exclude` entry rejects the row. A column's result and every other column's + combine with AND. A **group** names several columns and matches a row when its entries match any of + them, which is what a quick-search box needs; a group combines with the columns by AND. +- **Text matching.** Text operators compare through the table's collator at primary strength, so + `eq`, `ne`, `contains`, `startsWith` and `endsWith` ignore case and accents; an entry may set + `"caseSensitive": true`. `contains`, `startsWith` and `endsWith` treat `%` and `_` as ordinary + characters. +- A filter is compiled when set. Evaluation is column at a time over a shrinking candidate set. +- The semantics above are pinned by characterization tests (§16) before an application's existing + table is moved onto the engine, so a difference is a finding, not a surprise. + +## 7. Sort, filter and change + +- A filter change with a sort active filters the cached sorted order, O(n), with no re-sort. +- A sort change with a filter active sorts only the surviving rows. +- A change notification invalidates only the affected rows' keys. The order is repaired lazily, so a + stream of row updates does not re-sort per row. +- **Reorder policy.** A table's `reorder` is `immediate` (the default) or `deferred`. With + `deferred`, an updated row that would move stays in place until the view is quiet for the + `settle` interval or the row's editor commits and loses focus; then the engine emits one `moved`. + A user editing a sorted column therefore does not see the row leave from under the cursor. +- `ViewChange` is computed from the old and new view order, so a renderer applies the minimum list + operations (spec 3 §5). + +## 8. Execution and threading + +The reactive runtime is serial and owner-affine (spec 1 §3). Where a worker thread exists, the +engine does its work off the owner: + +- **Snapshot.** The engine reads an immutable, shared snapshot of the rows. `QueryRowsSource` holds + the query's reply as shared immutable data, so taking the snapshot copies a pointer, not the rows; a + `DataSource` that cannot share its rows hands the engine a copy-on-write array. A `DataSource` is + never read from the worker. +- **Tasks.** Building keys, sorting, filtering and the `ViewChange` diff run on a worker of the + application's thread pool, one task at a time per table. A task captures the snapshot, the compiled + sort and filter and its `CallbackScope` generation, and never the engine or a signal, so a task that + outlives its table only has its result dropped by the owner's post contract. +- **Stop.** Stopping is cooperative: the task checks the `StopToken` between chunks of the key build + and inside a chunked merge sort, so a superseded task usually stops early and may run to + completion, its result then dropped by generation. +- **Services.** Collator, date parser and progress sink calls happen on the worker: a service is + thread-safe, or the engine clones one per task; `ProgressSink` notifications are posted to the owner. +- **Result.** A view mapping and its `ViewChange` are posted to the owner and applied there. `pending` + is true from the request until then, and the previous view stays visible meanwhile (like a `Query`'s + last value, spec 1 §4b). The engine never writes a signal from the worker. +- **No worker.** On WebAssembly and on a frontend with one thread, the engine runs the same tasks as + cooperative chunks on the owner, each within the owner's frame budget (default 8 ms) and yielding + between chunks; a superseding request cancels at the next boundary. The 16 ms bound of §15 holds + without threads; throughput is lower, so the browser caps `client` mode lower than native (§9). +- **Small tables** (default under 2,000 rows) compute on the owner in one step, because the hand-off + costs more than the work. +- **Large changes.** A `ViewChange` of more than a set number of operations (default 5,000) is a + `reset`, which a renderer applies as a model reset and restores selection and focus by key. + +## 9. Client and server modes + +| | `client` | `server` | +|---|---|---| +| Rows the client holds | All | The pages it has fetched | +| Sorting, filtering | The engine, on the client | The server, from the query body | +| Suits | A result that fits one envelope (below) | Any size | +| The action | Returns all rows (no table member needed) | Embeds `TableQuery`, returns `Page` | + +**The client mode bound.** An action's reply travels as one wire envelope, and `wire::decode` rejects +an envelope above `kMaxEnvelopeBytes` (8 MiB); the reply is a JSON string inside it, so escaping +shrinks the room. A table of ten small cells per row fits some tens of thousands of rows, a figure +measured in the first change and not assumed. Above it a table uses `server` mode, or `client` mode +with `pageSize`, which loads pages into the same snapshot until the result is complete. + +**Hybrid.** `mode: "hybrid"` is a server search with a client engine over its result: the `body` is +sent to the server, whose reply is a result that fits one envelope; sort and filters run on the +client over it, as in `client` mode, and a change of `body` refetches. It is the mode for a table +with a heavy server-side filter panel and a header filter row on the rows it returned. + +**`TableQuery`** is `{ sort: [{column, dir}], filters: FilterSpec, page: {offset, limit} }`. +`Page` is `{ rows, total, offset }`; `total` is optional and may be capped, since an exact count +of a large filtered set is itself expensive. The server may implement the action with the same +engine over its own rows (`table::apply(source, query)`), or with a database query, as long as it +honours the semantics of §5–6; a server that cannot honour an operator or a column's sort answers +with a typed error the table shows. + +**Limits a server applies to every `TableQuery`**, since it is an untrusted request body: + +- `page.limit` is clamped (default 1,000) and `page.offset` is bounded; the number of sort keys, + filter columns, `include` and `exclude` entries and groups, and the length of a text value, are + bounded, and a request over a bound is refused with a typed error. +- A column in `sort` or `filters` is one of the row schema's columns, from a closed set, and never an + interpolated name; values are bound parameters; the wildcard characters of §6 are escaped. +- A sort or filter column is one the principal may read. The authoriser sees the action, not the + body, so the action or the model enforces it: a hidden column is not a security control (a filter on + it is an oracle for its value). +- The server's execute timeout and in-flight limits apply; an unindexed text scan is the usual way to + exhaust them. + +**Order.** The semantics of §5–6 are normative for what a filter matches and how ties break. The order +of text is the collator's: in `server` mode the server's collator decides the order it returns, and +the client does not re-sort a page. + +In `server` mode the table is a query whose key includes the sort, the filters and the page, so +spec 5 §5's rules apply: latest wins, the last value stays while a refetch is in flight, `debounce` +batches filter typing. The view fetches the pages around the visible rows and keeps a bounded window. + +## 10. Selection and activation + +- **Selection** is a set of `RowId`s with modes `none`, `single` and `multiple`, and an `activeKey` + (the row with the cursor). Both are element values of the table (spec 5 §6) and survive sort, + filter and refetch: a row that leaves the view stays selected unless the table says + `"selection": {"pruneOnFilter": true}`. +- **Activation** (double-click, Enter) runs `onActivate` with the row in scope; it does not change the + selection. +- `viewCount`, `sourceCount` and `selectedCount` are element values, for a status line. +- In `server` mode a selected row may be outside the fetched window, so "select all" and + `pruneOnFilter` are not available; selection is the set of keys the user chose. + +## 11. Editing cells + +A column with an `edit` entry is editable: + +```json +{ "field": "mass", "edit": { "mutation": "capture", "body": { "obj": { "resultId": { "row": "id" }, + "mass": { "ref": "event" } } }, + "commitOn": "enter" } } +``` + +- The cell shows an editor chosen from the column's kind (text, number with unit, date, enum select), + built by the forms engine's field model so validation, exact encoding and bounds are the form's. +- A commit runs the named mutation. Its `patch` replaces the row; a `serialKey` of the row id keeps + one row's edits in order (spec 5 §5). The cell shows `stale` while the call is in flight and the + server's field error on failure (spec 5 §7). +- With `reorder: "deferred"` (§7) the edited row holds its place until the commit settles. +- A column without `edit` is read-only; a document may make a column editable conditionally with + `readonly` bound to an expression. + +## 12. The table node + +The node of spec 5 §9 (`{"kind": "table", "rows": ..., "key": ..., "columns": [...]}`) gains the +declarative form of §2 and these properties, all optional: + +| Property | Meaning | +|---|---| +| `model`, `action`, `body` | The list action, as a query (§2); replaces `rows` | +| `mode` | `client`, `server` or `hybrid` (§9); default `client` | +| `columns` | A list, or an expression that yields one (§4); merged over the derived columns | +| `sort` | The initial sort chain; the user's changes replace it | +| `filters` | A `FilterSpec` expression, so an element (a search box) can drive it | +| `reorder`, `settle` | §7 | +| `rowTone` | An expression over `{"row": ...}` that yields `ok`, `warn` or `err` for the row | +| `highlightKey` | A key to emphasise, for a link with a chart (spec 5 §10) | +| `columnChooser` | A header menu for the visible columns; default `true` | +| `selection` | `{"mode": "none" \| "single" \| "multiple", "pruneOnFilter": bool}`; the default mode is `none` | +| `onActivate` | Command list; the row is in scope as `{"row": ...}` | +| `pageSize` | Server mode: rows per fetched page (default 200) | + +Element values (spec 5 §6): `sort`, `filters`, `selection`, `activeKey`, `hoveredKey`, `viewCount`, +`sourceCount`, `selectedCount`, `pending`, `error`. + +**Customization points** (spec 5 §12b): `columns` (order, visibility, width), plus `sort` and +`filters` of kind `saved`, so a user keeps named sorts and filters across devices. The column +chooser edits the `columns` point: it keeps at least one column visible and offers a reset to the +document's default. + +**Filter controls.** The default node shows a header with a sort indicator per sortable column and a +filter control per filterable one, each built from the column's kind. A document that wants its own +controls sets `"controls": false` and drives `sort` and `filters` itself. + +## 13. Rendering + +- The renderer's list model applies `ViewChange` as keyed operations (spec 3 §5), so selection, + focus and scroll position survive a sort or filter. +- A table shows `pending` as a busy indicator over the previous rows and an `error` as a banner. +- The terminal renderer shows the visible window and supports sort and filter keys. + +## 14. Packaging + +Header-only, in the base `morph` target, under `include/morph/table/`: `engine.hpp`, `data_source.hpp`, +`filter.hpp`, `sort.hpp`, `query.hpp` (`TableQuery`, `Page`, `apply`), `query_rows_source.hpp`. It +depends on `morph::reactive` only for `QueryRowsSource`, which is in its own header, so the engine +itself needs the standard library and `math::Rational`. The engine wraps no toolkit, so it follows +the program's packaging rule (morph is header-only except optional components that wrap a compiled +toolkit). Compile time of the sort and filter code is measured (§17); if it is not acceptable, the +headers are split so a translation unit that only needs `TableQuery` and `Page` does not include the +engine. + +## 15. Measurement + +Performance work is driven by numbers, from two sources. + +- **Benchmarks.** Catch2 `BENCHMARK` cases tagged `[.benchmark]` over synthetic sets of 1,000, + 10,000 and 100,000 rows, with integer, decimal, quantity, accented-text and date columns and a + share of invalid cells. Scenarios: one-key numeric and text sorts, a three-key sort, text-contains, + numeric-compare and group filters, sort then filter, filter then sort, toggling one filter ten + times, and 1,000 cell updates while sorted. Results are written to `docs/` as median and spread per + scenario. +- **Traces.** Zones behind `MORPH_ENABLE_TRACY`: `table.snapshot`, `table.keyBuild`, `table.sort`, + `table.filter`, `table.apply`, `table.change`, with the sort chain and filter summary as zone text, + and plots for source rows, view rows and key-cache size. +- **Provisional targets**, to be confirmed against baselines: the owner thread is never blocked for + more than 16 ms by a table operation at any size, with or without a worker (§8); a sort or filter + of 1,000 rows completes within one frame, on the owner in one step; of 10,000 rows within 100 ms; + of 100,000 rows within 1 s, off the owner. Rows per envelope (§9) is measured in the same run. + +An application measures its own baseline on its own table before moving it, and compares against the +same scenario names. The change that adds `server` mode runs the benchmarks first, and the measured +numbers replace the provisional targets above in this spec before that change merges. + +## 16. Tests + +- **Characterization:** an application's existing sort and filter behaviour, as a corpus of + (rows, filter, sort) → expected view, run against the engine before the application moves. +- **Sort:** multi-key, direction per key, stability, accented text and case with the real collator + and a fake, dates, exact decimals and quantities across units, invalid cells last in both + directions, empty source, all-equal keys. +- **Filter:** every operator by kind, `any` and `all`, exclude, groups, a numeric filter never + matching an invalid cell, a value that does not parse for its column. +- **Change:** an update during a sort repairs lazily; `deferred` holds a row until the settle interval + or the commit; `ViewChange` is minimal for insert, remove, move and update. +- **Execution:** a superseded sort stops and its result is dropped; `pending` brackets the work; the + small-table path runs on the owner; results are identical on and off the owner. +- **Modes:** the same corpus gives the same view in `client` mode and through a server using + `table::apply`; a server error is shown; pages are fetched for the visible window. +- **Selection:** selection survives sort, filter and refetch; `pruneOnFilter`; activation does not + select. +- **Editing:** a commit runs the mutation and patches the row; field errors attach to the cell; + serial edits to one row stay ordered. +- **Document:** a derived-columns table on `RecordingBackend`; a data-driven column list; saved sorts + and filters round-trip; the Qt Quick renderer's list model receives the operations of `ViewChange`. +- **Benchmarks:** build and run under `[.benchmark]`; a regression gate on the scenarios once + baselines exist. + +## 17. Risks + +- **Header-only compile time.** The engine is template-light but large. It is measured in the first + change; splitting the headers is the fallback (§14). +- **Semantic drift from an application's existing table.** Characterization tests (§16) are the + guard; a difference is decided case by case, not absorbed. +- **Server mode needs every server to honour §5–6.** `table::apply` gives a server the reference + implementation; a database-backed server maps the operators itself and says what it cannot. +- **Collation differs between clients.** The sort key comes from the renderer's collator; a client + and a server in server mode may order accented text differently. Server mode therefore uses the + server's collator for the order it returns, and the client does not re-sort a page (§9). +- **Provisional thresholds.** The figures in §8 and §15 are targets, not measurements.