Repository navigation
ui: view tree, mount and the frontend seam, with RecordingBackend - #913
Merged
Merged
Conversation
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>
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 Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #888
Part 3 of the declarative UI program (#885):
morph::ui's view tree, its mount, the backend contract withRecordingBackendand the conformance cases, and the frontend seam of spec 1 §5b. The UI document,expr/1and the interpreter are part 5 (#890).Summary
docs/superpowers/plans/2026-10-10-ui-view-tree.mdwas written against the specs onmasterand is the first commit. It takes the reviewed part-2 implementation onfeature/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.IViewBackendhas 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).Mountedtakes the depth of the scope it is placed under.frontend.hpp.AppContext,AppSource(open/switchBackend/close),Bundle,ConnectError,Backend,Frontend::run(AppSource&)andFrontendOption.selectFrontend(span, std::vector<std::string>&, env)returnsstd::expected<…, FrontendError>. It removes the--uiarguments it used and leavesargsalone on an error.runAppfixes the teardown order for every frontend: shell, thenclose(), then the runtime (which the frontend destroys afterrunAppreturns).testing/recording_backend.hpp,testing/backend_conformance.hpp.RecordingBackendnow 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.morphFILE_SET HEADERS; the configure-time drift guard caught a missing one while I worked. Nine test files are registered inmorph_tests, which is where thereactivetests are, as spec 1 §7 asks. Also updated:docs/spec/ui/{view_tree,backend_contract,frontend}.md, thedocs/spec/README.mdmap,ARCHITECTURE.md, andCHANGELOG.md[Unreleased].Spec amendments (said on #888 too)
AppSource::close(). §5b requires that "screens and connections are destroyed before the runtime", andrunbuilds the runtime. Butopenmakes the connections, and the source ismain's and outlivesrun, so nothing in the interface letrunend them.close()releases whatopenmade and drops areadythat has not been delivered yet.IViewBackend:a11y,testId,tooltip,keys,autofocus,surface);host.hpplands with hosting (ui: application hosting — screen lifecycle, ScreenHost, app root scope, design package, workspace shell #893), not here. Spec 6 §4'sParamMapholds the document'sValue(spec 5 §3), which part 5 defines, and its implementers are the Qt Quick renderer and the hosting layer. Landing it here would mean inventingValue.mainsketch now uses spec 1'sselectFrontendsignature (it calledselectFrontend(built, argc, argv)->run(...)).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 4build at a time.morph_tests:[ui]subset:All tests passed (1274 assertions in 154 test cases).69a18881code, with only the test-kit includes and the removedkFlushInWidgetEventcounts changed, passed all 134 of its[ui]cases againstmaster'smorph::reactivebefore any revision.Scope::cleardestroys oldest first → 11 of 154[ui]cases fail (the switch/tabs/dialog remount logs among them).test_ui_foreach.cpp:390, 447, 451, 452).moves == n − LIScheck fails.Mountedignoring its depth → the owner-first test seeskEffectThrew == 1.SlotBindingconstructor → the slot-id test fails.[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 everysetText.closebefore the shell; a loop's exception escaping before teardown; a strongreadywith noopenedmark; a secondreadyaccepted; the factory's exception escaping into the source; erasing one or front-to-back; erasing before validating;MORPH_UIover--ui.cmake --build build-doc --target doc(Doxygen 1.18.0,WARN_AS_ERROR) exit 0;morph::uicompounds generated.-Weverythingset: clean. The one finding,-WnrvoininputEvent, was fixed, although the project suppresses-Wnrvoon compilers that support it.include/morph/uiandtests/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.--dry-run -Werroron every changed C++ file: clean.Not verified locally:
-DAF_SANITIZER=asanbuild 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'sclang-asanjob.check_install_export.sh, and the branch-coverage floor.Inline review
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.set Table#1 selection=[2]to an empty log, because the table already shows the user's selection.RecordingBackenddialog test.RecordingBackend::dismissnow closes the dialog before calling the handler, as Qt and the TUI do.Mountedthe 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.runAppversus spec text.run"mounts the app shell". Here that is aShellFactorythe frontend supplies, which also receives aConnectErrorso the frontend can show it as a view. Part 5 provides the interpreter-backed factory.morph_testsrather than getting a separate executable likemorph_table_tests.🤖 Generated with Claude Code