Skip to content

ui: view tree, mount and the frontend seam, with RecordingBackend - #913

Merged
Yaraslaut merged 9 commits into
masterfrom
feature/888-ui-view-tree
Oct 10, 2026
Merged

Yaraslaut merged 9 commits into
masterfrom
feature/888-ui-view-tree

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Closes #888

Part 3 of the declarative UI program (#885): morph::ui's view tree, its mount, the backend contract with RecordingBackend and the conformance cases, and the frontend seam of spec 1 §5b. The UI document, expr/1 and the interpreter are part 5 (#890).

Summary

  • Plan first. docs/superpowers/plans/2026-10-10-ui-view-tree.md was written against the specs on master and is the first commit. It takes the reviewed part-2 implementation on feature/declarative-ui (69a18881) as the base, and lists eight revisions (R1–R8) where that code and the specs disagree, plus three spec amendments (S1–S3).
  • view.hpp. An immutable view tree with spec 5 §9's base kinds. Every property is a constant or a slot. ui::slot(id, read) gives a slot the stable id a code-generating renderer refers to; Prop::slotId() reads it. Every event is a command sink (std::function).
  • backend.hpp. IViewBackend has one typed factory per kind. The contract now says a setter called with the value the widget already shows changes nothing (a text cursor stays put), and that input widgets are controlled.
  • mount.hpp (Mounted).
    • Switch cases, Tabs pages, Dialog content and ForEach/Table rows mount all or nothing into child scopes (depth + 1). They are torn down child-first, and owner Effects run before content bindings. Mounted takes the depth of the scope it is placed under.
    • Rows keep their identity per key and reorder with the fewest moves.
    • Controlled inputs: after a user request, a turn posted behind the event's flush shows the slot if the widget shows anything else. A refused edit snaps back. An accepted edit costs no setter call. A transformed edit shows the raw text for one turn. A constant is not a slot.
  • frontend.hpp.
    • The seam types: AppContext, AppSource (open / switchBackend / close), Bundle, ConnectError, Backend, Frontend::run(AppSource&) and FrontendOption.
    • selectFrontend(span, std::vector<std::string>&, env) returns std::expected<…, FrontendError>. It removes the --ui arguments it used and leaves args alone on an error.
    • runApp fixes the teardown order for every frontend: shell, then close(), then the runtime (which the frontend destroys after runApp returns).
  • testing/recording_backend.hpp, testing/backend_conformance.hpp. RecordingBackend now models a text cursor (moveCursor, cursor) and closes a dialog on dismissal, as a toolkit does. There are 26 conformance cases; three are new: unchanged-text keeps the cursor, a text field is controlled, a refused dismissal keeps the dialog open.
  • Wiring. All six headers are in the morph FILE_SET HEADERS; the configure-time drift guard caught a missing one while I worked. Nine test files are registered in morph_tests, which is where the reactive tests are, as spec 1 §7 asks. Also updated: docs/spec/ui/{view_tree,backend_contract,frontend}.md, the docs/spec/README.md map, ARCHITECTURE.md, and CHANGELOG.md [Unreleased].

Spec amendments (said on #888 too)

  • S1, spec 1 §5b: AppSource::close(). §5b requires that "screens and connections are destroyed before the runtime", and run builds the runtime. But open makes the connections, and the source is main's and outlives run, so nothing in the interface let run end them. close() releases what open made and drops a ready that has not been delivered yet.
  • S2, spec 1 §5: the palette this part carries.
    • It carries spec 5 §9's base kinds, with the properties a widget needs to show and edit them.
    • Deferred to the interpreter that maps them (ui: the UI document — validation, expr/1, interpreter, server-side authoring #890) or the renderer that needs them, each adding setters or factories to IViewBackend:
      • the further kinds (banner … boundary);
      • the extra common properties (a11y, testId, tooltip, keys, autofocus, surface);
      • the input decorations;
      • richer menu items.
    • Until then the interpreter lowers a further kind to its fallback.
  • S3, spec 1 §2: host.hpp lands with hosting (ui: application hosting — screen lifecycle, ScreenHost, app root scope, design package, workspace shell #893), not here. Spec 6 §4's ParamMap holds the document's Value (spec 5 §3), which part 5 defines, and its implementers are the Qt Quick renderer and the hosting layer. Landing it here would mean inventing Value.
  • Spec 4's main sketch now uses spec 1's selectFrontend signature (it called selectFrontend(built, argc, argv)->run(...)).
  • Not done here: sink ids for events (spec 3's a.fire(<sink>)) are left to the generator (qt_quick: morph::qt_quick — QML generator and renderer, native and WebAssembly #891), which is the only reader.

What was measured, and on which configuration

macOS 26 (arm64), Apple clang 17, Debug, -Weverything -Werror (morph: warnings: '-Weverything' verified), -DMORPH_BUILD_EXAMPLES=OFF, one -j 4 build at a time.

  • Full morph_tests:
    test cases:  2215 |  2214 passed | 1 failed as expected
    assertions: 26070 | 26069 passed | 1 failed as expected
    
    The [ui] subset: All tests passed (1274 assertions in 154 test cases).
  • Baseline. The 69a18881 code, with only the test-kit includes and the removed kFlushInWidgetEvent counts changed, passed all 134 of its [ui] cases against master's morph::reactive before any revision.
  • Mutations. Each was applied, rebuilt, run against its test, and reverted; every one made its test fail.
    • Child-first remount: Scope::clear destroys oldest first → 11 of 154 [ui] cases fail (the switch/tabs/dialog remount logs among them).
    • ForEach identity: reconcile unmounts every row on each change → the in-place-update and minimal-move log checks fail (test_ui_foreach.cpp:390, 447, 451, 452).
    • Minimal moves: move every widget on a reorder → the randomized moves == n − LIS check fails.
    • Owner order: content scopes at depth 0, and separately Mounted ignoring its depth → the owner-first test sees kEffectThrew == 1.
    • Slots: drop the id in the SlotBinding constructor → the slot-id test fails.
    • Controlled inputs (M1–M11): no posted turn; sending a value already shown; re-asserting unconditionally (4 failures across [ui]); checking inside the event instead of a posted turn ("AB" == "ab"); re-asserting constants; editable slot without a handler; strong capture in the posted turn; checkbox and Tabs not controlled; Dialog re-opened without content; fake cursor moved on every setText.
    • Frontend seam (F1–F9): close before the shell; a loop's exception escaping before teardown; a strong ready with no opened mark; a second ready accepted; the factory's exception escaping into the source; erasing one or front-to-back; erasing before validating; MORPH_UI over --ui.
  • Docs. cmake --build build-doc --target doc (Doxygen 1.18.0, WARN_AS_ERROR) exit 0; morph::ui compounds generated.
  • Clang 22.1.8 (Homebrew), every new header standalone, with the project's -Weverything set: clean. The one finding, -Wnrvo in inputEvent, was fixed, although the project suppresses -Wnrvo on compilers that support it.
  • clang-tidy 22.1.8 over the ui test TUs. Every finding inside include/morph/ui and tests/test_ui_* is fixed. Findings in pre-existing core headers (logger.hpp, bridge.hpp, …) appear only in this local macOS run and are on lines this PR does not touch.
  • clang-format 22. --dry-run -Werror on every changed C++ file: clean.

Not verified locally:

  • ASan. The -DAF_SANITIZER=asan build compiled and is instrumented (243 compile lines with -fsanitize=address, __asan_report* symbols present). The binary hangs at startup in __asan::InitializeShadowMemory → dyld_shared_cache_iterate_text_swift → malloc → AsanInitFromRtl (recursive init in Apple clang 17's ASan runtime on this macOS), even for a one-assertion test. That is a toolchain problem, not morph's; the lifetime claims (the weakly held re-assert check, a ready after the run) are left to CI's clang-asan job.
  • GCC, MSVC, Linux, TSan, check_install_export.sh, and the branch-coverage floor.

Inline review

  • Where the risk is. The new mechanism is the controlled-input re-assertion, so I reviewed it against three failure shapes:
    • Use after free. The check is scope-owned and made after the binding, so it is destroyed first. The event and the posted turn hold it weakly, and two tests pin the "gone before the event returns" and "gone by the turn" paths.
    • Loops. A Dialog whose content failed records open=false. The re-assertion does not reopen it without content, and does not re-mount, so a refusing application cannot make it spin; there is a test for this.
    • Redundant setter calls. The mount records what each widget shows. Both the binding and the turn skip a value already shown, so an accepted edit is free. One existing table test changed from expecting set Table#1 selection=[2] to an empty log, because the table already shows the user's selection.
  • Behaviour change from the base.
    • A bound input no longer keeps an edit the application ignores. That is spec 1 §5 and spec 3 §7, and two existing cases now write the user's value back: the Multiple-mode Table conformance case and one RecordingBackend dialog test.
    • RecordingBackend::dismiss now closes the dialog before calling the handler, as Qt and the TUI do.
  • Owner order. Content created by an Effect is always younger than that Effect, so inside one Mounted the depth rule changes nothing measurable. The test therefore uses an outer owner made after the view, which is how the interpreter's screen scope will sit, and both mutations fail it.
  • runApp versus spec text. run "mounts the app shell". Here that is a ShellFactory the frontend supplies, which also receives a ConnectError so the frontend can show it as a view. Part 5 provides the interpreter-backed factory.
  • Left as is. Spec 1 §7 says the reactive/control/ui tests are in the base test tree, so the ui tests stay in morph_tests rather than getting a separate executable like morph_table_tests.

🤖 Generated with Claude Code

Yaraslaut and others added 7 commits October 10, 2026 17:45
The part-3 plan of the declarative UI program, written against the specs
as on master. It starts from the reviewed view-tree implementation on
feature/declarative-ui and lists where that code and the specs disagree:
child scopes for content and rows, slots with ids, controlled inputs,
setters that change nothing, and the frontend seam of spec 1 §5b. It also
names three spec amendments the part needs: AppSource::close(), the
palette this part carries, and host.hpp landing with hosting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
morph::ui, header-only in the base morph target. An immutable view tree
of the spec 5 base palette, whose props are constants or reactive
bindings; Mounted builds retained widgets through an IViewBackend once
and keeps them current through equality-gated bindings, with Switch,
Tabs, Dialog and keyed ForEach/Table rows mounted into scopes of their
own, so teardown is bindings before widgets and children before
parents, and a content mount is all or nothing. ForEach and Table keep
one row per key, update kept rows in place and reorder with the fewest
moveChild calls.

RecordingBackend is the headless reference backend: an operation log, a
golden dump and the click/edit/choose/toggle/dismiss/drag helpers, which
act only on a widget a user could reach. The conformance cases fix the
contract every renderer implements.

This is the reviewed implementation from feature/declarative-ui, built
against morph::reactive as on master: the tests take the owner probe
from morph/testing/, and no longer count a report for a flush deferred
inside a widget event, which the runtime now defers silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Switch case, a Tabs page, a Dialog's content and every ForEach or Table
row now mount into a scope one level deeper than the binding that mounts
them, and Mounted takes the depth of the scope it is placed under. The
runtime runs queued Effects owners first, so an Effect that takes content
away runs before that content's bindings in the same flush, even when
the content is older, and a binding never evaluates against state its
owner is removing.

A bound property is a slot: ui::slot(id, read) gives it the stable id a
code-generating renderer refers to, a bare callable gives it none, and
Prop::slotId() reads it. Mounted ignores the id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An input whose value is a slot now shows what the slot says after a
user's request: a text input, checkbox, select, slider, date-time input,
file picker, collapsible panel, Tabs index, Dialog open and Table
selection. The mount records what each widget shows; after the event, a
turn posted behind the event's flush shows the slot if the widget shows
anything else. A refused request snaps back, an accepted one costs no
setter call, and a transformed one shows the request for that one turn.
A slot without a handler is read-only. A constant is not a slot and the
widget keeps what the user did, as a QML literal does. The check is held
weakly, so a turn posted for content that is gone does nothing; a
Dialog whose content failed stays closed rather than opening empty.

The backend contract says a setter given the value a widget shows
changes nothing, a text input's cursor included. RecordingBackend models
a text cursor (moveCursor, cursor) and closes a dialog on dismissal
before onDismiss runs, as a toolkit does. Three conformance cases pin
the new rules, and the multiple-selection Table case writes the user's
selection back, as a controlled table needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntend

The seam of spec 1 §5b. A Frontend runs whatever an AppSource opens: a
remote server or the application linked in-process. The source delivers a
Bundle of verified documents, or a ConnectError, through a ready callback
on the owner; switchBackend replaces the dispatch target under mounted
screens; close releases what open made. Backend is the application's
opaque backend handle.

runApp fixes, once for every frontend, the order a run keeps: open the
source, mount the shell when it is ready, run the loop, then unmount the
shell and close the source before the frontend's runtime goes, whether
the loop returns or throws. A second ready is ignored, a ready after the
run ended does nothing, and a shell factory that throws quits with 1 and
rethrows after the teardown.

selectFrontend takes --ui=<name> or --ui <name> (the last before -- wins),
then MORPH_UI, then the first usable option, and returns the frontend or a
FrontendError naming the built ones. It removes the --ui arguments it used
and leaves the arguments alone on an error. A Query's refreshEvery runs
on AppContext::scheduler().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/spec/ui/view_tree.md, backend_contract.md and frontend.md state
what the headers do: slots with ids, the base palette and where the
document's further kinds go, child scopes and owner order, controlled
inputs, the setter rule a text cursor depends on, AppSource and runApp's
teardown order, and selectFrontend's argument handling. The spec map,
ARCHITECTURE and the changelog list the new subsystem.

Three spec amendments the part needed, in spec 1: AppSource gains
close(), so a run can end the source's connections before its runtime
goes; §5 says which palette the view tree carries now and how the rest
joins; §2 says host.hpp lands with hosting, since its ParamMap holds the
document's Value. Spec 4's main sketch follows spec 1's selectFrontend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A slot's read is checked for emptiness after it is stored, which the
static analyzer follows without seeing a temporary escape; slotId()
reads the variant without a throwing access, as its noexcept promises;
the slot pointer in a controlled binding is const; moveCursor's id and
position carry the reason they are adjacent. In the tests, the fake
source takes its options through its constructor, and handlers move or
borrow the text they get. inputEvent returns its two results as
prvalues, which clang 22's -Wnrvo prefers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Yaraslaut and others added 2 commits October 10, 2026 19:20
GCC 15's -Wdangling-pointer read the conformance case's two braced
option lists in a conditional as temporaries escaping the lambda. They
did not: each initializer list is copied into the returned vector. The
list is now built by name, so there is no temporary to misread.

GCC 15's -Wstrict-overflow flagged the reverse inlined from
std::ranges::next_permutation in the reorder test at -O2, a libstdc++
false positive. The test now names each of the 120 permutations by its
mixed-radix rank and checks it saw 120 different ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… floor

The conformance suite is now run against probes that break the contract
on purpose: one whose containers hand out no children, one whose widgets
show other text, one whose user actions reach nothing, and a backend
whose Select reports a choice when its options change. Each must fail
the cases that check that part of the contract, which covers the
suite's failure paths and shows it detects something. The cases read a
mounted container's root through rootContainer, a downcast its
factory's type guarantees, instead of a null check no backend can
reach.

RecordingBackend gains tests for every enumerator's name and the
unknown fallback, escaping a carriage return, an empty date, unknown
ids, moving or keying a stray widget, a select asked for no key, a
hidden field's cursor, a duplicate row selection and drops that must
not land. Mounted gains tests for constant enabled and drag keys, a menu
entry without a handler or out of range, a constant Tabs index, a
ForEach without a model, a Dialog without onDismiss whose content fails,
and two rows failing in one run.

include/morph/ui gets a branch floor of 92%, three points below the
95.05% (614 of 646 arms) measured with a clang 22 coverage build of the
ui tests, the same build that reproduced CI's 569 of 660 before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 10, 2026

Copy link
Copy Markdown

@Yaraslaut
Yaraslaut merged commit ca89886 into master Oct 10, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ui: view tree, mount and the frontend seam, with RecordingBackend

1 participant