|
| 1 | +# Design doc: window + app-main test members for compat.eui-neo |
| 2 | + |
| 3 | +Date: 2026-07-30 |
| 4 | + |
| 5 | +Follows `.agents/docs/2026-07-29-add-eui-neo-plan.md`, which landed `compat.eui-neo` |
| 6 | +0.5.3 with four workspace members (`eui-neo`, `eui-neo-markdown`, `eui-neo-vulkan`, |
| 7 | +`eui-neo-sdl2`). |
| 8 | + |
| 9 | +## Gap |
| 10 | + |
| 11 | +All four existing members are **headless by construction**. They assert on |
| 12 | +`eui::json::Document`, `core::platform::` frame flags, `components::detail::` |
| 13 | +markdown blocks and — structurally, via `nm` — which backend the dispatch TUs |
| 14 | +reference. None of them: |
| 15 | + |
| 16 | +* creates a window, |
| 17 | +* initialises the OpenGL render backend, |
| 18 | +* enters a render loop, or |
| 19 | +* renders text (the freetype path). |
| 20 | + |
| 21 | +Two consequences: |
| 22 | + |
| 23 | +1. `compat.eui-neo` shipped an `app-main` feature that **nothing built**. Upstream's |
| 24 | + `core/app/glfw_app_main.cpp` is 584 lines of platform code and was compiled by |
| 25 | + zero members on zero platforms. |
| 26 | +2. A consumer that drives EUI itself — `core::window::createWindow()` + |
| 27 | + `core::render::createRenderBackend()` + `app::update`/`app::render` — was never |
| 28 | + exercised, so it was unknown whether that surface is even reachable through the |
| 29 | + descriptor's `include_dirs`. |
| 30 | + |
| 31 | +This adds one member per entry-point shape. They are the two shapes an EUI |
| 32 | +application can have, and they are mutually exclusive per project (features resolve |
| 33 | +per consuming project). |
| 34 | + |
| 35 | +## Members |
| 36 | + |
| 37 | +| member | `app-main` | entry point | new coverage | |
| 38 | +|---|---|---|---| |
| 39 | +| `eui-neo-window` | off | hand-written `main()` in the test TU | `core::window`, OpenGL backend, `ScopedRenderBackend`, paced loop, freetype text | |
| 40 | +| `eui-neo-app-main` | **on** | upstream `glfw_app_main.cpp` | the feature's source compiles; `main` really comes from the package | |
| 41 | + |
| 42 | +Both are `[target.'cfg(linux)']`-gated, matching `imgui-window`, `gui-stack` and the |
| 43 | +rest of the X11/GLFW stack in this index. Off-Linux they declare no dependencies and |
| 44 | +the test TU compiles to `int main() { return 0; }`. |
| 45 | + |
| 46 | +Both declare `compat.glfw` **directly** rather than leaning on the transitive edge: |
| 47 | +each calls GLFW itself (`glfwPollEvents`/`glfwWindowShouldClose` in one, |
| 48 | +`glfwSetWindowShouldClose` in the other), because EUI's `core::window` facade |
| 49 | +exposes no event-pump entry point. |
| 50 | + |
| 51 | +## Why the run is opt-in |
| 52 | + |
| 53 | +A window needs a display; CI runners have none. Same answer as `imgui-window`: |
| 54 | +build + link is the headless test, and `MCPP_RUN_WINDOW=1` opts into the real run. |
| 55 | + |
| 56 | +``` |
| 57 | +$ mcpp test -p tests/examples/eui-neo-window # headless CI |
| 58 | +compat.eui-neo[own main]: linked; window run is opt-in (MCPP_RUN_WINDOW=1) |
| 59 | +
|
| 60 | +$ MCPP_RUN_WINDOW=1 mcpp test -p tests/examples/eui-neo-window |
| 61 | +compat.eui-neo[own main]: ok (60 frames in 0.98s, 120 rect / 120 text draws) |
| 62 | +``` |
| 63 | + |
| 64 | +`eui-neo-app-main` cannot put that gate in `main()` — it does not have one. It goes in |
| 65 | +a **namespace-scope constructor**, which runs before the package's `main()`: |
| 66 | + |
| 67 | +```cpp |
| 68 | +struct WindowRunGate { WindowRunGate() { if (!getenv("MCPP_RUN_WINDOW")) exit(0); } }; |
| 69 | +const WindowRunGate g_gate; |
| 70 | +``` |
| 71 | +
|
| 72 | +Both runs are bounded by a 60-frame budget so the opt-in path terminates on its own |
| 73 | +instead of waiting for a human to close the window. |
| 74 | +
|
| 75 | +## Why `app-main` cannot carry tests — and what that pins |
| 76 | +
|
| 77 | +mcpp links a dependency's objects **eagerly**, not as lazily-selected archive members. |
| 78 | +`glfw_app_main.o` is therefore always in the link, not only when `main` is still |
| 79 | +undefined. Verified by adding a `main()` to an `app-main` project: |
| 80 | +
|
| 81 | +``` |
| 82 | +ld: obj/glfw_app_main.o: in function `main': |
| 83 | + core/app/glfw_app_main.cpp:398: multiple definition of `main'; |
| 84 | + obj/probe.o: first defined here |
| 85 | +``` |
| 86 | +
|
| 87 | +So an `app-main` project may not contain **any** TU of its own that defines `main` — |
| 88 | +including every `mcpp test` TU. `eui-neo-app-main` is built around that: no `main()`, |
| 89 | +per `tests/examples/catch2-main`. This makes the member a two-sided assertion: |
| 90 | +
|
| 91 | +* if `app-main` stops contributing its source → `undefined reference to 'main'` here; |
| 92 | +* if `app-main` stops being opt-in → `multiple definition of 'main'` in *every other* |
| 93 | + member. |
| 94 | +
|
| 95 | +The descriptor comment on the feature is updated to say this; the previous wording |
| 96 | +("a consumer that has its own main() must not get a second one") describes the intent |
| 97 | +but understates the consequence. |
| 98 | +
|
| 99 | +## A green test is not a rendered frame |
| 100 | +
|
| 101 | +Per the `eui-neo` plan's "the library really is built" section: a frame counter only |
| 102 | +proves `app::update()` ran. `app::update()` reaches `compose()` and the `.onFrame()` |
| 103 | +hook without touching the render backend at all, so a member counting frames would |
| 104 | +pass against a null backend — which is exactly the failure mode the first revision of |
| 105 | +this descriptor shipped (`createRenderBackend()` returning null behind a green CI). |
| 106 | +
|
| 107 | +Both members therefore assert on `core::render::lastRenderFrameStats()` and require |
| 108 | +`rectDraws > 0` **and** `textDraws > 0` over the frame budget. `textDraws` is split out |
| 109 | +from `rectDraws` on purpose: text is the only path that needs freetype plus a usable |
| 110 | +system font, and "no font at any of upstream's Linux fallback paths" is a different |
| 111 | +failure from "dead backend". |
| 112 | +
|
| 113 | +## Loop mechanics worth recording |
| 114 | +
|
| 115 | +Two non-obvious things the loop in `eui-neo-window` has to get right, both discovered |
| 116 | +by running it: |
| 117 | +
|
| 118 | +* **Pacing is mandatory.** `OpenGLRenderBackend::initialize()` calls |
| 119 | + `glfwSwapInterval(0)`, so `present()` never blocks on vsync. An unpaced loop ran at |
| 120 | + ~8300 fps and pinned a core; the member paces off `DslAppConfig::fpsValue`. |
| 121 | +* **`onFrame` fires twice per rendered frame.** `app::update()` runs the runtime a |
| 122 | + second time with `deltaSeconds == 0` to settle the re-compose that `onFrame` itself |
| 123 | + requested (`dsl_app_impl.h`, the `composeRequested()` branch). Counting every |
| 124 | + invocation reports 2x the real frame rate — gate on the delta. |
| 125 | +
|
| 126 | +Neither is a descriptor bug; both are upstream behaviour a consumer must know, and |
| 127 | +neither was discoverable from the headless members. |
| 128 | +
|
| 129 | +## Verification |
| 130 | +
|
| 131 | +Local, mcpp **0.0.109** (matching `validate.yml` `env.MCPP_VERSION`), linux-x86_64, |
| 132 | +gcc 16.1.0, X11 display with NVIDIA GL 4.6. |
| 133 | +
|
| 134 | +``` |
| 135 | + headless MCPP_RUN_WINDOW=1 |
| 136 | +eui-neo-window ok (0.08s) ok (60 frames, 120 rect / 120 text draws) |
| 137 | +eui-neo-app-main ok (0.07s) ok (60 frames, 118 rect / 118 text draws) |
| 138 | +eui-neo (existing) ok |
| 139 | +eui-neo-markdown (existing) ok |
| 140 | +``` |
| 141 | +
|
| 142 | +`mcpp xpkg parse`, `check_mirror_urls.lua`, `check_package_name.lua` and |
| 143 | +`check_cross_package_refs.lua` all pass on the touched descriptor. The descriptor |
| 144 | +change is comment-only. |
| 145 | +
|
| 146 | +## Not done |
| 147 | +
|
| 148 | +* `app-main-sdl2` stays uncovered. It is the same shape against `compat.sdl2`, and |
| 149 | + covering it means a fifth window member for one `sources` line; worth doing if the |
| 150 | + SDL2 window backend gains real users. |
| 151 | +* macOS/Windows only get the build+link half, same as `imgui-window`. The opt-in run |
| 152 | + has been exercised on linux-x86_64 only. |
0 commit comments